diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index fd2f5840..1bf11817 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -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'}}); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 55a7ebc4..91d76f0c 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -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 @@ -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), }; diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index 6130844c..629c05c5 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -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'); diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 6e9ad353..bf296b7d 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -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, @@ -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'}], @@ -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,