Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/api/src/harness/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,32 @@ describe('the chat routes', () => {
]);
});

test('show what the persona started or sent without a word under its next words in the turn, or where the turn ended', async () => {
const {handleGetThreadRoute} = routes;
const later = new Date('2026-10-21T10:00:05.000Z');
store.listSubAgents.mockResolvedValue(Ok([subAgent({id: 'sub-1', parentCallId: 'c1', title: 'Weekly check'})]));
store.listShownEntries.mockResolvedValue(
Ok([
logEntry({id: 'e1', seq: 1, kind: 'assistant', text: '', toolCalls: [call('c2', 'send_to_subagent', {subagent: 'sub-1', text: 'Run it'})]}),
logEntry({id: 'e2', seq: 3, kind: 'assistant', text: 'I’m running the check.', createdAt: later}),
logEntry({id: 'e3', seq: 5, kind: 'assistant', text: 'All quiet.', createdAt: later}),
logEntry({id: 'e4', seq: 6, kind: 'assistant', text: '', toolCalls: [call('c3', 'send_to_subagent', {subagent: 'sub-1', text: 'Again'})]}),
logEntry({id: 'e5', seq: 8, kind: 'assistant', text: '', createdAt: later}),
logEntry({id: 'e6', seq: 9, kind: 'assistant', text: 'Here it is again.', createdAt: later}),
]),
);

const log = (await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap();

expect(log.entries).toEqual([
{id: 'e2', kind: 'assistant', text: 'I’m running the check.', isError: false, at: '2026-10-21T10:00:05.000Z'},
{id: 'e1:c2', kind: 'sent', task: {id: 'sub-1', title: 'Weekly check'}, at: '2026-10-21T10:00:05.000Z'},
{id: 'e3', kind: 'assistant', text: 'All quiet.', isError: false, at: '2026-10-21T10:00:05.000Z'},
{id: 'e4:c3', kind: 'sent', task: {id: 'sub-1', title: 'Weekly check'}, at: '2026-10-21T10:00:05.000Z'},
{id: 'e6', kind: 'assistant', text: 'Here it is again.', isError: false, at: '2026-10-21T10:00:05.000Z'},
]);
});

test('give a task’s chat: the persona’s brief and messages, the viewer’s own, and what the task answered and told the persona, without its calls, and its failures', async () => {
const {handleGetTaskRoute} = routes;
const task = subAgent({id: 'sub-1', parentCallId: 'c1', title: 'Find flights', taskId: 'task-7', state: {kind: 'stopped'}});
Expand Down
33 changes: 24 additions & 9 deletions packages/api/src/harness/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,34 @@ function callEntries(entry: EntryRow, call: ToolCall, subAgents: SubAgentRow[]):
return to ? [{id, kind: 'sent', task: toTask(to), at}] : [];
}

// What an entry of the conversation shows (db/harness listShownEntries): what the person said;
// what the persona answered in words, then the tasks it started and the messages it sent them.
function conversationEntries(subAgents: SubAgentRow[]): (entry: EntryRow) => ChatEntry[] {
return (entry) => {
// What the conversation shows (db/harness listShownEntries): what the person said; what the
// persona answered in words, then the tasks it started and the messages it sent them. What it
// started or sent without a word is shown under its next words in the turn, which say what it
// did, as of their time, or where the turn ended.
function conversationEntries(entries: EntryRow[], subAgents: SubAgentRow[]): ChatEntry[] {
const shown: ChatEntry[] = [];
let held: ChatEntry[] = [];
for (const entry of entries) {
if (entry.kind === 'person') {
return [toMessage({...entry, kind: 'person'})];
shown.push(...held, toMessage({...entry, kind: 'person'}));
held = [];
continue;
}
if (entry.kind !== 'assistant') {
return [];
continue;
}
const calls = entry.toolCalls ?? [];
const lines = calls.flatMap((call) => callEntries(entry, call, subAgents));
if (entry.text === '' && calls.length > 0) {
held.push(...lines);
continue;
}
const at = entry.createdAt.toISOString();
const said = entry.text === '' ? [] : [toMessage({...entry, kind: 'assistant'})];
return [...said, ...(entry.toolCalls ?? []).flatMap((call) => callEntries(entry, call, subAgents))];
};
shown.push(...said, ...held.map((line) => ({...line, at})), ...lines);
held = [];
}
return [...shown, ...held];
}

// What an entry of a task's chat shows (db/harness listShownSubAgentEntries): what the persona
Expand Down Expand Up @@ -341,7 +356,7 @@ function handleGetThreadRoute(store: ChatStore, harnesses: Harnesses, request: A
const state = threadState($(jobs), now);
return {
persona: toPersona(harnesses, viewer, persona),
entries: $(entries).flatMap(conversationEntries($(subAgents))),
entries: conversationEntries($(entries), $(subAgents)),
busy: state.kind === 'working',
draft: $(draft),
};
Expand Down
9 changes: 9 additions & 0 deletions packages/db/src/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,15 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => {
await db.agentEntry.create({
data: {threadId: conversation.id, kind: 'event', callId: 'c1', text: 'Two found.'},
});
// An answer to it without a word ends the turn, which the conversation shows where it is.
await db.agentEntry.create({data: {threadId: conversation.id, kind: 'assistant', text: ''}});
expect(
unwrap(await listShownEntries(conversation.id, [])).map((entry) => [entry.kind, entry.text]),
).toEqual([
['person', 'Find me flights'],
['assistant', 'On it.'],
['assistant', ''],
]);
const [subRow] = unwrap(await listSubAgents(conversation.id));
if (!subRow) {
throw new Error('expected the sub-agent');
Expand Down
19 changes: 13 additions & 6 deletions packages/db/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,8 +734,9 @@ function withCall(tool: string): Prisma.AgentEntryWhereInput {
/**
* The thread's turns as the person sees them, what was said and what was answered in words, and
* the answers that made a call of one of the tools in `calls`, which the person sees as what the
* call did; not the other calls, their results, the reports, the notes or the summaries: the
* log's, then what is pending, each in order.
* call did; and the answers that end a turn without a word, which show nothing but where the turn
* ended; not the other calls, their results, the reports, the notes or the summaries: the log's,
* then what is pending, each in order.
*/
export async function listShownEntries(
threadId: string,
Expand All @@ -746,7 +747,12 @@ export async function listShownEntries(
db.agentEntry.findMany({
where: {
threadId,
OR: [{kind: 'person'}, {kind: 'assistant', text: {not: ''}}, ...calls.map(withCall)],
OR: [
{kind: 'person'},
{kind: 'assistant', text: {not: ''}},
{kind: 'assistant', toolCalls: null},
...calls.map(withCall),
],
},
select: entrySelect,
orderBy: [{pending: 'asc'}, {seq: 'asc'}],
Expand All @@ -756,9 +762,10 @@ export async function listShownEntries(
}

/**
* A sub-agent's chat, as the member sees it: its thread's turns as `listShownEntries` has them, and
* what of what it told the thread it was started from was an error, its failures, which only that
* thread's log has: the logs', then what is pending, each in order.
* A sub-agent's chat, as the member sees it: its thread's turns as `listShownEntries` has them,
* but for the answers that end a turn without a word, and what of what it told the thread it was
* started from was an error, its failures, which only that thread's log has: the logs', then what
* is pending, each in order.
*/
export async function listShownSubAgentEntries(
subAgent: ThreadRow,
Expand Down
Loading