From d7a95782d1d2f36612a575d36fb46887f5576cea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:21:22 -0700 Subject: [PATCH] Add a shared timeline assembler for structured agent chats (not wired yet) (#25064) * Move the turn message ordinals and the turn-row revision to the neutral timeline folder Pure moves so a shared timeline assembler can use them: Codex's message ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row revision becomes the provider-neutral agent-journal turn-row revision. Only names and import paths change. * Admit one provider event's writes as one transition, and let rows be found again after a restart - A sink transition is admitted whole or not at all; its steps run back to back at their turn in the journal's write queue, and each resolver reads the fold with every earlier write landed. A resolver may also say where the row belongs (turn scope, provider reference), and the writer always hears how the transition landed. A resolved lifecycle batch chooses its settlement mutations from the fold at execution. - New optional row field providerItemRef: the provider's own reference for the item a row is, written only where the row's identity cannot spell it (Codex keys messages by their place in the turn and renumbers its item ids on resume). Set by the creating write, kept by revisions, indexed by the journal fold, never read by clients. A downgrade test shows an older host and client render such rows unchanged. - Provider timeline identity schemes (shared legacy arm, Codex) and the join index that resolves a provider item to its row from memory or the fold: ordinals and request incarnations are read back from the rows, so a restart or an evicted entry finds the original row instead of placing a new one. * Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch A fresh join index continued a turn's messages at the first free place, so a journal holding only a later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader. The join caches also drop what they read when the journal's epoch is replaced. * Spell the subagent thread's message slot without spreading an identity union * Add a provider timeline grammar and a shared assembler that decides at its turn in the journal Adapters translate their provider's dialect into a small grammar (turns, items, streamed text, requests, context facts, session end/reset); one shared assembler turns it into the journal rows every structured lane writes. Each event is planned as one sink transition. Which row a write lands on, whether a replay writes anything, and every change to what the assembler knows (its ledger) are decided by the transition's resolvers at the event's turn in the journal's write queue, against the fold as it stands then. A forecast (the ledger plus admitted events still queued) only answers apply() at once. So a refused event allocates nothing, a write the journal rejects leaves no trace in memory, and a restart or evicted cache finds the same rows again. Text and full snapshots of one provider item share one row and one lifecycle; reset always flushes text and settles the old session from the journal; the open-work budget is derived from what is actually open. Codex migration contracts compare against the existing Codex translator, including a restart mid-stream and a repeat that outlives the join cache. * Fix the types and the exhaustive event switch CI reported for the assembler * Let the journal decide stream lifetimes, request reuse, named sends and background work A third review found two blockers with the earlier rounds' cause, a remembered interpretation trusted after the journal moved on: - A reused request id was judged by its earlier prompt's settled turn before asking which turn the new one lands in, so a real approval in a later turn was dropped. The target turn now decides: the old turn again is a replay; a different live turn opens the next prompt beside it. - A text stream checked its row's turn only on its first write, and a turn's end released streams by the turn planning expected. Every write now checks the row, a turn's end stops the streams whose rows are in it, and turn status reads the journal first, so another writer's Stop wins. Also: a message boundary drawn by an event the journal held as a replay no longer splits an anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the journal shows it settled; a turn's opener is read from the journal's row. Background work is now Orca's existing background-task row instead of a tool call flagged `outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end never settles that row, so it survives restarts; session end leaves one in flight unverifiable. Three tests that opened a background tool call with `outlivesTurn` now open a background-task row and keep their original expectations about which turn the row stays in. * Type the unbound assembler helper's drain as the void it reports * Bound the rows kept for a continued anonymous message and the stopped streams A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's queued writes write nothing, lived in the live-stream map and were never removed when no stream followed. They now live in their own bounded maps, so the live map holds open streams only. * Keep the journal store under its line limit after the main merge * Drop the provider item reference, join index and identity schemes from the transition PR Nothing in production reaches the state they defended (an assembler that lost its memory while its child keeps streaming the same turn), and the stored Codex id was positional. The legacy identity scheme moves to the assembler PR with its first caller; the Codex scheme and any persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes back to codex/, since no neutral code imports it. * Write a resolved settlement in one transaction through enqueueRows A settlement too large for one row now commits all its rows or none, through the journal's existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any commits, so a settlement naming one item twice is refused before anything is written. * Drop the transition's landing report; keep the turn-row write fire-and-forget Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the steps before it written; the header says so, and tests cover it plus a settlement whose second row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main. * Run a transition's steps as a prefix; drop the paced flag and resolved options A failed step no longer lets the steps after it write: each step checks, at its own turn in the journal's queue, whether the write handed over just ahead of it completed, using the queue's count of completed write bodies (a promise would report the failure only after the next step ran). The sink fails only once every step has had its turn. The item step's `paced` size bypass and the resolver's replacement `options` are removed; nothing planned uses them. * Rebuild the timeline assembler on one admission-order state The assembler kept a second copy of its state (a forecast beside a ledger), hydrated the open turn from the journal, and recognised replays, all to recover from losing its memory while the provider child kept streaming. That never happens: one assembler lives exactly as long as one child, and a new child is a new assembler in a new generation whose events land after the dead-generation sweep. - One state, changed only when the sink admits an event (minted keys included), so a refused event takes nothing. - Every journal-dependent choice is made when the write runs, by keyed reads: a turn row is written only where none is, a stream checks its row's turn on every write, a running snapshot never lands in a settled turn or relights a settled tool, a request takes the first incarnation the journal holds no row for. - Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache. - The identity scheme (legacy arm only) lives here with its first caller; requests are spelled in their acquisition generation, since JSON-RPC ids restart per process. - Saved history goes in as `input.history` plus ordinary events with the provider's ids, into an empty journal; `session.reset` and every replay rule are gone. - One terminal-body function (`terminalAgentJournalBody`) is shared with the dead-generation settlement. - The test rig's restart now sweeps and starts a new generation, as production does. * Cover new running work in a turn the sweep ended * Run a transition's steps in one queued write that loops over them The steps of one event now share one turn in the journal's write queue: a loop writes each in its own transaction through the row writer's synchronous writeRows (split out of enqueueRows) and stops at the first throw. Prefix semantics and "nothing lands between the steps" now hold by construction, so the completed-write counter on the queue, the step gate and the allSettled barrier are gone; the queue is back to main's bytes. * End a turn another writer settled the way the provider's end does A person's Stop settled the open turn's row without a word to the assembler. The assembler then forgot the turn: its running tools and pending prompts were never settled, the provider's own end and withdrawal were dropped, and the turn's text streams stayed counted against the open budget for the life of the process. Text the provider kept streaming afterwards could land as a message outside the stopped turn. - The open turn the journal shows settled ends first, as one transition, through the same settlement the provider's turn.end plans; its streams stop and their keys drop later text until that turn's end or the next turn opens. - turn.end and request.withdrawn are admitted for a turn or request the journal holds; their settlement writes nothing for rows already settled. - The budget's re-check frees streams whose turn settled. - A settled tool keeps its terminal body against any differing write. - Session-end settlement of lost background work uses the journal's own lostLiveWorkJournalBody instead of a copy. - The rig's window elapses before every read, and restart swaps and disposes the old assembler. * Leave a stopped turn's running tools to the agent's own end When another writer settles the open turn (a person's Stop), the assembler now only stops that turn's text and cancels its pending prompts. Running tool calls stay the agent's: a progress update or completion it reports after the Stop lands as reported, and whatever is still running settles at the agent's turn end for that turn, the next turn's open, or the session's end. An agent's end for an earlier turn while a newer one is open no longer clears the open turn's activity line or ends its anonymous reply. An unnamed end right after a Stop ends the stopped turn instead of being dropped. The test rig's restart no longer writes the dead assembler's window text, matching dispose. * Pin that a stopped turn's running tools hold budget until the agent's end * Type the stopped turn's tool progress update as a tool body * List every event the assembler hands to the decision step The type-aware lint requires an exhaustive switch with no default case. Also retitle a Stop test to say what it asserts. * refactor(native-chat): drop saved-history adoption from the timeline assembler The common pattern discards the history a provider replays while loading a session, so the assembler has no use for an input.history event. * refactor(native-chat): a pending input is only Orca's send now Review follow-up to the adoption removal: drop the comment naming the provider's saved message, and make requestedAt required since every pending input comes from input.accepted. * Use current provider handles in transition tests * Use current provider handles in timeline fixtures --- .../journal-subagent-liveness.ts | 15 +- .../journal-terminal-settlement.ts | 13 + ...der-timeline-assembler-attribution.test.ts | 223 +++++++++++ ...imeline-assembler-earlier-turn-end.test.ts | 62 +++ ...vider-timeline-assembler-held-rows.test.ts | 160 ++++++++ .../provider-timeline-assembler-items.test.ts | 299 +++++++++++++++ .../provider-timeline-assembler-order.test.ts | 247 ++++++++++++ ...rovider-timeline-assembler-refusal.test.ts | 195 ++++++++++ ...rovider-timeline-assembler-restart.test.ts | 68 ++++ .../provider-timeline-assembler-rows.test.ts | 207 ++++++++++ ...rovider-timeline-assembler-session.test.ts | 193 ++++++++++ .../provider-timeline-assembler-stop.test.ts | 342 +++++++++++++++++ ...rovider-timeline-assembler-streams.test.ts | 100 +++++ ...rovider-timeline-assembler-test-support.ts | 314 ++++++++++++++++ .../provider-timeline-assembler-turns.test.ts | 189 ++++++++++ .../provider-timeline-assembler.ts | 201 ++++++++++ .../provider-timeline-background-tasks.ts | 33 ++ .../provider-timeline-budget.ts | 69 ++++ .../provider-timeline-context.ts | 64 ++++ .../provider-timeline-decision.ts | 128 +++++++ .../provider-timeline-event.ts | 129 +++++++ .../provider-timeline-identity.test.ts | 54 +++ .../provider-timeline-identity.ts | 106 ++++++ .../provider-timeline-item-decisions.ts | 242 ++++++++++++ .../provider-timeline-plan.ts | 82 ++++ .../provider-timeline-rows.ts | 119 ++++++ .../provider-timeline-settlement.ts | 144 +++++++ .../provider-timeline-state.ts | 166 +++++++++ .../provider-timeline-text-events.ts | 116 ++++++ .../provider-timeline-text-streams.ts | 352 ++++++++++++++++++ .../provider-timeline-transition-layout.ts | 95 +++++ .../provider-timeline-turn-decisions.ts | 297 +++++++++++++++ ...gent-session-dead-generation-settlement.ts | 19 +- 33 files changed, 5025 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-rows.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-context.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-event.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-state.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts 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 ) }