diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts index ec81136688e..ea4cebab150 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -52,8 +52,8 @@ export function staleSubagentRosterRevisions( ): JournalSubagentLivenessRevision[] { const revisions: JournalSubagentLivenessRevision[] = [] for (const item of items) { - const body = item.body - if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + const body = lostLiveWorkJournalBody(item.body) + if (!body) { continue } // A key that will not parse cannot be re-addressed, and appending under a @@ -62,11 +62,20 @@ export function staleSubagentRosterRevisions( if (!identity || agentJournalItemKey(identity) !== item.itemId) { continue } - revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } }) + revisions.push({ identity, body }) } return revisions } +/** The row once the host lost the session running its live work: every working child and + * in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. */ +export function lostLiveWorkJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) { + return null + } + return { ...body, blocks: settleBlocks(body.blocks) } +} + function hasStaleLiveWork(block: NativeChatBlock): boolean { return hasWorkingChild(block) || hasLiveBackgroundTask(block) } diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index 1dd1115ebd1..b09d387603e 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -1,5 +1,6 @@ import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { isRunningAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { cancelledJournalPromptBody } from './journal-prompt-body-bounds' /** True while an item is still awaiting the row that settles it, so a sink can * treat that row as lifecycle-critical rather than sheddable under pressure. */ @@ -12,3 +13,15 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean } return isRunningAgentJournalTurn(body) } + +/** The row that settles an item no one will finish: a running tool call fails, a pending prompt + * is cancelled. Null for an item that needs none. Turn rows are each writer's own to end. */ +export function terminalAgentJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null { + if (body.kind === 'tool-call') { + return body.state === 'running' ? { ...body, state: 'failed' } : null + } + if (body.kind === 'approval' || body.kind === 'question') { + return body.resolution.state === 'pending' ? cancelledJournalPromptBody(body) : null + } + return null +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts new file mode 100644 index 00000000000..c49d266e343 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -0,0 +1,223 @@ +// Where a request, a send or a background task belongs is decided from the turn the event names +// and the journal as it stands, not from what an earlier event left in memory. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +function identityOf(itemId: string) { + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error(`${itemId} did not parse`) + } + return identity +} + +/** `p1` opened in `t1` and answered by a client, `t1` over, `t2` running. */ +async function answeredInAnEarlierTurn(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.journal.appendItem(identityOf(providerItemId('request', 'p1')), answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + return rig +} + +const second = providerItemId('request', 'p1', { incarnation: 2 }) + +describe('a request key reused in another turn', () => { + it('opens a new prompt in the live turn it names after a withdrawn one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + await rig.rows() + const opened = rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't2' } + }) + expect(opened.dropped).toBeUndefined() + expect(await rig.row(second)).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } + }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('opens a new prompt in the open turn after an answered one, leaving the answer', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('opens a new row for a request id the previous process used, after a restart', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + // JSON-RPC ids restart with the provider process: the new child asks under `0` again. + const restarted = await rig.restart() + restarted.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + expect( + restarted.apply({ type: 'request.open', request: '0', body: pendingApproval }).dropped + ).toBeUndefined() + // The sweep cancelled the old process's prompt; the new one is its own row, still pending. + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t2') } + }) + }) + + it('asks nothing in a turn another writer ended while the open was queued', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't2' } + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }) + const running = await rig.turn('t2') + if (!running) { + throw new Error('t2 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t2')), + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 4_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + await bind() + expect( + await rig.row(providerItemId('request', 'p1', { generation: 'gen-unbound' })) + ).toBeUndefined() + }) +}) + +describe('a send that names its turn', () => { + it('opens the turn it names when that turn opens later', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send1', + requestedAt: 900, + join: { turn: 't1' } + }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:send1', requestedAt: 900 }) + }) + + it('never replaces an opener another writer gave the running turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const running = await rig.turn('t1') + if (!running) { + throw new Error('t1 is running') + } + await rig.journal.appendItem( + identityOf(providerTurnItemId('t1')), + agentJournalTurnBody({ ...running, userItemId: 'orca:first', requestedAt: 800 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'later', requestedAt: 900 }) + expect(await rig.turn('t1')).toMatchObject({ userItemId: 'orca:first', requestedAt: 800 }) + }) + + it('waits for its own turn rather than the next one to open', async () => { + const rig = await openProviderTimelineRig() + for (const [send, turn] of [ + ['send-b', 't2'], + ['send-a', 't1'] + ] as const) { + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: send, + requestedAt: 900, + join: { turn } + }) + } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't3', at: 3_000 }) + rig.assembler.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 5_000 }) + expect((await rig.turn('t1'))?.userItemId).toBe('orca:send-a') + // A turn no send named opens as the provider's own. + expect((await rig.turn('t3'))?.userItemId).toBe(providerTurnItemId('t3')) + expect((await rig.turn('t2'))?.userItemId).toBe('orca:send-b') + }) +}) + +describe('a background task', () => { + it('outlives its turn, and settles on its own update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('working') + expect( + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + .dropped + ).toBeUndefined() + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + // A straggler progress report never re-lights it. + expect( + rig.assembler.apply({ + type: 'item.update', + item: 'bg', + body: backgroundTask('bg', 'working') + }).dropped + ).toBe('item-settled') + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + }) + + it('is left unverifiable, not exited, when the session ends with it in flight', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + rig.assembler.apply({ type: 'item.open', item: 'ran', body: backgroundTask('ran', 'working') }) + rig.assembler.apply({ type: 'item.close', item: 'ran', body: backgroundTask('ran', 'done') }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual( + backgroundTask('bg', 'unverifiable') + ) + expect(await backgroundTaskState(rig, 'ran')).toBe('done') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts new file mode 100644 index 00000000000..0e79ec50d04 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe("an earlier turn's late end leaves the open turn alone", () => { + it("keeps the open turn's activity line", async () => { + const rig = await openProviderTimelineRig() + const activity: unknown[] = [] + const assembler = rig.assemble({ + sink: { ...rig.sink, setActivity: (each) => activity.push(each) } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + assembler.apply({ type: 'activity', text: 'Thinking' }) + activity.length = 0 + expect( + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + ).toEqual({ admission: { accepted: true } }) + expect(activity).toEqual([]) + expect(assembler.openTurnId).toBe(providerTurnId('t2')) + }) + + it("keeps the open turn's anonymous reply one message", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hello' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + rig.assembler.apply({ ...delta, text: ' world' }) + const texts = (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [[row.turnScope, messageText(row.body)]] : [] + ) + expect(texts).toEqual([[{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'Hello world']]) + }) + + it("leaves the open turn's running work to that turn's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + rig.assembler.apply({ type: 'turn.end', turn: 't2', at: 2_200, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts new file mode 100644 index 00000000000..8d01dfbbb4d --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const answered = { + ...pendingApproval, + resolution: { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } +} + +describe('what the journal already holds stands', () => { + it('settles at the session end the work the journal holds open, from its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + const identity = parseAgentJournalItemKey(providerItemId('item', 'done')) + if (!identity) { + throw new Error('item key did not parse') + } + // Another writer settled one row in the turn; the session end settles only what is still open. + await rig.journal.appendItem( + identity, + { ...runningTool('grep'), state: 'completed' }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect((await rig.row(providerItemId('item', 'done')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('never resurrects a settled tool from a running snapshot', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect( + rig.assembler.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + }) +}) + +describe('requests', () => { + it('opens a request reused after it settled as a new one, and withdraws each on its own', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + await rig.rows() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + + const prompts = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(prompts.map((row) => row.itemId)).toEqual([ + providerItemId('request', 'p1'), + providerItemId('request', 'p1', { incarnation: 2 }) + ]) + expect(prompts.map((row) => row.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }) + ]) + }) + + it('opens a new request when the pending one under its key was answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBeUndefined() + // The answered prompt keeps its answer; withdrawing reaches only the new one. + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('leaves an answered request answered when its withdrawal lands after the answer', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + // Memory still holds it pending; only the journal knows a client answered. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('never overwrites a request row already in the journal when its open runs', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + const identity = parseAgentJournalItemKey(providerItemId('request', 'p1')) + if (!identity) { + throw new Error('request key did not parse') + } + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + // Another assembler of the same generation, blind to the journal, opens `p1` again. + const blind = rig.assemble({ sink: { ...rig.sink, journalItems: () => null } }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'pending' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts new file mode 100644 index 00000000000..2bebb9ee5b1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalPlainStatusItem +} from '../../../shared/agent-session-journal-types' +import { + assistantText, + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const plan = (text: string): AgentJournalPlainStatusItem => ({ + kind: 'status', + text, + presentation: 'plan-document' +}) + +function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +describe('provider timeline items', () => { + it('scopes interleaved tool calls and text to the turn they ran in, in arrival order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Let me ' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'look.' + }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.open', item: 'call-b', body: runningTool('grep') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-b', + body: { ...runningTool('grep'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + // The tool call ended the anonymous stream, so this text is a new message. + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Done.' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => messageText(row.body) ?? row.itemId)).toEqual([ + 'Let me look.', + providerItemId('item', 'call-a'), + providerItemId('item', 'call-b'), + 'Done.' + ]) + const turnScope = { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } + expect(rows.every((row) => JSON.stringify(row.turnScope) === JSON.stringify(turnScope))).toBe( + true + ) + expect(rows.filter((row) => row.body.kind === 'tool-call').map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'read', state: 'completed' }), + expect.objectContaining({ name: 'grep', state: 'completed' }) + ]) + }) + + it('drops a second close of a settled item and keeps the first terminal body', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + const repeat = rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + expect(repeat.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('keeps a settled tool terminal body against a later update carrying another', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.close', + item: 'call-a', + body: { ...runningTool('read'), state: 'failed' } + }) + const update = rig.assembler.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it("keeps the sweep's verdict on a tool against the next child's update", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + const next = await rig.restart() + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + const update = next.apply({ + type: 'item.update', + item: 'call-a', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'turn-1' } + }) + expect(update.dropped).toBe('item-settled') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + it('reopens a settled item under the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ type: 'item.open', item: 'agent-1', body: runningTool('task') }) + rig.assembler.apply({ + type: 'item.close', + item: 'agent-1', + body: { ...runningTool('task'), state: 'completed' } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ state: 'completed' }) + }) + + it('replaces a plan in place with each whole-list update', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read') }) + rig.assembler.apply({ type: 'item.update', item: 'plan', body: plan('1. read ✓\n2. edit') }) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ text: '1. read ✓\n2. edit' }) + }) + + it('writes an item that arrives with no turn open as a thread row, opening no turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'item.close', item: 'call-a', body: assistantText('stray') }) + expect(rig.assembler.openTurnId).toBeNull() + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) + }) + + it('fails a tool call its turn left running, and leaves a background task it started running', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'item.open', + item: 'bg-1', + body: backgroundTask('bg-1', 'working') + }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('working') + + // It settles on its own update later, still in the turn that started it. + rig.assembler.apply({ type: 'item.close', item: 'bg-1', body: backgroundTask('bg-1', 'done') }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('done') + expect((await rig.row(providerItemId('item', 'bg-1')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('turn-1') + }) + }) + + it('stamps the producing subagent on its rows', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'call-a', + body: runningTool('read'), + producer: { agentId: 'helper-1' } + }) + expect((await rig.row(providerItemId('item', 'call-a')))?.agentId).toBe('helper-1') + }) +}) + +describe('provider timeline text streams', () => { + it('coalesces a burst of deltas into one snapshot write', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + for (const text of ['a', 'b', 'c']) { + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'x' + }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(0) + runs.splice(0).forEach((run) => run()) + const [reply, single] = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messageText(reply?.body)).toBe('abc') + // Three deltas cost the writes one delta does. + expect(reply?.revision).toBe(single?.revision) + }) + + it('writes every delta of a window once it elapses', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'reply' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + rig.assembler.apply({ ...delta, text: 'lo' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['Hello']) + }) + + it('settles with the provider final text, and keys a named message by its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'msg-1' }, + channel: 'assistant', + text: 'Draf' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' }, text: 'Final answer' }) + expect(messageText((await rig.row(providerItemId('item', 'msg-1')))?.body)).toBe('Final answer') + expect(rig.assembler.apply({ type: 'text.close', item: { id: 'msg-1' } }).dropped).toBe( + 'stream-unknown' + ) + }) + + it('writes reasoning as a reasoning message, and nothing for a whitespace-only stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'think' }, + channel: 'reasoning', + text: 'Hmm' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: ' \n' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text: 'Hmm' }] } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts new file mode 100644 index 00000000000..9c7be28bd34 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + assistantText, + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A rig whose coalescing window only fires when the test says so. */ +async function heldWindowRig() { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig({ + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + return { rig, fire: () => runs.splice(0).forEach((run) => run()) } +} + +describe('one canonical row per provider item', () => { + it('writes each named message to its own row, even on one provider stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'message-2' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'message-1')))?.body)).toBe('First') + expect(messageText((await rig.row(providerItemId('item', 'message-2')))?.body)).toBe('Second') + }) + + it('refuses a named message whose channel changes mid-stream instead of mixing them', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Reply' + }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'reasoning', + text: 'x' + }).dropped + ).toBe('stream-mismatch') + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Reply') + }) + + it('starts the next anonymous message when its producer changes', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Root' + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'out' }, + channel: 'assistant', + text: 'Helper', + producer: { agentId: 'helper-1' } + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [messageText(row.body), row.agentId])).toEqual([ + ['Root', undefined], + ['Helper', 'helper-1'] + ]) + }) + + it('settles a streamed message through its full snapshot as the same row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('') }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Hello, final') }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1'), 'Hello, final'] + ]) + }) + + it('keeps every open stream’s whole text while the budget holds them, and refuses the next', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'A' + }) + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'one-more' }, + channel: 'assistant', + text: 'A' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm0' }, channel: 'assistant', text: 'B' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm0')))?.body)).toBe('AB') + }) +}) + +describe('every other event is an ordering barrier for text', () => { + it('writes text that arrived before a tool ahead of the tool, named stream or not', async () => { + const { rig, fire } = await heldWindowRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + fire() + rig.assembler.flush() + const items = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(items.map((row) => row.itemId)).toEqual([ + providerItemId('item', 'm1'), + providerItemId('item', 'tool') + ]) + }) + + it('starts a new anonymous message after a context report, as after any row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'First' + }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 1_000, capturedAt: 1_100 } } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'Second' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['First', 'Second']) + }) + + it('does not split a message on an event it dropped or on the activity line', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'One ' + }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }).dropped).toBe( + 'turn-duplicate' + ) + rig.assembler.apply({ type: 'activity', text: 'Thinking' }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 's1' }, + channel: 'assistant', + text: 'message' + }) + rig.assembler.flush() + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['One message']) + }) +}) + +describe('explicit provider attribution', () => { + it('scopes a late item to the earlier turn the provider names', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'late-t1-message', + body: assistantText('Late output from t1'), + join: { turn: 't1' } + }) + expect((await rig.row(providerItemId('item', 'late-t1-message')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('keeps a background task in the turn it opened in when it settles after the next turn opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'bg', body: backgroundTask('bg', 'working') }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + const row = await rig.row(providerItemId('item', 'bg')) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) + expect(await backgroundTaskState(rig, 'bg')).toBe('done') + }) + + it('writes context facts onto the turn the provider names, not the open one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 5_000, capturedAt: 3_100 } }, + join: { turn: 't1' } + }) + expect(await rig.turn('t1')).toMatchObject({ contextUsage: { window: { tokens: 5_000 } } }) + expect(await rig.turn('t2')).not.toHaveProperty('contextUsage') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts new file mode 100644 index 00000000000..f9dbff3efc5 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + refusingSink, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const BACKPRESSURE = { accepted: false, reason: 'backpressure' } + +describe('a refused event changes nothing and its retry lands it once', () => { + it('settles the tool a refused turn end owed when the end is re-applied', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + refusing = true + const end = { type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' } as const + expect(assembler.apply(end).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + + refusing = false + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + // A repeated end is admitted and writes nothing: the row keeps its first end. + expect(assembler.apply(end)).toEqual({ admission: { accepted: true } }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + }) + + it('keeps observed text when the close that would write it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ + sink: refusingSink(rig.sink, () => refusing), + schedule: () => () => {} + }) + assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Observed text' + }) + refusing = true + expect(assembler.apply({ type: 'text.close', item: { id: 'm1' } }).admission).toEqual( + BACKPRESSURE + ) + refusing = false + assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Observed text') + }) + + it('keeps window text while the sink is full and retries it, and lets it go once the sink is gone', async () => { + const runs: (() => void)[] = [] + const rig = await openProviderTimelineRig() + let refusal: 'backpressure' | 'failed' | null = null + const assembler = rig.assemble({ + sink: { + ...rig.sink, + tryAppendTransition: (transition) => + refusal ? { accepted: false, reason: refusal } : rig.sink.tryAppendTransition(transition) + }, + schedule: (run) => { + runs.push(run) + return () => {} + } + }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusal = 'backpressure' + assembler.apply({ type: 'text.delta', item: { id: 'm1' }, channel: 'assistant', text: 'Kept' }) + runs.splice(0).forEach((run) => run()) + // Refused under backpressure: still owed, and the window is scheduled again. + expect(runs).toHaveLength(1) + refusal = null + runs.splice(0).forEach((run) => run()) + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Kept') + + assembler.apply({ type: 'text.delta', item: { id: 'm2' }, channel: 'assistant', text: 'Lost' }) + refusal = 'failed' + runs.splice(0).forEach((run) => run()) + // A failed sink can never take it: no retry is scheduled. + expect(runs).toHaveLength(0) + }) + + it('does not latch a refused session end, so its retry still settles the session', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + refusing = true + const ended = { + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 3_000 } + } as const + expect(assembler.apply(ended).admission).toEqual(BACKPRESSURE) + expect(assembler.apply({ type: 'activity', text: 'still live' }).dropped).toBeUndefined() + + refusing = false + assembler.apply(ended) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 3_000 }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 4_000 }).dropped).toBe( + 'session-ended' + ) + }) + + it('leaves the open turn in place when a superseding open is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const first = assembler.openTurnId + refusing = true + expect(assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }).admission).toEqual( + BACKPRESSURE + ) + expect(assembler.openTurnId).toBe(first) + refusing = false + assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) + expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) + }) +}) + +describe('what the assembler holds open is bounded', () => { + it('refuses, as failed, work past its open budget instead of forgetting it', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let index = 0; index < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES / 2; index += 1) { + expect( + rig.assembler.apply({ type: 'item.open', item: `item-${index}`, body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + expect( + rig.assembler.apply({ + type: 'request.open', + request: `request-${index}`, + body: pendingApproval + }).admission + ).toEqual({ accepted: true }) + } + const refused = { accepted: false, reason: 'failed' } + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ type: 'request.open', request: 'one-more', body: pendingApproval }) + .admission + ).toEqual(refused) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'more' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual(refused) + expect(await rig.row(providerItemId('item', 'one-more'))).toBeUndefined() + + // Settling frees the budget again. + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'one-more', body: runningTool('read') }) + .admission + ).toEqual({ accepted: true }) + }) + + it('refuses a stream past the budget, even though the coalescer would hold it', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + let admitted = 0 + for (let index = 0; index < 1_100; index += 1) { + const { admission } = rig.assembler.apply({ + type: 'text.delta', + item: { id: `m${index}` }, + channel: 'assistant', + text: 'x' + }) + admitted += admission.accepted ? 1 : 0 + } + expect(admitted).toBe(MAX_PROVIDER_TIMELINE_OPEN_ENTRIES) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts new file mode 100644 index 00000000000..536381911be --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -0,0 +1,68 @@ +// A new provider child is a new assembler. Its events can be admitted before its sink binds, and +// the dead-generation sweep for the previous child lands before they are written, so every write +// decides from the journal the sweep left. + +import { afterEach, describe, expect, it } from 'vitest' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + SESSION +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe('a resumed session', () => { + it('decides events admitted before bind against the journal the sweep left', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + await rig.rows() + + // The next child's events are admitted while its sink is unbound. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-2' + }) + const late = { type: 'item.update', item: 'call-a', body: runningTool('read') } as const + assembler.apply({ ...late, join: { turn: 'old' } }) + assembler.apply({ + type: 'item.open', + item: 'call-b', + body: runningTool('grep'), + join: { turn: 'old' } + }) + assembler.apply({ type: 'turn.open', turn: 'new', at: 3_000 }) + assembler.apply({ type: 'request.open', request: '0', body: pendingApproval }) + // The sweep for the dead child lands before the drain. + await settleStaleStructuredAgentSessionState({ + journal: rig.journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-2', + deathEvidence: null + }) + await bind() + + expect((await rig.turn('old'))?.state).not.toBe('running') + // The swept tool is not relit by a running report that was admitted before the sweep. + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + // Nor does new running work land in a turn the sweep ended: nothing would ever settle it. + expect(await rig.row(providerItemId('item', 'call-b'))).toBeUndefined() + expect((await rig.row(providerItemId('request', '0')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('new')).toMatchObject({ state: 'running', startedAt: 3_000 }) + expect(await rig.row(providerItemId('request', '0', { generation: 'gen-2' }))).toMatchObject({ + body: { resolution: { state: 'pending' } }, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('new') } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts new file mode 100644 index 00000000000..8e4de9e95ae --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts @@ -0,0 +1,207 @@ +// Which row an event lands on is spelled from its keys, and an event the sink refused took +// nothing: no key, no row, no room in the budget. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + assistantText, + backgroundTask, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnItemId, + refusingSink +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a refused event takes nothing', () => { + it('allocates no key and no request row, and its retry lands once', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing) }) + const refused = { accepted: false, reason: 'backpressure' } + const turnOpen = { type: 'turn.open', at: 1_000 } as const + const frame = { type: 'provider.frame', frameKind: 'mystery', payload: { a: 1 } } as const + const request = { type: 'request.open', request: 'p1', body: pendingApproval } as const + refusing = true + expect(assembler.apply(turnOpen).admission).toEqual(refused) + expect(assembler.openTurnId).toBeNull() + refusing = false + expect(assembler.apply(turnOpen).admission).toEqual({ accepted: true }) + // The minted turn takes the first serial, as if the refusal never happened. + expect(assembler.openTurnId).toBe('m:gen-1%3At1') + refusing = true + expect(assembler.apply(frame).admission).toEqual(refused) + expect(assembler.apply(request).admission).toEqual(refused) + refusing = false + expect(assembler.apply(frame).admission).toEqual({ accepted: true }) + expect(assembler.apply(request).admission).toEqual({ accepted: true }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hi' + }) + assembler.flush() + + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + expect.stringContaining('frame%3Am%3Agen-1%253Af2'), + providerItemId('request', 'p1'), + expect.stringContaining('item%3Am%3Agen-1%253As3') + ]) + expect(messageText(rows[2]?.body)).toBe('Hi') + }) +}) + +describe('a row keeps the turn it opened in', () => { + it('takes a background task update in the turn it opened in, after that turn ended', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.open', + item: 'background-tool', + body: backgroundTask('background-tool', 'working') + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // No turn is open: the update still joins the row's own turn, not the conversation. + rig.assembler.apply({ + type: 'item.update', + item: 'background-tool', + body: backgroundTask('background-tool', 'done') + }) + expect(await rig.row(providerItemId('item', 'background-tool'))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') }, + body: backgroundTask('background-tool', 'done') + }) + }) +}) + +describe('streamed text has the same lifecycle as the item it streams into', () => { + it('keeps interleaved same-id streams on separate threads apart', async () => { + const rig = await openProviderTimelineRig({ ownThread: () => 'root' }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Root', + join: { thread: 'root', turn: 't1' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Child', + join: { thread: 'child', turn: 'child-turn' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + [providerItemId('item', 'm1', { thread: 'root' }), 'Root'], + [providerItemId('item', 'm1', { thread: 'child' }), 'Child'] + ]) + }) + + it('settles a message on text.close, so a late delta cannot erase what it said', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Prefix' + }) + rig.assembler.apply({ type: 'text.close', item: { id: 'm1' } }) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Suffix' + }).dropped + ).toBe('item-settled') + rig.assembler.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Prefix') + }) +}) + +describe('the budget counts the work actually open', () => { + it('frees an answered request incarnation without waiting for its turn to end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let incarnation = 1; incarnation <= 129; incarnation += 1) { + expect( + rig.assembler.apply({ type: 'request.open', request: 'approval', body: pendingApproval }) + .admission + ).toEqual({ accepted: true }) + await rig.rows() + const identity = parseAgentJournalItemKey( + providerItemId('request', 'approval', { incarnation }) + ) + if (!identity) { + throw new Error('the request has a journal key') + } + // A client answers it, through the journal. + await rig.journal.appendItem( + identity, + { + ...pendingApproval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_100 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } } + ) + } + }) + + it('keeps a streamed message counted while its stream is open, whatever its snapshots say', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + for (let index = 0; index < 128; index += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `stream-${index}` }, + channel: 'assistant', + text: 'x' + }) + rig.assembler.apply({ + type: 'item.update', + item: `stream-${index}`, + body: assistantText('x') + }) + if (index % 32 === 31) { + await rig.rows() + } + } + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'overflow' }, + channel: 'assistant', + text: 'x' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) + + it('refuses a provider key larger than the whole budget', () => + openProviderTimelineRig({ schedule: () => () => {} }).then((rig) => { + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'x'.repeat(1024 * 1024 + 1) }, + channel: 'assistant', + text: 'tiny' + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + })) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts new file mode 100644 index 00000000000..cbb0441ac9f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalApprovalItem } from '../../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + providerItemId, + providerTurnItemId, + runningTool +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run npm test?', + detail: null, + options: [ + { id: 'allow', label: 'Allow' }, + { id: 'reject', label: 'Reject' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +describe('provider timeline session end', () => { + it('ends the open turn interrupted with no verdict when the child exit was observed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 4_000 } + }) + + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 4_000, startedAt: 1_000 }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled', selectedOptionId: null } + }) + // A stream cut off keeps the text it received. + const messages = (await rig.rows()).filter((row) => row.body.kind === 'message') + expect(messages.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Half' }] } + ]) + }) + + it('marks the open turn unverifiable with no end when the host lost the child', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + const turn = await rig.turn('turn-1') + expect(turn).toMatchObject({ state: 'unverifiable', startedAt: 1_000, requestedAt: 900 }) + expect(turn).not.toHaveProperty('completedAt') + expect(turn).not.toHaveProperty('durationMs') + expect(turn).not.toHaveProperty('outcome') + }) + + it('drops everything after the session ended', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const first = rig.assembler.openTurnId + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + // A straggler from the dead child must not open a turn nothing would close. + expect(rig.assembler.apply({ type: 'turn.open', at: 2_500 }).dropped).toBe('session-ended') + expect( + rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped + ).toBe('session-ended') + expect(rig.assembler.openTurnId).toBeNull() + const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') + expect(turns.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnId: first, state: 'interrupted' }) + ]) + }) +}) + +describe('provider timeline requests', () => { + it('cancels a request its turn left pending', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + expect( + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }).dropped + ).toBe('request-duplicate') + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + const row = await rig.row(providerItemId('request', 'perm-1')) + expect(row?.body).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) + + it('never cancels a request a client already answered', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + const itemId = providerItemId('request', 'perm-1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set wins on another device and writes the row resolved. + rig.journal.appendItem( + identity, + { + ...approval, + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 1_500 + } + }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: providerTurnItemId('turn-1') } } + ) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow', resolvedBy: 'phone' } + }) + }) + + it('cancels a request the provider withdrew, once', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'request.open', request: 'perm-1', body: approval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }) + // The journal holds the row it withdrew: a repeat is admitted and finds nothing pending. + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' })).toEqual({ + admission: { accepted: true } + }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-9' }).dropped).toBe( + 'request-unknown' + ) + expect((await rig.row(providerItemId('request', 'perm-1')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) +}) + +describe('provider timeline turn facts', () => { + it('writes context usage onto the turn that just ended, keeping its end', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + rig.assembler.apply({ + type: 'context.usage', + usage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + completedAt: 2_000, + contextUsage: { window: { tokens: 200_000, capturedAt: 2_100 } } + }) + }) + + it('sets the live activity line only while a turn is open', async () => { + const rig = await openProviderTimelineRig() + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBe('no-turn') + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'activity', text: 'Reading' }).dropped).toBeUndefined() + }) + + it('journals provider traffic no event covers as the shared fallback row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'provider.frame', + frameKind: 'session/mystery_update', + payload: { message: 'Something new happened' } + }) + const frame = (await rig.rows()).find((row) => row.body.kind === 'status') + expect(frame?.body).toMatchObject({ kind: 'status' }) + expect(frame?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('turn-1') }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts new file mode 100644 index 00000000000..e0e4541402b --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnId, + providerTurnItemId, + runningTool, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A person's Stop, settled by the host on the journal, with no word to the assembler. */ +async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Promise { + const running = await rig.turn(turnKey) + const identity = parseAgentJournalItemKey(providerTurnItemId(turnKey)) + if (!running || !identity) { + throw new Error('the turn row is written') + } + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: at }), + { fence: 1, turnScope: { kind: 'thread' } } + ) +} + +const completed = { ...runningTool('read'), state: 'completed' as const } + +async function texts(rig: ProviderTimelineRig): Promise { + return (await rig.rows()).flatMap((row) => { + const text = messageText(row.body) + return text === undefined || row.body.kind !== 'message' || row.body.role === 'user' + ? [] + : [[row.turnScope, text]] + }) +} + +describe('a turn another writer settled stops its text and prompts; its tools wait for the provider', () => { + it("cancels the turn's prompt at once and settles its running tool at the provider's own end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + await stop(rig, 't1') + + // The provider's trailing end of the turn it was asked to stop, and its withdrawal. + expect( + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + ).toEqual({ admission: { accepted: true } }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: '7' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + // The Stop's row stands. + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('settles the same work when the provider end lands before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) + }) + + it('cancels the prompt even when the next event writes nothing, and leaves the tool running', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'request.open', request: '7', body: pendingApproval }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect(rig.assembler.openTurnId).toBeNull() + expect((await rig.row(providerItemId('request', '7')))?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('lands a tool the provider completes after the Stop as completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + // Its progress, then its completion, both reported between the cancel and the turn's end. + const progress = { ...runningTool('read'), input: { name: 'read', path: 'a.ts' } } + expect( + rig.assembler.apply({ + type: 'item.update', + item: 'call', + body: progress, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + input: { path: 'a.ts' } + }) + expect( + rig.assembler.apply({ + type: 'item.close', + item: 'call', + body: completed, + join: { turn: 't1' } + }) + ).toEqual({ admission: { accepted: true } }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it('lands the completion the same way when it arrives before the Stop', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + rig.assembler.apply({ type: 'item.close', item: 'call', body: completed, join: { turn: 't1' } }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'completed' + }) + }) + + it("ends the stopped turn on the provider's unnamed end", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + expect(rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' })).toEqual({ + admission: { accepted: true } + }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + // Ended once: a second unnamed end names no turn. + expect(rig.assembler.apply({ type: 'turn.end', at: 2_200, state: 'interrupted' }).dropped).toBe( + 'no-turn' + ) + }) + + it('settles the running tools of a stopped turn the provider never ended when the next opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('t2')) + }) + + it("counts a stopped turn's running tools against the budget until the provider ends it", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ type: 'item.open', item: `call-${n}`, body: runningTool('read') }) + } + await stop(rig, 't1') + const extra = { type: 'item.open', item: 'extra', body: runningTool('read') } as const + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: false, reason: 'failed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'interrupted' }) + expect(rig.assembler.apply(extra).admission).toEqual({ accepted: true }) + }) + + it('never fills the open budget with the streams of turns a person stopped', async () => { + const rig = await openProviderTimelineRig() + for (let n = 1; n <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + 12; n += 1) { + const turn = `t${n}` + rig.assembler.apply({ type: 'turn.open', turn, at: n * 10 }) + // Named messages with no close, as a provider that never closes its text sends them. + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: `msg-${n}` }, + channel: 'assistant', + text: `reply ${n}`, + join: { turn } + }).admission + ).toEqual({ accepted: true }) + await stop(rig, turn, n * 10 + 5) + rig.assembler.apply({ type: 'turn.end', turn, at: n * 10 + 6, state: 'interrupted' }) + } + expect(messageText((await rig.row(providerItemId('item', 'msg-140')))?.body)).toBe('reply 140') + }) + + it('frees a stream whose turn another writer settled before refusing for the budget', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 1_100 }) + // Streams joined to turn t1, which is not the open one, so no turn end of this run frees them. + for (let n = 1; n < MAX_PROVIDER_TIMELINE_OPEN_ENTRIES; n += 1) { + rig.assembler.apply({ + type: 'text.delta', + item: { id: `old-${n}` }, + channel: 'assistant', + text: 'x', + join: { turn: 't1' } + }) + } + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'now' }, + channel: 'assistant', + text: 'a' + }) + expect( + rig.assembler.apply({ type: 'item.open', item: 'call', body: runningTool('read') }).admission + ).toEqual({ accepted: true }) + }) +}) + +describe('text after a person stopped its turn never lands outside that turn', () => { + it('drops the rest of an anonymous stream', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + expect(rig.assembler.apply({ ...delta, text: ' world' }).dropped).toBe('turn-settled') + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'] + ]) + }) + + it("drops a stopped turn's anonymous stream until the provider's unnamed end, then lands it at thread level", async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + const delta = { type: 'text.delta', item: { stream: 'a' }, channel: 'assistant' } as const + rig.assembler.apply({ ...delta, text: 'Hel' }) + await stop(rig, 't1') + expect(rig.assembler.apply({ ...delta, text: 'lo' }).dropped).toBe('turn-settled') + rig.assembler.apply({ type: 'turn.end', at: 2_100, state: 'interrupted' }) + // The provider's end of the stopped turn is that turn's boundary: its marker is gone. + expect(rig.assembler.apply({ ...delta, text: 'B' }).dropped).toBeUndefined() + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'Hel'], + [{ kind: 'thread' }, 'B'] + ]) + }) + + it('drops the rest of a named stream whose first write found the turn stopped', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + await stop(rig, 't1') + rig.assembler.flush() + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'lo' + }).dropped + ).toBe('turn-settled') + rig.assembler.flush() + expect(await texts(rig)).toEqual([]) + }) + + it('drops a stopped stream admitted before the sink bound once its write found the turn over', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + await stop(rig, 't1') + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'Hel', + join: { turn: 't1' } + }) + assembler.flush() + await bind() + expect( + assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'lo', + join: { turn: 't1' } + }).dropped + ).toBe('turn-settled') + expect(await texts(rig)).toEqual([]) + }) + + it('starts a new message for the same stream once the next turn opens', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'A' + }) + await stop(rig, 't1') + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'a' }, + channel: 'assistant', + text: 'B' + }) + expect(await texts(rig)).toEqual([ + [{ kind: 'turn', turnItemId: providerTurnItemId('t1') }, 'A'], + [{ kind: 'turn', turnItemId: providerTurnItemId('t2') }, 'B'] + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts new file mode 100644 index 00000000000..95b28378dfb --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + providerItemId, + providerTurnItemId +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe("a stream writes only while its row's turn runs", () => { + it('stops at the end of its turn, though its next delta was admitted before the end was written', async () => { + const rig = await openProviderTimelineRig() + const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + assembler.flush() + assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + // Admitted before bind: only its write can see that the turn is over. + expect( + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + .dropped + ).toBeUndefined() + assembler.flush() + await bind() + await drained() + expect(await rig.turn('t1')).toMatchObject({ state: 'completed' }) + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + }) + + it('writes nothing more once another writer of the journal settled its turn', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + const running = await rig.turn('t1') + const identity = parseAgentJournalItemKey(providerTurnItemId('t1')) + if (!running || !identity) { + throw new Error('the turn row is written') + } + // A person's Stop, settled by the host on the journal, with no word to the assembler. + await rig.journal.appendItem( + identity, + agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2_000 }), + { fence: 1, turnScope: { kind: 'thread' } } + ) + rig.assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + rig.assembler.flush() + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') + + // The open turn is over for the assembler too: new work is no longer that turn's. + expect(rig.assembler.openTurnId).toBeNull() + }) + + it('still takes the provider final full snapshot of a message whose turn settled', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'Hel' + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'item.close', + item: 'm', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Hello.' }] } + }) + expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hello.') + }) +}) + +describe('the open budget counts every provider string a stream keeps', () => { + it('refuses a stream whose thread alone is past the budget', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + const thread = 't'.repeat(1024 * 1024 + 1) + expect( + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm' }, + channel: 'assistant', + text: 'x', + join: { thread } + }).admission + ).toEqual({ accepted: false, reason: 'failed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts new file mode 100644 index 00000000000..6946d455314 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -0,0 +1,314 @@ +// A real assembled lane for tests: grammar events → assembler → deferred sink queue → on-disk +// journal. Assertions read the journal back, so they check what a client sees. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalToolCallItem, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' +import { + isBackgroundTaskBlock, + type NativeChatBackgroundTaskBlock +} from '../../../shared/native-chat-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler, + type ProviderTimelineAssemblerDeps +} from './provider-timeline-assembler' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineItemFamily +} from './provider-timeline-identity' +import { providerTimelineSink, type ProviderTimelineSink } from './provider-timeline-plan' + +export const SESSION = 'session-timeline' +export const AGENT = 'grok' +export const GENERATION = 'gen-1' +export const NAMESPACE = 'provider-session-1' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) + +/** The journal key the assembler gives a provider-keyed item, or a request of `generation`. */ +export function providerItemId( + family: ProviderTimelineItemFamily | 'request', + key: string, + options: { namespace?: string; thread?: string; generation?: string; incarnation?: number } = {} +): string { + return agentJournalItemKey( + family === 'request' + ? scheme.request({ + generation: options.generation ?? GENERATION, + key, + incarnation: options.incarnation ?? 1 + }) + : scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: options.thread ?? null + }) + ) +} + +/** The journal key of a provider-keyed turn's row. */ +export function providerTurnItemId(turnKey: string, namespace = NAMESPACE): string { + return agentJournalItemKey( + scheme.turn({ namespace, key: { source: 'provider', value: turnKey } }) + ) +} + +/** The turn id a provider-keyed turn's row carries. */ +export function providerTurnId(turnKey: string, namespace = NAMESPACE): string { + return scheme.turnId({ namespace, key: { source: 'provider', value: turnKey } }) +} + +export function runningTool(name: string): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state: 'running' } +} + +/** A background task's row as every lane writes it: its plain-text twin, then its block. */ +export function backgroundTask( + taskId: string, + state: NativeChatBackgroundTaskBlock['state'] +): AgentJournalMessageItem { + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + taskId, + kind: 'command', + label: taskId, + state + } + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, block] + } +} + +/** The run state of the background task in the row of provider item `item`. */ +export async function backgroundTaskState( + rig: ProviderTimelineRig, + item: string +): Promise { + const body = (await rig.row(providerItemId('item', item)))?.body + const block = body?.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + return block?.state +} + +export function assistantText(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export const pendingApproval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +export function messageText(body: AgentJournalItemBody | undefined): string | undefined { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' + ? body.blocks[0].text + : undefined +} + +const journals = createTrackedJournalOpener() +const cleanups: (() => Promise)[] = [] + +/** Call from `afterEach`. */ +export async function closeProviderTimelineRigs(): Promise { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await journals.closeAll() +} + +/** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that + * starts before its journal opens (a resume): it admits events without the journal's view. */ +export type UnboundProviderTimelineAssembler = { + assembler: ProviderTimelineAssembler + bind(): Promise + drained(): Promise +} + +export function openUnboundProviderTimelineAssembler( + journal: AgentSessionJournal, + overrides: Partial = {} +): UnboundProviderTimelineAssembler { + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + const assembler = createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: 'gen-unbound', + namespace: NAMESPACE, + // The window never fires on its own; `flush` writes what it holds. + schedule: () => () => {}, + ...overrides + }) + cleanups.push(async () => { + assembler.dispose() + deferred.close() + }) + return { + assembler, + bind: async () => { + deferred.bind({ journal, fence: 1, publish: () => {} }) + await deferred.drained() + }, + drained: async () => { + await deferred.drained() + } + } +} + +export type ProviderTimelineRig = { + journal: AgentSessionJournal + /** The current child's assembler; `restart` replaces it. */ + assembler: ProviderTimelineAssembler + sink: ProviderTimelineSink + /** The same journal's event sink, for a lane that writes it directly. */ + eventSink: StructuredAgentSessionEventSink + /** What a restarted host does: the old child's assembler is gone (text still in its window is + * lost, as `dispose` drops it), the dead-generation sweep settles what it left, then a new + * child gets a new assembler in a new generation, which becomes `assembler`. */ + restart(overrides?: Partial): Promise + /** Another assembler of the same generation on the same journal, for a test that needs its own + * sink; the rig's own assembler must then stay unused. */ + assemble(overrides?: Partial): ProviderTimelineAssembler + rows(): Promise + row(itemId: string): Promise + /** The turn row of provider turn `turnKey`, or the row whose turn id is `turnKey`. */ + turn(turnKey: string, namespace?: string): Promise + turns(): Promise +} + +/** The rig's sink with its transitions refused while `refusing()` holds. */ +export function refusingSink( + sink: ProviderTimelineSink, + refusing: () => boolean, + reason: 'backpressure' | 'failed' = 'backpressure' +): ProviderTimelineSink { + return { + ...sink, + tryAppendTransition: (transition) => + refusing() ? { accepted: false, reason } : sink.tryAppendTransition(transition) + } +} + +export async function openProviderTimelineRig( + overrides: Partial = {} +): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-')) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: AGENT, + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: () => {} }) + cleanups.push(async () => { + deferred.close() + await rm(root, { recursive: true, force: true }) + }) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the deferred sink offers transitions') + } + // The coalescing window elapses before every read, so all text streamed so far is written, + // unless a test overrides `schedule` to drive the window itself. + const windows = new Set<() => void>() + const elapse = () => { + // Only the windows open now: a flush the sink refused schedules another for the next read. + const due = Array.from(windows) + windows.clear() + due.forEach((run) => run()) + } + const build = (more: Partial = {}) => + createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: GENERATION, + namespace: NAMESPACE, + schedule: (run) => { + windows.add(run) + return () => windows.delete(run) + }, + ...overrides, + ...more + }) + const rows = async () => { + elapse() + await deferred.drained() + return journal.snapshot().items + } + const turns = async () => + (await rows()).flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + let generations = 1 + const rig: ProviderTimelineRig = { + journal, + assembler: build(), + sink, + eventSink: deferred.sink, + restart: async (more = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + // The dead child's window never elapses: as in production, dispose drops its text. + await deferred.drained() + rig.assembler.dispose() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + rig.assembler = build({ ...more, generation }) + return rig.assembler + }, + assemble: build, + rows, + row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), + turns, + turn: async (turnKey, namespace) => { + const all = await turns() + return ( + all.find((turn) => turn.turnId === providerTurnId(turnKey, namespace)) ?? + all.find((turn) => turn.turnId === turnKey) + ) + } + } + return rig +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts new file mode 100644 index 00000000000..cd31a2c8e1e --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + providerTurnId, + providerTurnItemId +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +describe('provider timeline turns', () => { + it('opens a running turn and settles it with the provider verdict and duration', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.openTurnId).toBe(providerTurnId('turn-1')) + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'running', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900 + }) + const runningRow = await rig.row(providerTurnItemId('turn-1')) + // The running row is stamped at the turn start, not at append time. + expect(runningRow?.observedAt).toBe(1_000) + + rig.assembler.apply({ + type: 'turn.end', + at: 3_000, + state: 'completed', + outcome: 'success', + durationMs: 1_800 + }) + expect(rig.assembler.openTurnId).toBeNull() + const row = await rig.row(providerTurnItemId('turn-1')) + expect(row?.body).toEqual({ + kind: 'turn', + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('send-1'), + startedAt: 1_000, + requestedAt: 900, + completedAt: 3_000, + durationMs: 1_800 + }) + // Turn rows belong to no turn, and are revised rather than replaced. + expect(row?.turnScope).toEqual({ kind: 'thread' }) + expect(row?.revision).toBeGreaterThan(runningRow?.revision ?? Infinity) + }) + + it('records a cancelled turn as interrupted with the cancellation verdict', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'turn.end', + at: 2_000, + state: 'interrupted', + outcome: 'cancellation' + }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation', + completedAt: 2_000 + }) + }) + + it('keeps a provider-reported failure a completed turn whose verdict says it failed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'failure' }) + expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it('leaves the verdict absent when the provider gave none', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + const turn = await rig.turn('turn-1') + expect(turn?.state).toBe('completed') + expect(turn).not.toHaveProperty('outcome') + }) + + it('mints a turn id when the provider names none, and keys a turn no send opened by its own row', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', at: 1_000 }) + const turnId = rig.assembler.openTurnId + expect(turnId).toMatch(/^m:gen-1/) + const rows = await rig.rows() + const [row] = rows + expect(rows).toHaveLength(1) + expect(row?.body).toMatchObject({ kind: 'turn', turnId, userItemId: row?.itemId }) + }) + + it('names the open turn with a send that arrives after a provider-opened turn began', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 1_100 }) + // A second send folds into the turn; it does not replace the opener. + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 1_200 }) + expect(await rig.turn('turn-1')).toMatchObject({ + userItemId: agentJournalSubmissionKey('send-1'), + requestedAt: 1_100 + }) + }) + + it('gives each queued send to the next turn that opens, in order', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-1', requestedAt: 900 }) + rig.assembler.apply({ type: 'input.accepted', clientMessageId: 'send-2', requestedAt: 950 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 1_500, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect((await rig.turn('turn-1'))?.userItemId).toBe(agentJournalSubmissionKey('send-1')) + expect((await rig.turn('turn-2'))?.userItemId).toBe(agentJournalSubmissionKey('send-2')) + }) + + it('ends a still-open turn as superseded when the provider opens a different one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 2_000 }) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'interrupted', + outcome: 'superseded', + completedAt: 2_000 + }) + expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) + }) + + it('drops a repeated open and an end for a turn it never opened; a repeated end writes nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_100 }).dropped).toBe( + 'turn-duplicate' + ) + // An open naming no turn while one is open is the same turn, not a new one. + expect(rig.assembler.apply({ type: 'turn.open', at: 1_200 }).dropped).toBe('turn-duplicate') + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-9', at: 1_500, state: 'completed' }) + .dropped + ).toBe('turn-unknown') + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + // The journal holds the turn: its end is admitted and finds nothing left to settle. + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) + ).toEqual({ admission: { accepted: true } }) + expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( + 'no-turn' + ) + // An open of a turn the journal holds settled neither reopens nor rewrites it. + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( + 'turn-settled' + ) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000 + }) + }) + + it('writes nothing for an open admitted before bind whose row the journal already holds settled', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + await rig.rows() + + // Admitted without the journal's view, so only its write can see the row is there. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + expect( + assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }).dropped + ).toBeUndefined() + await bind() + expect(await rig.turn('turn-1')).toEqual({ + turnId: providerTurnId('turn-1'), + state: 'completed', + outcome: 'success', + userItemId: providerTurnItemId('turn-1'), + startedAt: 1_000, + completedAt: 2_000 + }) + // The journal's settlement reaches the state at the next event. + assembler.apply({ type: 'activity', text: 'reading' }) + expect(assembler.openTurnId).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts new file mode 100644 index 00000000000..fdd10ab6104 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -0,0 +1,201 @@ +// Turns a provider adapter's grammar events (`provider-timeline-event.ts`) into the journal rows +// every structured lane writes. +// +// Division of lifecycle work: +// - The adapter translates its dialect, decides when a turn opens, and re-applies an event the sink +// refused (the lane runner holds it and pauses reading under backpressure). +// - The assembler admits each event as ONE sink transition. What it knows (`ProviderTimelineState`) +// changes only when the sink admits the event, in admission order, which is the order its own +// rows are written in. Each write decides, when it runs, from what the journal holds then: a +// resume admits events before the sink binds, and the dead-generation sweep lands in between. +// - The journal keeps what other writers own: a person's Stop, a client's answer, the sweep. The +// assembler reads those by key and never copies them. +// - One assembler lives exactly as long as one provider child, so nothing it knows ever has to be +// recovered: a new child is a new assembler, in a new acquisition generation. + +import type { AgentType } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import { + PROVIDER_TIMELINE_OVER_BUDGET, + providerTimelineBudgetAdmits, + type ProviderTimelineHold +} from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import { + decideProviderTimelineEvent, + type ProviderTimelineDecidedEvent +} from './provider-timeline-decision' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineRows, providerTimelineTurnRowState } from './provider-timeline-rows' +import { ProviderTimelineState } from './provider-timeline-state' +import { + applyProviderTimelineTextClose, + applyProviderTimelineTextDelta, + type ProviderTimelineApplyResult, + type ProviderTimelineTextHost +} from './provider-timeline-text-events' +import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' +import { + planProviderTimelineBarrier, + planProviderTimelineWrites +} from './provider-timeline-transition-layout' + +export type { ProviderTimelineDropRule } from './provider-timeline-decision' +export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' + +export type ProviderTimelineAssembler = { + apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult + /** The turn id of the open turn, as its row and a client's Stop name it. */ + readonly openTurnId: string | null + /** Writes the text the coalescing window holds. */ + flush(): void + /** Drops the text the window holds: apply `session.ended` first, which writes it. */ + dispose(): void +} + +export type ProviderTimelineAssemblerDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: AgentType + /** The acquisition: minted keys and request ids are unique per generation. */ + generation: string + /** The provider session whose ids the adapter forwards. */ + namespace: string + /** The session's own provider thread, for providers that run subagents on threads of their own. */ + ownThread?: () => string | null + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function createProviderTimelineAssembler( + deps: ProviderTimelineAssemblerDeps +): ProviderTimelineAssembler { + const context: ProviderTimelineContext = { + sessionId: deps.sessionId, + agent: deps.agent, + generation: deps.generation, + rows: new ProviderTimelineRows({ + scheme: createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }), + generation: deps.generation, + namespace: deps.namespace + }), + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}) + } + const state = new ProviderTimelineState() + const streams = new ProviderTimelineTextStreams({ + sink: deps.sink, + context, + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}) + }) + + const textHost = (journal: ProviderTimelineTextHost['journal']): ProviderTimelineTextHost => ({ + sink: deps.sink, + state, + streams, + journal, + admits: (hold) => admits(hold, journal) + }) + + const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => + providerTimelineBudgetAdmits({ hold, state, streams, journal }) + + const applyDecided = ( + event: ProviderTimelineDecidedEvent, + journal: ProviderTimelineTextHost['journal'] + ): ProviderTimelineApplyResult => { + const serials = state.serials() + const decision = decideProviderTimelineEvent( + { context, state, journal, serial: serials.next }, + event + ) + if (decision.dropped) { + return { admission: ADMITTED, dropped: decision.dropped } + } + if (decision.hold && !admits(decision.hold, journal)) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + const plan = new ProviderTimelinePlan() + planProviderTimelineBarrier({ streams, state }, plan, event, decision) + planProviderTimelineWrites(context, plan, decision, serials.next) + plan.onAdmitted(() => { + decision.commit?.(state) + serials.commit() + }) + // An earlier turn's end leaves the open turn's activity line alone. + if ( + event.type === 'turn.open' || + event.type === 'session.ended' || + decision.ends?.current === true + ) { + plan.onAdmitted(() => deps.sink.setActivity?.(null)) + } + return { admission: plan.submit(deps.sink) } + } + + /** The open turn another writer settled (a person's Stop) ends here first, as one transition + * that stops its text and cancels its prompts; refused, the event that found it is refused with + * it and its retry finds it again. */ + const endSettledTurn = ( + journal: NonNullable + ): ProviderTimelineApplyResult | null => { + const open = state.open + if (state.ended || !open || providerTimelineTurnRowState(journal, open.itemId) !== 'settled') { + return null + } + return applyDecided({ type: 'turn.settled', turn: open }, journal) + } + + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + const settled = journal ? endSettledTurn(journal) : null + if (settled && !settled.admission.accepted) { + return settled + } + switch (event.type) { + case 'text.delta': + return applyProviderTimelineTextDelta(textHost(journal), event) + case 'text.close': + return applyProviderTimelineTextClose(textHost(journal), event) + case 'activity': { + const open = state.open + if (state.ended || !open) { + return { admission: ADMITTED, dropped: state.ended ? 'session-ended' : 'no-turn' } + } + deps.sink.setActivity?.( + event.text === null ? null : { turnId: open.turnId, text: event.text } + ) + return { admission: ADMITTED } + } + case 'input.accepted': + case 'turn.open': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'provider.frame': + case 'session.ended': + return applyDecided(event, journal) + } + } + + return { + apply, + get openTurnId() { + return state.open?.turnId ?? null + }, + flush: () => streams.flush(), + dispose: () => streams.dispose() + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts new file mode 100644 index 00000000000..c8522462245 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -0,0 +1,33 @@ +// Work that outlives its turn is a background-task row: a message carrying a `background-task` +// block with its own run state, the row every lane writes for it. It is not open work a turn's +// end settles, so it outlives its turn by what the row is, whichever run of the assembler ends +// that turn; only its own updates, or the session's end, change its state. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { canReplaceBackgroundTaskState } from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' + +/** Whether `next` would move a task `held` already settled back to a state it may not take. */ +export function relightsProviderTimelineBackgroundTask( + held: AgentJournalItemBody | null, + next: AgentJournalItemBody +): boolean { + if (held?.kind !== 'message' || next.kind !== 'message') { + return false + } + const states = new Map() + for (const block of held.blocks) { + if (isBackgroundTaskBlock(block)) { + states.set(block.taskId, block.state) + } + } + return next.blocks.some((block) => { + const current = isBackgroundTaskBlock(block) ? states.get(block.taskId) : undefined + return ( + isBackgroundTaskBlock(block) && + current !== undefined && + current !== block.state && + !canReplaceBackgroundTaskState(current, block.state) + ) + }) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts new file mode 100644 index 00000000000..3422ad01f6e --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -0,0 +1,69 @@ +// One admission budget for everything the assembler holds open: running items, pending requests +// and open text streams. The open set is the state's open items plus the open streams, one entry +// per item (an item and its stream share its row id) and one per request key whatever its +// incarnation. Before refusing, each entry is checked against the journal by key, so an answer a +// client gave, a row another writer settled, or a stream whose turn settled frees its room. Past the budget an event is refused +// as `failed`, which ends the session the way a failed journal write does, and the journal-derived +// dead-generation settlement closes whatever it left open. + +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export const MAX_PROVIDER_TIMELINE_OPEN_ENTRIES = 128 +export const MAX_PROVIDER_TIMELINE_OPEN_BYTES = 1024 * 1024 + +export const PROVIDER_TIMELINE_OVER_BUDGET: StructuredAgentSessionSinkAdmission = { + accepted: false, + reason: 'failed' +} + +export type ProviderTimelineHold = { key: string; bytes: number } + +export function providerTimelineBudgetAdmits(input: { + hold: ProviderTimelineHold + state: ProviderTimelineState + streams: ProviderTimelineTextStreams + journal: StructuredAgentSessionTransitionJournal | null +}): boolean { + if (fits(input)) { + return true + } + const { journal, state, streams } = input + if (!journal) { + return false + } + // Another writer's fact, read by key: what it settled is settled. + for (const key of state.items.keys()) { + if (state.settledInJournal(key, journal)) { + state.items.delete(key) + } + } + streams.stopSettled(journal) + return fits(input) +} + +function fits(input: { + hold: ProviderTimelineHold + state: ProviderTimelineState + streams: ProviderTimelineTextStreams +}): boolean { + const open = new Map() + for (const [key, entry] of input.state.items) { + if (!entry.closed) { + open.set(key, entry.bytes) + } + } + for (const stream of input.streams.open) { + open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.bytes)) + } + open.set(input.hold.key, Math.max(open.get(input.hold.key) ?? 0, input.hold.bytes)) + let bytes = 0 + for (const each of open.values()) { + bytes += each + } + return ( + open.size <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts new file mode 100644 index 00000000000..d321dfbe7d8 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -0,0 +1,64 @@ +// What every decision of one assembler shares, and where a joined row goes. + +import type { + AgentJournalProducerLinkage, + AgentJournalTurnScope, + AgentType +} from '../../../shared/agent-session-journal-types' +import type { ProviderTimelineJoin } from './provider-timeline-event' +import { providerKey, type ProviderTimelineRows } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' + +export type ProviderTimelineContext = { + sessionId: string + agent: AgentType + generation: string + rows: ProviderTimelineRows + /** The session's own provider thread; a join naming another thread is subagent work. */ + ownThread?: () => string | null +} + +/** A settlement id no other settlement of this journal shares. */ +export function providerTimelineSettlementId( + context: ProviderTimelineContext, + serial: number, + what: string +): string { + return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` +} + +/** The turn a new row joins: the one the provider names, else the open one, else none. Subagent + * work on a thread of its own joins the open turn whatever turn it names. */ +export function providerTimelinePlacement( + context: ProviderTimelineContext, + state: ProviderTimelineState, + join: ProviderTimelineJoin | undefined +): AgentJournalTurnScope { + const thread = join?.thread ?? null + if (join?.turn === undefined) { + return state.scope + } + const own = context.ownThread?.() ?? null + if (thread !== null && own !== null && thread !== own) { + return state.scope + } + return { kind: 'turn', turnItemId: context.rows.turn(providerKey(join.turn)).itemId } +} + +/** What an entry the assembler holds open costs: its body, its producer, and every provider + * string it keeps (its key and join), each twice: as given, and inside the identities spelled + * from it. */ +export function providerTimelineEntryBytes(input: { + key: string + join: ProviderTimelineJoin | undefined + body?: unknown + producer: AgentJournalProducerLinkage | undefined +}): number { + const measure = (value: unknown) => + value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') + const kept = [input.key, input.join?.thread ?? '', input.join?.turn ?? ''].reduce( + (total, part) => total + Buffer.byteLength(part, 'utf8'), + 0 + ) + return 2 * kept + measure(input.body) + measure(input.producer) + 64 +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts new file mode 100644 index 00000000000..61fdfe4cd49 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -0,0 +1,128 @@ +// What a non-text event does, in two parts that never share a clock. +// +// Admission decides from the state (and the journal's rows by key, when bound) whether the event +// is dropped, what it holds open, and how it changes the state; it writes and allocates nothing. +// Each write's resolver decides, when the write runs, what the journal holds then: a resume admits +// events before the sink binds, and the dead-generation sweep lands between admission and the +// write, so every journal-dependent choice is made there. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionItemAppendOptions } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' +import type { ProviderTimelineTurnRef } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import { + decideContextUsage, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen, + decideTurnSettled +} from './provider-timeline-turn-decisions' + +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a fact already held. */ +export type ProviderTimelineDropRule = + | 'session-ended' + | 'turn-duplicate' + | 'turn-settled' + | 'turn-unknown' + | 'no-turn' + | 'item-settled' + | 'request-duplicate' + | 'request-unknown' + | 'stream-unknown' + | 'stream-mismatch' + +export type ProviderTimelineDecidedEvent = + | Exclude + /** The assembler's own: the journal shows the open turn settled by another writer (a person's + * Stop), so its text stops and its prompts are cancelled; its running tool calls wait for the + * provider's own `turn.end`. */ + | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } + +type Journal = StructuredAgentSessionTransitionJournal + +export type ProviderTimelineResolvedWrite = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody +} + +/** One row write: where it goes is fixed at admission, what it says is resolved when it runs. */ +export type ProviderTimelineItemWrite = { + reservedBytes: number + lifecycle: boolean + /** A new row's turn is admission's placement; an existing row keeps its own. */ + options: StructuredAgentSessionItemAppendOptions + resolve: (journal: Journal) => ProviderTimelineResolvedWrite | null +} + +export type ProviderTimelineDecision = { + dropped?: ProviderTimelineDropRule + /** What the event holds open, for the budget. */ + hold?: ProviderTimelineHold + /** The event's change to the state, made when the sink admits it. */ + commit?: (state: ProviderTimelineState) => void + /** The settlement the event owes, read from the journal when it runs. */ + settle?: { what: string; resolve: (journal: Journal) => readonly JournalLifecycleMutationInput[] } + writes?: readonly ProviderTimelineItemWrite[] + /** The streamed item whose text this event's full snapshot replaces. */ + closes?: string + /** The turn this event ends; `current` unless another turn is open, whose text and activity + * an earlier turn's end leaves alone. */ + ends?: { turnItemId: string; current: boolean } +} + +export type ProviderTimelineDecisionInput = { + context: ProviderTimelineContext + state: ProviderTimelineState + /** The journal at admission; null before the sink binds. */ + journal: Journal | null + /** Serials this event takes, committed with it. */ + serial: () => number +} + +export function decideProviderTimelineEvent( + input: ProviderTimelineDecisionInput, + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended) { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, event) + case 'turn.settled': + return decideTurnSettled(event) + case 'item.open': + case 'item.update': + case 'item.close': + return decideItem(input, event) + case 'request.open': + return decideRequest(input, event) + case 'request.withdrawn': + return decideWithdrawal(input, event) + case 'context.usage': + return decideContextUsage(input, event) + case 'provider.frame': + return decideFrame(input, event) + case 'session.ended': + return decideSessionEnd(input, event) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts new file mode 100644 index 00000000000..4cd8b8650ff --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -0,0 +1,129 @@ +// The narrow grammar a provider adapter speaks to the timeline assembler. +// +// The adapter knows the provider's dialect; the assembler knows the timeline. An adapter parses +// provider traffic into these semantic events and never writes a journal row or mints a journal +// identity: every key here is the provider's own id (a turn, a tool call, a message, a thread), +// or a name the adapter keeps stable for something the provider does not name. The assembler +// spells those keys as row ids, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`. +// +// Rules an adapter can rely on: +// - One assembler lives exactly as long as one provider child: a new process is a new assembler, +// with a new acquisition generation. +// - Only `turn.open` opens a turn. Items, text and requests never do: one that arrives with no +// turn open and names none is written as a thread row. An adapter whose provider starts work on +// its own (a background wake, auto-compaction) decides that a turn began and says `turn.open` +// before that work's items. +// - `join.turn` attaches a row to the turn the provider says it belongs to, open or not. +// - A turn settles once, with only the provider's verdict. A row the journal already holds is +// never written back to an earlier state: a settled turn stays settled, a settled tool keeps its +// terminal body, an answered request stays answered. +// - A turn another writer settled (a person's Stop) is over here: its pending requests are +// cancelled, and text still streaming into it is dropped until the provider's end of that turn +// or the next `turn.open`. Its running tool calls are still the provider's: a close reported +// after the Stop lands as reported, and whatever is still running settles at the provider's +// `turn.end` for it (or the next `turn.open`, or `session.ended`). +// - An event the sink refused changed nothing. Re-apply the same event to retry it (after +// `backpressure`); `failed` and `closed` are final. +// - A provider item is (thread, id): the same id on another thread is another item. Text and +// full snapshots of one id are one row, and its close settles it for both. +// - After `session.ended` every event is dropped. + +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { + AgentJournalApprovalItem, + AgentJournalItemBody, + AgentJournalProducerLinkage, + AgentJournalQuestionItem, + AgentJournalTurnOutcome +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTurnVerdict } from '../agent-session-wire/structured-agent-session-stale-turn-verdict' + +/** Bodies an item event may carry. Turn rows are the assembler's; prompts travel as requests. */ +export type ProviderTimelineItemBody = Exclude< + AgentJournalItemBody, + { kind: 'turn' | 'approval' | 'question' } +> + +export type ProviderTimelineRequestBody = AgentJournalApprovalItem | AgentJournalQuestionItem + +/** `assistant` is reply text; `reasoning` is the model's visible thinking. */ +export type ProviderTimelineTextChannel = 'assistant' | 'reasoning' + +/** Where the provider says something belongs. */ +export type ProviderTimelineJoin = { + /** The provider thread it came from, when the provider runs several (a subagent's own thread). */ + thread?: string + /** The provider turn it belongs to. Absent: the turn open now. */ + turn?: string +} + +/** A streamed text item: one the provider names (the same item as item events with that id), or + * an anonymous stream that becomes a new message each time it starts. */ +export type ProviderTimelineTextItem = { id: string } | { stream: string } + +type Produced = { + /** The subagent that produced this, when not the session's own agent. Stamped on the row as is. */ + producer?: AgentJournalProducerLinkage +} + +type Joined = { join?: ProviderTimelineJoin } + +export type ProviderTimelineEvent = + /** Orca's send reached the provider. It names the open turn when nothing opened that one, else + * the next; `join.turn` names the turn it opens. The send's own row is the bubble. */ + | { + type: 'input.accepted' + clientMessageId: string + requestedAt: number + join?: ProviderTimelineJoin + } + /** A turn began. `turn` is the provider's turn id when it has one; the assembler mints one otherwise. */ + | { type: 'turn.open'; turn?: string; at: number } + /** The provider ended a turn. Absent `turn` means the open turn, else the one a person stopped + * that the provider had not ended yet. A turn already over (another writer's Stop, a newer + * turn) takes it too: what it left open settles, its row stays, and the open turn's text and + * activity are untouched. */ + | { + type: 'turn.end' + turn?: string + at: number + state: 'completed' | 'interrupted' + /** The provider's own verdict. Absent means it gave none, which reads as unknown. */ + outcome?: AgentJournalTurnOutcome + /** The provider's own measured duration. */ + durationMs?: number + } + /** Work began. Work that outlives its turn (a backgrounded task) is a background-task row — a + * message carrying a `background-task` block with its own run state — beside the tool call that + * started it, which closes as usual: no turn's end settles that row; its own updates do, and the + * session's end leaves one still in flight `unverifiable`. */ + | ({ type: 'item.open'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** The item's whole current body. Content may be replaced; a settled tool keeps its terminal body. */ + | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** The item's whole terminal body; it replaces any text streamed into the same item. */ + | ({ type: 'item.close'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) + /** Streamed text. */ + | ({ + type: 'text.delta' + item: ProviderTimelineTextItem + channel: ProviderTimelineTextChannel + text: string + } & Produced & + Joined) + /** The stream ended; `text` is the provider's final text, else what streamed is kept. `join` + * names the same item its deltas named. */ + | ({ type: 'text.close'; item: ProviderTimelineTextItem; text?: string } & Joined) + /** The provider asked the user something and waits on the answer. */ + | ({ type: 'request.open'; request: string; body: ProviderTimelineRequestBody } & Produced & + Joined) + /** The provider stopped waiting for an answer it never got; one already settled stays settled. */ + | { type: 'request.withdrawn'; request: string } + /** What the provider said about its context window: for `join.turn`, else the open turn, else the last. */ + | ({ type: 'context.usage'; usage: AgentSessionContextUsage } & Joined) + /** The live activity line for the open turn; null clears it. */ + | { type: 'activity'; text: string | null } + /** Provider traffic no typed event covers; it becomes the shared bounded fallback row. */ + | ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined) + /** The provider child is gone. The verdict is what the host can prove about its end. */ + | { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts new file mode 100644 index 00000000000..6242af3998f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + providerTimelineKeyPart, + spellProviderTimelineKey +} from './provider-timeline-identity' + +const scheme = createLegacyProviderTimelineIdentityScheme({ agent: 'grok', sessionId: 's1' }) +const provider = (value: string) => ({ source: 'provider', value }) as const + +describe('provider timeline identity spelling', () => { + it('escapes a key so it cannot forge another part', () => { + expect(providerTimelineKeyPart('a:b/c')).toBe('a%3Ab%2Fc') + expect(spellProviderTimelineKey('ns', provider('x:y'))).not.toBe( + spellProviderTimelineKey('ns:x', provider('y')) + ) + }) + + it('bounds a long key by a digest, so two long keys with one prefix stay apart', () => { + const head = 'k'.repeat(400) + const one = providerTimelineKeyPart(`${head}1`) + const two = providerTimelineKeyPart(`${head}2`) + expect(Buffer.byteLength(one, 'utf8')).toBeLessThanOrEqual(256) + expect(one).not.toBe(two) + }) + + it('spells an item per thread, and a minted key apart from any provider one', () => { + const item = (thread: string | null) => + agentJournalItemKey( + scheme.item({ namespace: 'ns', family: 'item', key: provider('m1'), thread }) + ) + expect(new Set([item(null), item('root'), item('child')]).size).toBe(3) + expect(spellProviderTimelineKey('ns', { source: 'minted', value: 'gen-1:s1' })).toBe( + 'm:gen-1%3As1' + ) + }) + + it('spells a request in the acquisition that asked it, with its incarnation', () => { + const request = (generation: string, incarnation: number) => + scheme.request({ generation, key: '0', incarnation }) + expect(request('gen-1', 1)).toMatchObject({ provider: 'legacy', recordId: 'request:g:gen-1:0' }) + expect(request('gen-1', 2)).toMatchObject({ recordId: 'request:g:gen-1:0#2' }) + expect(agentJournalItemKey(request('gen-2', 1))).not.toBe( + agentJournalItemKey(request('gen-1', 1)) + ) + }) + + it('keeps turn rows on the record prefix every lane writes', () => { + const turn = { namespace: 'ns', key: provider('t1') } + expect(scheme.turn(turn)).toMatchObject({ recordId: 'turn-lifecycle:p:ns:t1' }) + expect(scheme.turnId(turn)).toBe('p:ns:t1') + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts new file mode 100644 index 00000000000..6dd47f03ec5 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,106 @@ +// How the assembler's keys become persisted journal identities. +// +// The assembler decides WHICH rows are the same row; a scheme only spells them. It must be pure, +// and it is the one place a provider's persisted identity shape lives. Every provider on the +// assembler spells its rows in the existing `legacy` arm, so no row shape is new. + +import type { + AgentJournalItemIdentity, + AgentType +} from '../../../shared/agent-session-journal-types' +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' + +/** A key the provider vouched for, or one the assembler minted because it named nothing. + * A minted value is unique per acquisition, so it never needs a namespace. */ +export type ProviderTimelineKey = { source: 'provider' | 'minted'; value: string } + +/** Streamed text and full item snapshots share `item`, so a provider-named message is one row + * however it arrives; fallback frames never share its key space. */ +export type ProviderTimelineItemFamily = 'item' | 'frame' + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +export type ProviderTimelineItemAddress = ProviderTimelineTurnAddress & { + family: ProviderTimelineItemFamily + /** The provider thread it came from; null when the provider named none. */ + thread: string | null +} + +/** A question the provider asked under its request id. JSON-RPC ids restart with each provider + * process, so a request is spelled in the acquisition that asked it. */ +export type ProviderTimelineRequestAddress = { + generation: string + key: string + /** 1 for the first request under the key in this acquisition; a reused key takes the next. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity + request(address: ProviderTimelineRequestAddress): AgentJournalItemIdentity +} + +const MAX_KEY_PART_BYTES = 256 + +/** A provider id as a bounded identity part: escaped so `:` cannot forge another part, and + * digest-suffixed when long so two long ids never share a prefix-only spelling. */ +export function providerTimelineKeyPart(value: string): string { + const encoded = encodeURIComponent(value) + if (Buffer.byteLength(encoded, 'utf8') <= MAX_KEY_PART_BYTES) { + return encoded + } + const suffix = `#${digestPayload(value).slice(0, 24)}` + const head = boundPayload(encoded, { + inlineHeadBytes: MAX_KEY_PART_BYTES - Buffer.byteLength(suffix, 'utf8') + }).head + return `${head}${suffix}` +} + +/** A key as one bounded string inside its namespace (and thread, for a per-thread key). */ +export function spellProviderTimelineKey( + namespace: string, + key: ProviderTimelineKey, + thread: string | null = null +): string { + // A provider's item ids are its own per thread, so a subagent thread's ids are spelled apart. + const scope = thread === null ? '' : `${providerTimelineKeyPart(thread)}/` + return key.source === 'provider' + ? `p:${providerTimelineKeyPart(namespace)}:${scope}${providerTimelineKeyPart(key.value)}` + : `m:${providerTimelineKeyPart(key.value)}` +} + +/** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes + * write; provider keys are spelled inside their namespace, and apart from minted ones. */ +export function createLegacyProviderTimelineIdentityScheme(input: { + agent: AgentType + sessionId: string +}): ProviderTimelineIdentityScheme { + const identity = (recordId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId + }) + return { + turn: (address) => + identity(`turn-lifecycle:${spellProviderTimelineKey(address.namespace, address.key)}`), + turnId: (address) => spellProviderTimelineKey(address.namespace, address.key), + item: (address) => + identity( + `${address.family}:${spellProviderTimelineKey(address.namespace, address.key, address.thread)}` + ), + request: (address) => + identity( + `request:g:${providerTimelineKeyPart(address.generation)}:${providerTimelineKeyPart(address.key)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts new file mode 100644 index 00000000000..22e8a1c6f1e --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -0,0 +1,242 @@ +// What item, request and frame events do: admitted on the state, written against the journal. + +import { isDeepStrictEqual } from 'node:util' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' +import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { + providerKey, + providerTimelineTurnRowState, + turnOf, + type ProviderTimelineRowId +} from './provider-timeline-rows' +import type { ProviderTimelineRequestBody } from './provider-timeline-event' +import type { ProviderTimelineOpenItem } from './provider-timeline-state' + +type Journal = StructuredAgentSessionTransitionJournal +type ItemChange = 'open' | 'update' | 'close' + +function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimelineRequestBody { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +/** Whether the row the journal holds refuses this write: a settled tool keeps its first terminal + * body (whoever settled it, the sweep included), a settled background task is never relit, and a + * turn that is over takes no new work that waits on a settlement. Work it still holds open (a + * person's Stop leaves the provider's running tools) takes the provider's updates until settled. */ +function refusesItemWrite( + journal: Journal, + row: ProviderTimelineRowId, + change: ItemChange, + body: AgentJournalItemBody, + placed: string | null +): boolean { + const held = journal.item(row.itemId) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed + if ( + held && + settledTool(held.body) && + (change === 'close' || !isDeepStrictEqual(held.body, body)) + ) { + return true + } + if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { + return true + } + return ( + requiresTerminalSettlement(body) && + !(held && requiresTerminalSettlement(held.body)) && + turn !== null && + providerTimelineTurnRowState(journal, turn) === 'settled' + ) +} + +export function decideItem( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const change = + event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' + const row = context.rows.item('item', providerKey(event.item), event.join?.thread ?? null) + const key = row.itemId + const closed = state.items.get(key)?.closed === true + const scope = providerTimelinePlacement(context, state, event.join) + const placed = turnOf(scope) + if ( + (closed && + (change === 'close' || (change === 'update' && requiresTerminalSettlement(event.body)))) || + (journal && refusesItemWrite(journal, row, change, event.body, placed)) + ) { + return { dropped: 'item-settled' } + } + const obligation = change !== 'close' && requiresTerminalSettlement(event.body) + const bytes = providerTimelineEntryBytes({ + key: event.item, + join: event.join, + body: event.body, + producer: event.producer + }) + return { + ...(obligation ? { hold: { key, bytes } } : {}), + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: change !== 'update', + options: { ...event.producer, turnScope: scope }, + resolve: (at) => + refusesItemWrite(at, row, change, event.body, placed) + ? null + : { identity: row.identity, body: event.body } + } + ], + ...(change === 'close' ? { closes: key } : {}), + commit: (next) => { + if (change === 'close') { + next.close(key, placed) + } else if (obligation) { + next.items.set(key, { kind: 'item', row, turnItemId: placed, bytes, closed: false }) + } else if (change === 'open' || next.items.get(key)?.closed !== true) { + next.items.delete(key) + } + } + } +} + +const requestKey = (request: string) => `request:${request}` + +export function decideRequest( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const key = requestKey(event.request) + // Pending here, unless a client's answer already settled it in the journal. + if (state.items.has(key) && !(journal && state.settledInJournal(key, journal))) { + return { dropped: 'request-duplicate' } + } + const scope = providerTimelinePlacement(context, state, event.join) + const turn = turnOf(scope) + const bytes = providerTimelineEntryBytes({ + key: event.request, + join: event.join, + body: event.body, + producer: event.producer + }) + const opened: ProviderTimelineOpenItem = { + kind: 'request', + row: null, + turnItemId: turn, + bytes, + closed: false + } + return { + hold: { key, bytes }, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes( + context.rows.widestRequest(event.request).identity, + event.body + ), + lifecycle: true, + options: { ...event.producer, turnScope: scope, lifecycle: true }, + resolve: (at) => { + // A reused id takes the next incarnation; a row already there is never overwritten. + const row = context.rows.nextRequest(event.request, at) + opened.row = row + // A turn another writer ended while the open was queued asks nothing more. + if (turn !== null && providerTimelineTurnRowState(at, turn) === 'settled') { + return null + } + return { identity: row.identity, body: event.body } + } + } + ], + commit: (next) => next.items.set(key, opened) + } +} + +export function decideWithdrawal( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const key = requestKey(event.request) + const entry = state.items.get(key) + const opened = entry?.kind === 'request' ? entry : null + // One its turn's end already let go is still the journal's newest row under its id. + if (!opened && journal && !context.rows.heldRequest(event.request, journal)) { + return { dropped: 'request-unknown' } + } + return { + settle: { + what: 'request-withdrawn', + resolve: (at) => { + // The open ran first and named its row. Only while it is pending: a client's answer, or + // the settlement of its turn, that landed first stands. + const row = opened ? opened.row : context.rows.heldRequest(event.request, at) + const held = row ? at.item(row.itemId) : null + const cancelled = + held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null + if (!row || !held || !cancelled) { + return [] + } + return [ + { + kind: 'item', + identity: row.identity, + body: cancelled, + turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + }, + commit: (next) => next.items.delete(key) + } +} + +export function decideFrame( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, context } = input + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (!frame) { + return {} + } + const row = context.rows.item( + 'frame', + context.rows.minted('f', input.serial()), + event.join?.thread ?? null + ) + const write = { identity: row.identity, body: frame.body } + return { + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, frame.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: () => write + } + ] + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts new file mode 100644 index 00000000000..f9e9e0e33e3 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -0,0 +1,82 @@ +// One grammar event's journal writes, admitted as one sink transition. +// +// What each write says, and whether it writes at all, is decided by its resolver when the +// transition reaches its turn in the journal's write queue. `onAdmitted` work (the state, text +// marked written) runs only when the sink takes the transition, so a refused event leaves the +// assembler exactly as it was and re-applying it is the retry. + +import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionStep } from '../agent-session-wire/structured-agent-session-transition' + +/** The sink calls the assembler needs: one-operation transitions, and the journal as it stands. */ +export type ProviderTimelineSink = Required< + Pick +> & + Pick + +type ItemStep = Extract +type SettlementStep = Extract + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export class ProviderTimelinePlan { + private readonly steps: StructuredAgentSessionTransitionStep[] = [] + private readonly admitted: (() => void)[] = [] + private lifecycle = false + + item(step: Omit, lifecycle = false): void { + this.steps.push({ kind: 'item', ...step }) + this.lifecycle ||= lifecycle + } + + settlement(step: Omit): void { + this.steps.push({ kind: 'settlement', ...step }) + this.lifecycle = true + } + + /** Runs once the sink takes the transition. */ + onAdmitted(change: () => void): void { + this.admitted.push(change) + } + + submit(sink: ProviderTimelineSink): StructuredAgentSessionSinkAdmission { + if (this.steps.length > 0) { + const admission = sink.tryAppendTransition({ + steps: this.steps, + lifecycle: this.lifecycle, + publish: true + }) + if (!admission.accepted) { + return admission + } + } + for (const change of this.admitted) { + change() + } + return ADMITTED + } +} + +/** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ +export function providerTimelineSink( + sink: StructuredAgentSessionEventSink +): ProviderTimelineSink | null { + const { tryAppendTransition, journalItems, setActivity } = sink + if (!tryAppendTransition || !journalItems) { + return null + } + return { + tryAppendTransition: (transition) => tryAppendTransition.call(sink, transition), + journalItems: () => journalItems.call(sink), + ...(setActivity + ? { + setActivity: (activity: AgentSessionTurnActivity | null) => + setActivity.call(sink, activity) + } + : {}) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts new file mode 100644 index 00000000000..8ce41039c30 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -0,0 +1,119 @@ +// Provider keys → journal rows, with no index to keep. +// +// Every row the assembler writes has an identity spelled from its key, so finding a row is +// spelling its id and reading that row: nothing to cache, evict or rebuild after a restart. The +// turn a row belongs to is the one the journal holds for it (its first write fixes it). Request +// incarnations are probed: the next is the first spelling the journal holds no row for. + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineIdentityScheme, + ProviderTimelineItemFamily, + ProviderTimelineKey +} from './provider-timeline-identity' + +export type ProviderTimelineRowId = { identity: AgentJournalItemIdentity; itemId: string } + +export type ProviderTimelineTurnRef = ProviderTimelineRowId & { + key: ProviderTimelineKey + /** The id the turn row carries and a client's Stop names. */ + turnId: string +} + +export type ProviderTimelineTurnRowState = 'absent' | 'running' | 'settled' + +type Journal = StructuredAgentSessionTransitionJournal + +/** Room an item step reserves for a request id: the longest incarnation suffix it could take. */ +const REQUEST_INCARNATION_BOUND = 1_000_000_000 + +export function providerKey(value: string): ProviderTimelineKey { + return { source: 'provider', value } +} + +export function turnOf(scope: AgentJournalTurnScope): string | null { + return scope.kind === 'turn' ? scope.turnItemId : null +} + +/** What the journal holds for a turn row: any writer's settlement (a person's Stop included). */ +export function providerTimelineTurnRowState( + journal: Journal, + turnItemId: string +): ProviderTimelineTurnRowState { + const row = readAgentJournalTurn(journal.itemBody(turnItemId) ?? undefined) + return !row ? 'absent' : row.state === 'running' ? 'running' : 'settled' +} + +export class ProviderTimelineRows { + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + /** A key unique to this acquisition; `serial` is taken from the state when the event is admitted. */ + minted(kind: string, serial: number): ProviderTimelineKey { + return { source: 'minted', value: `${this.deps.generation}:${kind}${serial}` } + } + + turn(key: ProviderTimelineKey): ProviderTimelineTurnRef { + const address = { namespace: this.deps.namespace, key } + const identity = this.deps.scheme.turn(address) + return { + key, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + item( + family: ProviderTimelineItemFamily, + key: ProviderTimelineKey, + thread: string | null + ): ProviderTimelineRowId { + return this.row(this.deps.scheme.item({ namespace: this.deps.namespace, family, key, thread })) + } + + /** The widest id a request under `key` could take, for the room its write reserves. */ + widestRequest(key: string): ProviderTimelineRowId { + return this.request(key, REQUEST_INCARNATION_BOUND) + } + + /** The first incarnation under `key` the journal holds no row for: never one already written. */ + nextRequest(key: string, journal: Journal): ProviderTimelineRowId { + return this.request(key, this.heldIncarnations(key, journal) + 1) + } + + /** The newest incarnation under `key` the journal holds, if any. */ + heldRequest(key: string, journal: Journal): ProviderTimelineRowId | null { + const held = this.heldIncarnations(key, journal) + return held === 0 ? null : this.request(key, held) + } + + private heldIncarnations(key: string, journal: Journal): number { + let held = 0 + while (journal.itemBody(this.request(key, held + 1).itemId) !== null) { + held += 1 + } + return held + } + + private request(key: string, incarnation: number): ProviderTimelineRowId { + return this.row( + this.deps.scheme.request({ generation: this.deps.generation, key, incarnation }) + ) + } + + private row(identity: AgentJournalItemIdentity): ProviderTimelineRowId { + return { identity, itemId: agentJournalItemKey(identity) } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts new file mode 100644 index 00000000000..24d35559a2e --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -0,0 +1,144 @@ +// What a turn's end or the session's end settles, read from the journal when the settlement runs. +// +// The same mechanism, and the same terminal bodies, as the dead-generation settlement after a +// restart: every row still waiting on the row that settles it — a running tool call, a pending +// prompt — is settled from its body as the journal holds it then. Nothing about open work is +// trusted from memory, so a prompt a client answered a moment earlier stays answered, and a +// second settlement finds nothing left to do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { lostLiveWorkJournalBody } from '../agent-session-journal/journal-subagent-liveness' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' + +/** How a turn ended: the provider's report, or what the host could prove when the child went. */ +export type ProviderTimelineTurnEnd = + | { + state: 'completed' | 'interrupted' + completedAt: number + outcome?: AgentJournalTurnLifecycle['outcome'] + durationMs?: number + } + | { state: 'unverifiable' } + +/** The end owns the turn's terminal fields; `unverifiable` carries no end and no verdict. Fields + * keep the order every lane writes them in, so the row is the same bytes whoever ends it. */ +export function endedProviderTimelineTurn( + running: AgentJournalTurnLifecycle, + end: ProviderTimelineTurnEnd +): AgentJournalTurnLifecycle { + const { turnId, userItemId, startedAt, requestedAt } = running + const opened = { + ...(userItemId !== undefined ? { userItemId } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(requestedAt !== undefined ? { requestedAt } : {}) + } + if (end.state === 'unverifiable') { + return { turnId, state: 'unverifiable', ...opened } + } + return { + turnId, + state: end.state, + ...(end.outcome !== undefined ? { outcome: end.outcome } : {}), + ...opened, + completedAt: end.completedAt, + ...(end.durationMs !== undefined ? { durationMs: end.durationMs } : {}) + } +} + +/** Which open rows a settlement covers: one turn's, or every row when the session ends. A turn + * another writer settled covers only its prompts: its tool calls are the provider's to finish. */ +export type ProviderTimelineSettlementScope = { turnItemId: string; promptsOnly?: true } | 'session' + +/** The turns a settlement ends, and how; absent when another writer already ended the turn. */ +export type ProviderTimelineSettlementEnding = { + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[] + end: ProviderTimelineTurnEnd +} + +/** The settled rows of `scope`, then each ended turn's row while the journal holds it running. */ +export function providerTimelineSettlement( + journal: StructuredAgentSessionTransitionJournal, + scope: ProviderTimelineSettlementScope, + ending?: ProviderTimelineSettlementEnding +): JournalLifecycleMutationInput[] { + const mutations: JournalLifecycleMutationInput[] = [] + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + const covered = + scope === 'session' || + (turnScope.kind === 'turn' && + turnScope.turnItemId === scope.turnItemId && + !(scope.promptsOnly && body.kind === 'tool-call')) + // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. + const settled = !covered + ? null + : (terminalAgentJournalBody(body) ?? + (scope === 'session' ? lostLiveWorkJournalBody(body) : null)) + const identity = settled ? parseAgentJournalItemKey(itemId) : null + if (settled && identity) { + // No linkage: a revision keeps the row's own producer. + mutations.push({ kind: 'item', identity, body: settled, turnScope }) + } + }) + for (const turn of ending?.turns ?? []) { + const ended = ending ? endedTurnRow(journal, turn, ending.end) : null + if (ended) { + mutations.push({ kind: 'item', ...ended, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + } + return mutations +} + +/** Every turn row the journal holds running, for the session's end. */ +export function runningProviderTimelineTurns( + journal: StructuredAgentSessionTransitionJournal +): { identity: AgentJournalItemIdentity; itemId: string }[] { + const running: { identity: AgentJournalItemIdentity; itemId: string }[] = [] + journal.visitItems((itemId, _sequence, body) => { + const identity = + readAgentJournalTurn(body)?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (identity) { + running.push({ identity, itemId }) + } + }) + return running +} + +function endedTurnRow( + journal: StructuredAgentSessionTransitionJournal, + turn: { identity: AgentJournalItemIdentity; itemId: string }, + end: ProviderTimelineTurnEnd +): { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | null { + const running = readAgentJournalTurn(journal.itemBody(turn.itemId) ?? undefined) + if (running?.state !== 'running') { + return null + } + const target = { identity: turn.identity } + const write = { + lifecycle: agentJournalTurnBody(endedProviderTimelineTurn(running, end)), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. + onlyWhileRunning: true as const + } + return resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts new file mode 100644 index 00000000000..991dea1ce26 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -0,0 +1,166 @@ +// What the assembler knows about the session: one state, in admission order. +// +// An event changes it only when the sink admits the event, so a refused event changed nothing and +// re-applying it is the retry. For this producer's own rows admission order is write order (the +// sink is a FIFO queue), so the state never has to be re-read from the journal. Facts other +// writers own (a person's Stop, a client's answer) are read from the journal by key, never copied. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineRowId, ProviderTimelineTurnRef } from './provider-timeline-rows' + +/** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ +const MAX_CLOSED_ITEMS = 512 +/** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ +const MAX_PENDING_INPUTS = 64 +const MAX_PENDING_INPUT_BYTES = 64 * 1024 + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { + running: AgentJournalTurnLifecycle +} + +/** Orca's send, waiting for the turn it opens. */ +export type ProviderTimelinePendingInput = { + userItemId: string + requestedAt: number + /** The turn row the provider said it opens; absent: the next turn to open. */ + turnItemId?: string +} + +/** Work this acquisition holds open (a running tool, a pending request), or an item it closed. */ +export type ProviderTimelineOpenItem = { + kind: 'item' | 'request' + /** The row; a request's is chosen by its write, which records it here. */ + row: ProviderTimelineRowId | null + /** The turn whose end settles it; null for a row in no turn. */ + turnItemId: string | null + bytes: number + /** Closed here: holds no budget; a repeat close is dropped and an open reopens it. */ + closed: boolean +} + +export class ProviderTimelineState { + ended = false + open: ProviderTimelineOpenTurn | null = null + /** The turn that ended last, for context facts that arrive after it. */ + latest: ProviderTimelineTurnRef | null = null + /** The turn another writer settled (a person's Stop) that the provider has not ended yet: its + * running work is the provider's until then. */ + stopped: ProviderTimelineTurnRef | null = null + inputs: ProviderTimelinePendingInput[] = [] + /** Keyed by row id (an item) or `request:` (a request, whatever its incarnation). */ + items = new Map() + /** Minted keys and settlement ids; taken only by admitted events. */ + serial = 0 + + get scope(): AgentJournalTurnScope { + return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } + + /** Serials for one event: committed with it, so a refused event takes none. */ + serials(): { next(): number; commit(): void } { + let taken = this.serial + return { + next: () => (taken += 1), + commit: () => { + this.serial = taken + } + } + } + + /** Whether the journal shows the work under `key` settled by any writer (a client's answer). + * Work whose write is still queued stays open. */ + settledInJournal(key: string, journal: StructuredAgentSessionTransitionJournal): boolean { + const entry = this.items.get(key) + if (!entry || entry.closed || !entry.row) { + return false + } + const body = journal.itemBody(entry.row.itemId) + // A request write that found its turn over left no row: nothing is pending. + return body === null ? entry.kind === 'request' : !requiresTerminalSettlement(body) + } + + /** A user message waiting for its turn; past the bounds the oldest is forgotten (its turn opens + * as the provider's own). */ + wait(pending: ProviderTimelinePendingInput): void { + this.inputs.push(pending) + const size = (input: ProviderTimelinePendingInput) => + bytes(input.userItemId) + bytes(input.turnItemId ?? '') + 16 + let held = this.inputs.reduce((total, input) => total + size(input), 0) + while (this.inputs.length > MAX_PENDING_INPUTS || held > MAX_PENDING_INPUT_BYTES) { + const forgotten = this.inputs.shift() + if (!forgotten) { + return + } + held -= size(forgotten) + } + } + + /** The message that opens `turnItemId`: the one that named it, else the oldest that named none. */ + opener(turnItemId: string): ProviderTimelinePendingInput | undefined { + return ( + this.inputs.find((input) => input.turnItemId === turnItemId) ?? + this.inputs.find((input) => input.turnItemId === undefined) + ) + } + + /** Closed here, in the turn it joined; past the bound the oldest closed items are forgotten. */ + close(key: string, turnItemId: string | null): void { + this.items.delete(key) + this.items.set(key, { kind: 'item', row: null, turnItemId, bytes: 0, closed: true }) + const closed = [...this.items].filter(([, entry]) => entry.closed) + for (const [oldest] of closed.slice(0, Math.max(0, closed.length - MAX_CLOSED_ITEMS))) { + this.items.delete(oldest) + } + } + + /** The turn is over, and so is everything in it: its end settled that work. */ + endTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + this.latest = turn + } + if (this.stopped?.itemId === turn.itemId) { + this.stopped = null + } + this.forget(turn, () => true) + } + + /** Another writer settled the open turn: it is over here and its prompts were cancelled with it, + * but its running work stays open until the provider ends the turn. */ + stopTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + this.latest = turn + this.stopped = turn + } + this.forget(turn, (entry) => entry.kind === 'request') + } + + endSession(): void { + this.ended = true + this.open = null + this.stopped = null + this.items.clear() + } + + private forget( + turn: ProviderTimelineTurnRef, + which: (entry: ProviderTimelineOpenItem) => boolean + ): void { + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId && which(entry)) { + this.items.delete(key) + } + } + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts new file mode 100644 index 00000000000..ec93367efd4 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -0,0 +1,116 @@ +// Text events: streams admitted like every other event. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineHold } from './provider-timeline-budget' +import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineDropRule } from './provider-timeline-decision' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { providerTimelineTurnRowState, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +export type ProviderTimelineApplyResult = { + /** The sink's answer for the event's writes; refused means nothing changed. */ + admission: StructuredAgentSessionSinkAdmission + dropped?: ProviderTimelineDropRule +} + +/** What the text appliers share with the assembler that owns them. */ +export type ProviderTimelineTextHost = { + sink: ProviderTimelineSink + state: ProviderTimelineState + streams: ProviderTimelineTextStreams + journal: StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean +} + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function applyProviderTimelineTextDelta( + host: ProviderTimelineTextHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams, state } = host + if (state.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const plan = new ProviderTimelinePlan() + const key = streams.key(event.item, event.join) + let stream = streams.get(key) + if (stream && !streams.continues(stream, event.channel, event.producer)) { + if (stream.named) { + return { admission: ADMITTED, dropped: 'stream-mismatch' } + } + // The anonymous stream's next message: what the last one owes lands first. + streams.planFlush(plan) + const ended = stream + streams.planRelease(plan, (each) => each === ended) + stream = undefined + } + if (!stream) { + if (streams.stoppedFor(key, event.join)) { + return { admission: ADMITTED, dropped: 'turn-settled' } + } + if ('id' in event.item && settledNamedItem(host, key)) { + return { admission: ADMITTED, dropped: 'item-settled' } + } + const serials = state.serials() + stream = streams.start({ + item: event.item, + join: event.join, + channel: event.channel, + producer: event.producer, + state, + serial: serials.next() + }) + if (!host.admits({ key: stream.key, bytes: stream.bytes })) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + plan.onAdmitted(serials.commit) + } + streams.planAppend(plan, stream, event.text) + return { admission: plan.submit(host.sink) } +} + +/** A named delta for an item this run closed, or one the journal holds in a settled turn. */ +function settledNamedItem(host: ProviderTimelineTextHost, key: string): boolean { + if (host.state.items.get(key)?.closed) { + return true + } + const held = host.journal?.item(key) + const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : null + return ( + host.journal !== null && + turn !== null && + providerTimelineTurnRowState(host.journal, turn) === 'settled' + ) +} + +export function applyProviderTimelineTextClose( + host: ProviderTimelineTextHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams, state } = host + if (state.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const key = streams.key(event.item, event.join) + const stream = streams.get(key) + if (!stream) { + return { + admission: ADMITTED, + dropped: streams.stoppedFor(key, event.join) ? 'turn-settled' : 'stream-unknown' + } + } + const plan = new ProviderTimelinePlan() + streams.planFlush(plan, stream) + streams.planClose(plan, stream, event.text) + if (stream.named) { + // The close settles the item for full snapshots too. + plan.onAdmitted(() => state.close(stream.key, turnOf(stream.scope))) + } + return { admission: plan.submit(host.sink) } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts new file mode 100644 index 00000000000..eda78997e6d --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -0,0 +1,352 @@ +// Streamed text, as message rows. +// +// A named stream is the item with that id on its thread, so its deltas, its close and any full +// snapshot of it are one row. An anonymous stream becomes a new message each time it starts, and +// its identity is its name, thread, channel and producer: a change to any starts the next +// message. Deltas accumulate in the shared coalescer, and a row is a snapshot of the text so far. +// Text owed to the journal is written inside the next event's transition — every other event is +// an ordering barrier — or by the coalescer's window, and is marked written once the sink takes it. +// Every write first reads the turn its row is in: once any writer settled that turn (a person's +// Stop, the turn's own end), the stream writes nothing more. A stream stopped that way keeps its +// key until the turn's boundary, so the provider's trailing deltas drop rather than start a +// message outside the stopped turn. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalProducerLinkage, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../agent-session-journal/journal-payload-bounds' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../agent-session-wire/agent-session-delta-coalescer' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineEntryBytes, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import type { ProviderTimelineResolvedWrite } from './provider-timeline-decision' +import type { + ProviderTimelineJoin, + ProviderTimelineTextChannel, + ProviderTimelineTextItem +} from './provider-timeline-event' +import { providerTimelineKeyPart } from './provider-timeline-identity' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { + providerKey, + providerTimelineTurnRowState, + turnOf, + type ProviderTimelineRowId +} from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** One open text stream. */ +export type ProviderTimelineStream = { + /** The coalescer's key, unique per stream. */ + id: string + /** The stream's slot: a named stream's is its item's row id, which the budget shares. */ + key: string + row: ProviderTimelineRowId + /** The turn its row joins when it is new; an existing row keeps its own. */ + scope: AgentJournalTurnScope + named: boolean + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + /** Whether any text was written; a whitespace-only stream completes nothing. */ + written: boolean + bytes: number + /** The settled turn a write found its row in: it writes nothing more. */ + stoppedIn: string | null +} + +/** Stopped streams remembered until their turn's boundary; past this the oldest is forgotten. */ +const MAX_STOPPED_STREAMS = 128 + +export class ProviderTimelineTextStreams { + private readonly streams = new Map() + private readonly byId = new Map() + /** Keys of streams stopped by their turn's settlement, to that turn's row id. */ + private readonly stopped = new Map() + private readonly coalescer + + constructor( + private readonly deps: { + sink: ProviderTimelineSink + context: ProviderTimelineContext + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] + } + ) { + this.coalescer = createAgentSessionDeltaCoalescer({ + ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + // Every stream here is one this assembler holds open; the shared budget counts them. + isProtected: () => true, + emit: (id, text) => this.flushOnWindow(id, text) + }) + } + + /** What the budget counts: one entry per open stream. */ + get open(): readonly ProviderTimelineStream[] { + return [...this.streams.values()].filter((stream) => stream.stoppedIn === null) + } + + /** The stream slot `item` names on its thread. */ + key(item: ProviderTimelineTextItem, join: ProviderTimelineJoin | undefined): string { + const thread = join?.thread ?? null + return 'id' in item + ? this.deps.context.rows.item('item', providerKey(item.id), thread).itemId + : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + } + + /** The open stream in `key`; a stopped one is let go and its key kept as stopped. */ + get(key: string): ProviderTimelineStream | undefined { + const stream = this.streams.get(key) + if (stream?.stoppedIn) { + this.stop(stream, stream.stoppedIn) + return undefined + } + return stream + } + + /** Whether text for `key` would continue a stream its turn's settlement stopped: it names no + * turn, or names that one. */ + stoppedFor(key: string, join: ProviderTimelineJoin | undefined): boolean { + const turn = this.stopped.get(key) + return ( + turn !== undefined && + (join?.turn === undefined || + this.deps.context.rows.turn(providerKey(join.turn)).itemId === turn) + ) + } + + /** A new stream, not yet open; `serial` names it (and an anonymous one's row). */ + start(input: { + item: ProviderTimelineTextItem + join: ProviderTimelineJoin | undefined + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + state: ProviderTimelineState + serial: number + }): ProviderTimelineStream { + const { rows } = this.deps.context + const thread = input.join?.thread ?? null + const named = 'id' in input.item + const key = 'id' in input.item ? providerKey(input.item.id) : rows.minted('s', input.serial) + return { + id: `s${input.serial}`, + key: this.key(input.item, input.join), + row: rows.item('item', key, thread), + scope: providerTimelinePlacement(this.deps.context, input.state, input.join), + named, + channel: input.channel, + producer: input.producer, + written: false, + bytes: providerTimelineEntryBytes({ + key: 'id' in input.item ? input.item.id : input.item.stream, + join: input.join, + producer: input.producer + }), + stoppedIn: null + } + } + + /** Whether a delta on `stream` continues it or begins another message. */ + continues( + stream: ProviderTimelineStream, + channel: ProviderTimelineTextChannel, + producer: AgentJournalProducerLinkage | undefined + ): boolean { + return stream.channel === channel && stream.producer?.agentId === producer?.agentId + } + + /** The delta's text; the stream opens when the event is admitted. */ + planAppend(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, text: string): void { + plan.onAdmitted(() => { + if (!this.byId.has(stream.id)) { + this.streams.set(stream.key, stream) + this.byId.set(stream.id, stream) + } + this.coalescer.append(stream.id, text) + }) + } + + /** Every stream's unwritten text, ahead of whatever the event writes. */ + planFlush(plan: ProviderTimelinePlan, except?: ProviderTimelineStream): void { + for (const { key, snapshot } of this.coalescer.dirty()) { + const stream = this.byId.get(key) + if (stream && stream !== except) { + this.planText(plan, stream, snapshot.text) + } + } + } + + /** Stops every stream of a turn another writer settled once the event is admitted: their text + * can never land. */ + planStop(plan: ProviderTimelinePlan, turnItemId: string): void { + const stopped = [...this.streams.values()].filter( + (stream) => turnOf(stream.scope) === turnItemId + ) + if (stopped.length > 0) { + plan.onAdmitted(() => stopped.forEach((stream) => this.stop(stream, turnItemId))) + } + } + + /** A turn's boundary: what its settlement stopped no longer holds the key; null is every turn. */ + planBoundary(plan: ProviderTimelinePlan, turnItemId: string | null): void { + plan.onAdmitted(() => { + for (const [key, turn] of this.stopped) { + if (turnItemId === null || turn === turnItemId) { + this.stopped.delete(key) + } + } + }) + } + + /** Before the budget refuses: a stream whose row's turn the journal shows settled holds nothing, + * since none of its text can land. */ + stopSettled(journal: StructuredAgentSessionTransitionJournal): void { + for (const stream of this.open) { + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + this.stop(stream, turn) + } + } + } + + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ + planRelease( + plan: ProviderTimelinePlan, + which: (stream: ProviderTimelineStream) => boolean + ): void { + const released = [...this.streams.values()].filter(which) + if (released.length > 0) { + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) + } + } + + /** Ends one stream with the provider's final text, else what streamed. */ + planClose(plan: ProviderTimelinePlan, stream: ProviderTimelineStream, finalText?: string): void { + if (finalText === undefined) { + const owed = this.coalescer.dirty().find(({ key }) => key === stream.id) + if (owed) { + this.planText(plan, stream, owed.snapshot.text) + } + } else { + const text = boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + this.planText(plan, stream, text, true) + } + this.planRelease(plan, (each) => each === stream) + } + + flush(): void { + this.coalescer.flushAll() + } + + /** Drops the window's unwritten text: apply `session.ended` first, which writes it. */ + dispose(): void { + Array.from(this.byId.values()).forEach((stream) => this.forget(stream)) + this.stopped.clear() + this.coalescer.dispose() + } + + /** The window's flush: false keeps the text for a retry, but only while the sink is merely full. */ + private flushOnWindow(id: string, text: string): boolean { + const stream = this.byId.get(id) + if (!stream) { + return true + } + const plan = new ProviderTimelinePlan() + this.planText(plan, stream, text) + const admission = plan.submit(this.deps.sink) + // A failed or closed sink can never take it; the session's end owns what it leaves. + return admission.accepted || admission.reason !== 'backpressure' + } + + private planText( + plan: ProviderTimelinePlan, + stream: ProviderTimelineStream, + text: string, + providerFinal = false + ): void { + const empty = providerFinal ? text.length === 0 : text.trim().length === 0 + if (!stream.written && empty) { + plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) + return + } + const body = this.message(stream, text) + plan.item({ + reservedBytes: estimateStructuredAgentSessionItemBytes(stream.row.identity, body), + resolve: (journal) => this.resolveText(stream, body, journal), + options: { ...stream.producer, turnScope: stream.scope } + }) + plan.onAdmitted(() => { + stream.written = true + this.coalescer.markFlushed(stream.id) + }) + } + + /** Every write, not only the first: whoever ended the row's turn since, this run or another + * writer of the journal, the stream never writes into it again. */ + private resolveText( + stream: ProviderTimelineStream, + body: AgentJournalItemBody, + journal: StructuredAgentSessionTransitionJournal + ): ProviderTimelineResolvedWrite | null { + if (stream.stoppedIn !== null) { + return null + } + const turn = this.rowTurn(stream, journal) + if (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') { + stream.stoppedIn = turn + return null + } + return { identity: stream.row.identity, body } + } + + /** The turn the stream's row is in: the journal's for a written row, else where it would go. */ + private rowTurn( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): string | null { + const held = journal.item(stream.row.itemId) + return turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + } + + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { + return { + kind: 'message', + role: stream.channel === 'assistant' ? 'assistant' : 'reasoning', + blocks: [{ type: 'text', text }] + } + } + + private stop(stream: ProviderTimelineStream, turnItemId: string): void { + stream.stoppedIn = turnItemId + this.forget(stream) + this.stopped.delete(stream.key) + this.stopped.set(stream.key, turnItemId) + for (const [oldest] of [...this.stopped].slice( + 0, + Math.max(0, this.stopped.size - MAX_STOPPED_STREAMS) + )) { + this.stopped.delete(oldest) + } + } + + private forget(stream: ProviderTimelineStream): void { + this.coalescer.forget(stream.id) + if (this.streams.get(stream.key) === stream) { + this.streams.delete(stream.key) + } + this.byId.delete(stream.id) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts new file mode 100644 index 00000000000..f2c221ed8e8 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -0,0 +1,95 @@ +// The writes one non-text event's transition carries, in order: the text owed ahead of it, its +// settlement, then its rows. + +import { + providerTimelineSettlementId, + type ProviderTimelineContext +} from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision +} from './provider-timeline-decision' +import type { ProviderTimelinePlan } from './provider-timeline-plan' +import { turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' + +/** Paces the queue only: a settlement's mutations are the journal's to choose. */ +const SETTLEMENT_RESERVED_BYTES = 64 * 1024 + +/** Text owed ahead of the event lands first; a row-writing event also ends the messages it + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. + * An earlier turn's end, while another turn is open, ends only that earlier turn's streams. A turn + * another writer settled stops its streams until that turn's boundary: the provider's end of it, + * or the next turn's open. */ +export function planProviderTimelineBarrier( + input: { + streams: ProviderTimelineTextStreams + state: ProviderTimelineState + }, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decision: ProviderTimelineDecision +): void { + const { streams, state } = input + // A full snapshot of a streamed message replaces what streamed, so that text is not flushed. + const replaced = decision.closes ? streams.get(decision.closes) : undefined + streams.planFlush(plan, replaced) + if (replaced) { + streams.planRelease(plan, (stream) => stream === replaced) + } + if (event.type === 'session.ended') { + streams.planRelease(plan, () => true) + return + } + if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { + const ending = + decision.ends?.turnItemId ?? (event.type === 'turn.open' ? state.open?.itemId : undefined) + const current = event.type === 'turn.open' || decision.ends?.current === true + if (event.type === 'turn.settled') { + streams.planStop(plan, event.turn.itemId) + } else if (event.type === 'turn.open') { + streams.planBoundary(plan, null) + } else if (ending !== undefined) { + streams.planBoundary(plan, ending) + } + streams.planRelease( + plan, + (stream) => + (current && !stream.named && stream.producer?.agentId === undefined) || + (ending !== undefined && turnOf(stream.scope) === ending) + ) + return + } + if (event.type !== 'input.accepted') { + const agentId = 'producer' in event ? event.producer?.agentId : undefined + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) + } +} + +/** The event's own settlement and rows, after the text it flushed. */ +export function planProviderTimelineWrites( + context: ProviderTimelineContext, + plan: ProviderTimelinePlan, + decision: ProviderTimelineDecision, + serial: () => number +): void { + const { settle } = decision + if (settle) { + plan.settlement({ + settlementId: providerTimelineSettlementId(context, serial(), settle.what), + reservedBytes: SETTLEMENT_RESERVED_BYTES, + resolve: settle.resolve + }) + } + for (const write of decision.writes ?? []) { + plan.item( + { + reservedBytes: write.reservedBytes, + resolve: write.resolve, + options: write.options + }, + write.lifecycle + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts new file mode 100644 index 00000000000..7b1a5e4756e --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -0,0 +1,297 @@ +// What turn, user-message, context and session events do: admitted on the state, written +// against the journal. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + agentJournalTurnRowReservedBytes, + resolveAgentJournalTurnRowWrite +} from './agent-journal-turn-row-revision' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput, + ProviderTimelineItemWrite, + ProviderTimelineResolvedWrite +} from './provider-timeline-decision' +import { + providerKey, + providerTimelineTurnRowState, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' +import { + providerTimelineSettlement, + runningProviderTimelineTurns, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import type { + ProviderTimelineOpenTurn, + ProviderTimelinePendingInput +} from './provider-timeline-state' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 + +export function decideTurnOpen( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const open = state.open + // A newer turn ends this one, whoever asked for it; or the stopped one the provider never ended. + const superseded = open ?? state.stopped + // An open naming no turn while one is open is the same turn, not a new one. + if (event.turn === undefined && open) { + return { dropped: 'turn-duplicate' } + } + const turn = context.rows.turn( + event.turn === undefined ? context.rows.minted('t', input.serial()) : providerKey(event.turn) + ) + const held = journal ? providerTimelineTurnRowState(journal, turn.itemId) : 'absent' + if (open?.itemId === turn.itemId || held === 'running') { + return { dropped: 'turn-duplicate' } + } + if (held === 'settled') { + return { dropped: 'turn-settled' } + } + const pending = state.opener(turn.itemId) + const running: AgentJournalTurnLifecycle = { + turnId: turn.turnId, + state: 'running', + userItemId: pending?.userItemId ?? turn.itemId, + startedAt: event.at, + ...(pending ? { requestedAt: pending.requestedAt } : {}) + } + const write: ProviderTimelineResolvedWrite = { + identity: turn.identity, + body: agentJournalTurnBody(running) + } + return { + ...(superseded + ? { + settle: { + what: 'turn-superseded', + resolve: (journal) => + providerTimelineSettlement( + journal, + { turnItemId: superseded.itemId }, + { + turns: [superseded], + end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } + } + ) + } + } + : {}), + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + // The running row's ts is the turn start itself, so clients read no append lag. + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true, observedAt: event.at }, + // Only where no row is: a turn the journal already holds is never written back to running. + resolve: (at) => (at.itemBody(turn.itemId) === null ? write : null) + } + ], + commit: (next) => { + if (superseded) { + next.endTurn(superseded) + } + if (pending) { + next.inputs = next.inputs.filter((each) => each !== pending) + } + next.open = { ...turn, running } + } + } +} + +export function decideTurnEnd( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + // Unnamed: the open turn, else the one a person stopped, which the provider is now ending. + const turn = + event.turn === undefined + ? (state.open ?? state.stopped) + : input.context.rows.turn(providerKey(event.turn)) + if (!turn) { + return { dropped: 'no-turn' } + } + // A turn this run opened, or one the journal holds (open, superseded, or ended by another + // writer): its end settles whatever is left, and a settled row is not written again. + const known = + state.open?.itemId === turn.itemId || + state.latest?.itemId === turn.itemId || + !journal || + providerTimelineTurnRowState(journal, turn.itemId) !== 'absent' + if (!known) { + return { dropped: 'turn-unknown' } + } + const end: ProviderTimelineTurnEnd = { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + return { + ends: { turnItemId: turn.itemId, current: !state.open || state.open.itemId === turn.itemId }, + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, { turns: [turn], end }) + }, + commit: (next) => next.endTurn(turn) + } +} + +/** The open turn another writer settled (a person's Stop): its text stops and its prompts are + * cancelled; its row stays as that writer left it, and its running tool calls stay the + * provider's until the provider ends the turn (or a newer turn, or the session's end, does). */ +export function decideTurnSettled( + event: Extract +): ProviderTimelineDecision { + const { turn } = event + return { + ends: { turnItemId: turn.itemId, current: true }, + settle: { + what: 'turn-settled', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId, promptsOnly: true }) + }, + commit: (next) => next.stopTurn(turn) + } +} + +export function decideInput( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + return decideOpener( + input, + { + userItemId: agentJournalSubmissionKey(event.clientMessageId), + requestedAt: event.requestedAt + }, + event.join?.turn + ) +} + +/** A user message names the turn it opened: the one it names once that one opens, else the open + * turn while that still names no message of its own, else the next to open. */ +function decideOpener( + input: ProviderTimelineDecisionInput, + message: Omit, + named: string | undefined +): ProviderTimelineDecision { + const { state, journal, context } = input + const open = state.open + const turn = named === undefined ? null : context.rows.turn(providerKey(named)) + if (turn && turn.itemId !== open?.itemId) { + // A late echo of a turn already over names nothing. + if (journal && providerTimelineTurnRowState(journal, turn.itemId) !== 'absent') { + return {} + } + return { commit: (next) => next.wait({ ...message, turnItemId: turn.itemId }) } + } + if (!open) { + return { commit: (next) => next.wait(message) } + } + const opener = + (journal && readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined)?.userItemId) ?? + open.running.userItemId + if (opener !== open.itemId) { + return {} + } + const running = { ...open.running, ...message } + return { + writes: [ + { + reservedBytes: TURN_ROW_RESERVED_BYTES, + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true }, + resolve: (at) => reviseOpener(at, open, message) + } + ], + commit: (next) => { + if (next.open?.itemId === open.itemId) { + next.open = { ...next.open, running } + } + } + } +} + +/** Only while the row runs and still names the turn itself as its opener: an opener another writer + * gave it stands. Every other field is the row's as the journal holds it. */ +function reviseOpener( + journal: StructuredAgentSessionTransitionJournal, + open: ProviderTimelineOpenTurn, + message: Omit +): ProviderTimelineResolvedWrite | null { + const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) + if (row?.state !== 'running' || row.userItemId !== open.itemId) { + return null + } + const target = { identity: open.identity } + const write = { + lifecycle: agentJournalTurnBody({ ...row, ...message }), + // Defence only: the check above reads the same snapshot; the revision refuses an ended row too. + onlyWhileRunning: true as const + } + return resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) +} + +export function decideSessionEnd( + _input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + return { + settle: { + what: 'session-end', + resolve: (journal) => + providerTimelineSettlement(journal, 'session', { + turns: runningProviderTimelineTurns(journal), + end: event.verdict + }) + }, + commit: (next) => next.endSession() + } +} + +export function decideContextUsage( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state } = input + const named = event.join?.turn + const turn: ProviderTimelineTurnRef | null = + named === undefined ? (state.open ?? state.latest) : input.context.rows.turn(providerKey(named)) + const target = turn ? { identity: turn.identity } : ({ newest: true } as const) + const write = { contextUsage: event.usage } + const usage: ProviderTimelineItemWrite = { + reservedBytes: agentJournalTurnRowReservedBytes(target, write), + lifecycle: true, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE }, + resolve: (journal) => + resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + } + return { writes: [usage] } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 639b34cb795..6286fe457fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -8,13 +8,12 @@ import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, @@ -176,7 +175,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -234,7 +233,7 @@ export async function settleStaleStructuredAgentSessionState(input: { const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - const body = terminalDeadGenerationBody(item) + const body = terminalAgentJournalBody(item.body) if (identity && body) { mutations.push({ kind: 'item', @@ -288,20 +287,10 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { - if (item.body.kind === 'tool-call' && item.body.state === 'running') { - return { ...item.body, state: 'failed' } - } - if (item.body.kind === 'approval' || item.body.kind === 'question') { - return item.body.resolution.state === 'pending' ? cancelledJournalPromptBody(item.body) : null - } - return null -} - function isUnfinishedItem(item: AgentJournalRenderItem): boolean { return ( readAgentJournalTurn(item.body)?.state === 'running' || - terminalDeadGenerationBody(item) !== null + terminalAgentJournalBody(item.body) !== null ) }