From 23b927e859e93523ca523c654eefcd63f0daceee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:49:30 -0700 Subject: [PATCH 01/26] 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. --- config/scripts/ci-shard-timings.json | 2 +- src/main/claude/claude-context-facts.ts | 11 +- src/main/claude/claude-open-turn.ts | 4 +- .../codex-structured-item-translation.test.ts | 28 ++--- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../agent-journal-turn-row-revision.ts} | 106 +++++++++++------- .../provider-turn-message-ordinals.test.ts} | 25 +++-- .../provider-turn-message-ordinals.ts} | 19 +--- 14 files changed, 130 insertions(+), 102 deletions(-) rename src/main/{claude/claude-turn-row-revision.ts => native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts} (67%) rename src/main/{codex/codex-turn-ordinals.test.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts} (64%) rename src/main/{codex/codex-turn-ordinals.ts => native-chat/agent-session-timeline/provider-turn-message-ordinals.ts} (88%) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b8..fb242a4788c8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/codex/codex-turn-ordinals.test.ts": 96, + "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/claude/claude-context-facts.ts b/src/main/claude/claude-context-facts.ts index 358fd134c87a..3a1326e09363 100644 --- a/src/main/claude/claude-context-facts.ts +++ b/src/main/claude/claude-context-facts.ts @@ -25,7 +25,10 @@ import type { ClaudeOpenTurn } from './claude-open-turn' import { claudeRecord, claudeText } from './claude-structured-item-translation' import type { ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { isRootClaudeFrame } from './claude-turn-opening' -import { writeClaudeTurnRow, type ClaudeTurnRowTarget } from './claude-turn-row-revision' +import { + writeAgentJournalTurnRow, + type AgentJournalTurnRowTarget +} from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' const CONVERSATION_FRAME_TYPES = new Set(['assistant', 'user', 'stream_event']) @@ -188,7 +191,7 @@ export class ClaudeContextFacts { this.windowHint = report.windowTokens const contextUsage: AgentSessionContextUsage = part === 'report' ? { used: { kind: 'report', ...report }, window } : { window } - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target === null ? { newest: true } : { identity: target }, { contextUsage }, @@ -211,9 +214,9 @@ export class ClaudeContextFacts { windowIfNoneHeld?: AgentSessionContextWindow ): void { const identity = this.turn.identity - const target: ClaudeTurnRowTarget = identity ? { identity } : { newest: true } + const target: AgentJournalTurnRowTarget = identity ? { identity } : { newest: true } // A context fact often lands with no later frame to publish it, so it publishes itself. - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.sink, target, { contextUsage, ...(windowIfNoneHeld ? { windowIfNoneHeld } : {}) }, diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index ddc1bd553907..4e7fe7ff9530 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -18,7 +18,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' -import { writeClaudeTurnRow } from './claude-turn-row-revision' +import { writeAgentJournalTurnRow } from '../native-chat/agent-session-timeline/agent-journal-turn-row-revision' import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' @@ -203,7 +203,7 @@ export class ClaudeOpenTurn { contextUsage?: AgentSessionContextUsage ): void { const item = claudeTurnLifecycleItem(turn, end) - writeClaudeTurnRow( + writeAgentJournalTurnRow( this.deps.sink, { identity: item.identity }, { lifecycle: item.body, ...(contextUsage ? { contextUsage } : {}) }, diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index ad63d624f58b..29562552a02c 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES, + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new CodexTurnOrdinals() - const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new ProviderTurnMessageOrdinals() + const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 25b821d90d9f..3f4229cb6e54 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index ec6ac67df5d4..22bb16c8ef08 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - CodexTurnOrdinals, + ProviderTurnMessageOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new CodexTurnOrdinals() + readonly ordinals = new ProviderTurnMessageOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 131de9824aae..e7d0a376eca8 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type CodexTurnOrdinals + type ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 9f7efc15ae4c..a3b4652f366e 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,5 +1,8 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' +import { + readCodexThreadItem, + type ProviderTurnMessageOrdinals +} from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -55,7 +58,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 54a2c017aa05..7bb88edc3a6f 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { CodexTurnOrdinals } from './codex-structured-item-translation' +import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 33f138c6578d..dc955f70385c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 0488e5c00e98..663b497f7731 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { CodexTurnOrdinals } from './codex-turn-ordinals' +import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' // Codex thread items → durable journal identities. // @@ -15,6 +15,10 @@ import type { CodexTurnOrdinals } from './codex-turn-ordinals' // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -50,7 +54,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: CodexTurnOrdinals + ordinals: ProviderTurnMessageOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts similarity index 67% rename from src/main/claude/claude-turn-row-revision.ts rename to src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 1b684ba89aaa..69356c406784 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -1,4 +1,4 @@ -// Every write to a Claude turn row is a revision of the row as the journal holds +// Every write to a turn row is a revision of the row as the journal holds // it at execution: a writer overrides only the fields it owns and keeps the // rest, whoever wrote them. Nothing about ended turns is kept in memory, so a // restart or reattach revises the same rows a live translator would. @@ -9,34 +9,37 @@ import { MAX_CONTEXT_MODEL_ID_CHARS, type AgentSessionContextUsage, type AgentSessionContextWindow -} from '../../shared/agent-session-context-usage' +} from '../../../shared/agent-session-context-usage' import { agentJournalItemKey, parseAgentJournalItemKey -} from '../../shared/agent-session-journal-item-key' +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, type AgentJournalTurnItem -} from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions -} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + StructuredAgentSessionRevisionOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ -export type ClaudeTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } +export type AgentJournalTurnRowTarget = { identity: AgentJournalItemIdentity } | { newest: true } -export type ClaudeTurnRowWrite = { +export type AgentJournalTurnRowWrite = { /** The lifecycle fields this turn has now; the lifecycle writer owns all of them. */ lifecycle?: AgentJournalTurnItem /** Context parts, each replacing its namesake on the row. */ contextUsage?: AgentSessionContextUsage /** A window written only while no row in the journal holds one. */ windowIfNoneHeld?: AgentSessionContextWindow + /** Lands only while the row is absent or still running, so an ended turn is never rewritten. */ + onlyWhileRunning?: true } function jsonBytes(value: unknown): number { @@ -84,7 +87,7 @@ const TURN_ROW_BYTES_WITHOUT_CONTEXT = 8 * 1024 /** `lifecycle` over the row's current body, keeping every field it does not own. */ function reviseTurnBody( current: AgentJournalTurnItem | null, - write: ClaudeTurnRowWrite + write: AgentJournalTurnRowWrite ): AgentJournalTurnItem | null { const { lifecycle, contextUsage } = write const base = @@ -113,8 +116,8 @@ function withoutLifecycle(turn: AgentJournalTurnItem) { /** The write with its fallback window resolved against every row, since any of them may hold the newest. */ function withFallbackWindow( journal: StructuredAgentSessionRevisionJournal, - { windowIfNoneHeld, ...write }: ClaudeTurnRowWrite -): ClaudeTurnRowWrite { + { windowIfNoneHeld, ...write }: AgentJournalTurnRowWrite +): AgentJournalTurnRowWrite { if (!windowIfNoneHeld || write.contextUsage?.window) { return write } @@ -129,7 +132,7 @@ function withFallbackWindow( function findTurnRow( journal: StructuredAgentSessionRevisionJournal, - target: ClaudeTurnRowTarget + target: AgentJournalTurnRowTarget ): { itemId: string; body: AgentJournalTurnItem } | null { if ('identity' in target) { const itemId = agentJournalItemKey(target.identity) @@ -147,23 +150,60 @@ function findTurnRow( } /** How a turn-row write reaches live subscribers. */ -export type ClaudeTurnRowDelivery = { +export type AgentJournalTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean options?: Omit } +/** Bytes a turn-row write may resolve to. */ +export function agentJournalTurnRowReservedBytes( + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite +): number { + const known = 'identity' in target ? target.identity : null + return ( + (known && write.lifecycle + ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) + : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) + ) +} + +/** The row a turn-row write lands as, read from the journal at execution; null writes nothing. */ +export function resolveAgentJournalTurnRowWrite( + journal: StructuredAgentSessionRevisionJournal, + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + reservedBytes: number +): { identity: AgentJournalItemIdentity; body: AgentJournalTurnItem } | null { + const known = 'identity' in target ? target.identity : null + const row = findTurnRow(journal, target) + if (write.onlyWhileRunning && row && row.body.state !== 'running') { + return null + } + const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) + const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) + if (!identity || !body) { + return null + } + if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { + return { identity, body } + } + // Only a row grown by fields this build does not know gets here; the lifecycle still lands. + return write.lifecycle ? { identity, body: write.lifecycle } : null +} + /** - * Queue one revision of a Claude turn row. A lifecycle write creates the row + * Queue one revision of a turn row. A lifecycle write creates the row * when it is absent; a context write only ever revises one that exists. * Without a journal-reading sink only the lifecycle is written, as it was built. */ -export function writeClaudeTurnRow( +export function writeAgentJournalTurnRow( sink: StructuredAgentSessionEventSink, - target: ClaudeTurnRowTarget, - write: ClaudeTurnRowWrite, - { publish, options: delivery = {} }: ClaudeTurnRowDelivery -): void { + target: AgentJournalTurnRowTarget, + write: AgentJournalTurnRowWrite, + { publish, options: delivery = {} }: AgentJournalTurnRowDelivery +): StructuredAgentSessionSinkAdmission { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -174,29 +214,13 @@ export function writeClaudeTurnRow( sink.publish() } } - return + return { accepted: true } } - const known = 'identity' in target ? target.identity : null - const reservedBytes = - (known && write.lifecycle - ? estimateStructuredAgentSessionItemBytes(known, write.lifecycle) - : TURN_ROW_BYTES_WITHOUT_CONTEXT) + contextBytesBound(write.contextUsage) - revise.call( + const reservedBytes = agentJournalTurnRowReservedBytes(target, write) + return revise.call( sink, reservedBytes, - (journal) => { - const row = findTurnRow(journal, target) - const identity = known ?? (row ? parseAgentJournalItemKey(row.itemId) : null) - const body = reviseTurnBody(row?.body ?? null, withFallbackWindow(journal, write)) - if (!identity || !body) { - return null - } - if (estimateStructuredAgentSessionItemBytes(identity, body) <= reservedBytes) { - return { identity, body } - } - // Only a row grown by fields this build does not know gets here; the lifecycle still lands. - return write.lifecycle ? { identity, body: write.lifecycle } : null - }, + (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), options ) } diff --git a/src/main/codex/codex-turn-ordinals.test.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts similarity index 64% rename from src/main/codex/codex-turn-ordinals.test.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts index 5ae12011a855..b070b449f264 100644 --- a/src/main/codex/codex-turn-ordinals.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts @@ -1,16 +1,17 @@ import { expect, it } from 'vitest' import { - CodexTurnOrdinals, - MAX_CODEX_TURN_ORDINAL_BYTES, - MAX_CODEX_TURN_ORDINAL_ENTRIES -} from './codex-turn-ordinals' + ProviderTurnMessageOrdinals, + MAX_PROVIDER_TURN_ORDINAL_BYTES, + MAX_PROVIDER_TURN_ORDINAL_ENTRIES +} from './provider-turn-message-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new CodexTurnOrdinals() - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new ProviderTurnMessageOrdinals() + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -29,30 +30,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new CodexTurnOrdinals() + const ordinals = new ProviderTurnMessageOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts similarity index 88% rename from src/main/codex/codex-turn-ordinals.ts rename to src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts index 7e21b900d965..9405ff9281ef 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts @@ -1,18 +1,11 @@ -import { - boundPayload, - digestPayload -} from '../native-chat/agent-session-journal/journal-payload-bounds' - -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 +import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 -export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 +export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class CodexTurnOrdinals { +export class ProviderTurnMessageOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -46,7 +39,7 @@ export class CodexTurnOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -67,7 +60,7 @@ export class CodexTurnOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { From 8a42c335c541df45eee0b42c7419d9d2b13680dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:04 -0700 Subject: [PATCH 02/26] 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. --- .../codex/codex-provider-timeline-identity.ts | 43 +++ .../journal-item-appender.ts | 9 +- .../journal-item-fold.ts | 10 +- .../journal-lifecycle-batch-appender.ts | 30 +- .../journal-provider-item-ref.test.ts | 75 ++++ .../agent-session-journal/journal-reducer.ts | 3 + .../journal-render-item.ts | 1 + .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-row-writer.ts | 55 +-- .../journal-store-collaborators.ts | 32 +- .../journal-store-contracts.ts | 10 +- .../agent-session-journal/journal-store.ts | 11 + .../provider-timeline-identity.ts | 133 +++++++ .../provider-timeline-joins.test.ts | 176 ++++++++++ .../provider-timeline-joins.ts | 330 ++++++++++++++++++ .../agent-session-delta-coalescer.ts | 25 ++ ...ructured-agent-session-event-sink-queue.ts | 3 + .../structured-agent-session-event-sink.ts | 9 +- ...ed-agent-session-journal-append-options.ts | 1 + ...tructured-agent-session-transition.test.ts | 265 ++++++++++++++ .../structured-agent-session-transition.ts | 156 +++++++++ src/shared/agent-session-journal-types.ts | 9 + ...n-provider-item-ref-downgrade.unit.test.ts | 127 +++++++ 24 files changed, 1486 insertions(+), 44 deletions(-) create mode 100644 src/main/codex/codex-provider-timeline-identity.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.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-joins.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts new file mode 100644 index 000000000000..029c13fe56e2 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -0,0 +1,43 @@ +// Codex's persisted identities, spelled for the shared timeline assembler. +// +// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or +// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume +// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by +// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { + codexTurnLifecycleIdentity, + codexTurnUserItemId +} from './codex-structured-journal-translation-turns' + +export function createCodexProviderTimelineIdentityScheme(input: { + sessionId: string + primaryThreadId: () => string | null +}): ProviderTimelineIdentityScheme { + const turn: ProviderTimelineIdentityScheme['turn'] = (address) => + codexTurnLifecycleIdentity(input.sessionId, address.key.value) + return { + ordinalMessages: true, + turn, + turnId: (address) => address.key.value, + turnOpener: (address) => { + const thread = input.primaryThreadId() + return thread === null + ? agentJournalItemKey(turn(address)) + : codexTurnUserItemId(thread, address.key.value) + }, + item: (address) => { + const threadId = address.thread ?? input.primaryThreadId() ?? '' + return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null + ? { + provider: 'codex', + threadId, + turnId: address.turn.value, + ordinal: address.messageOrdinal + } + : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..479f0bc04a9d 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,7 +10,12 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } +export type JournalResolvedItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + /** Replaces the call's options when only the fold could say where the row belongs. */ + options?: JournalItemAppendOptions +} export class JournalItemAppender { constructor( @@ -48,7 +53,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - options + resolved.options ?? options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index 10aee4a99c41..aa1a02e4a70a 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,6 +39,9 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { + state.providerItemRefs.set(next.providerItemRef, itemId) + } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -65,7 +68,9 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope + turnScope: existing.turnScope ?? next.turnScope, + // The creating write's, as the scope is: a revision never re-points the row at another item. + ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) }) state.tombstones.delete(itemId) } @@ -92,6 +97,9 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) + if (existing?.providerItemRef !== undefined) { + state.providerItemRefs.delete(existing.providerItemRef) + } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a87457..682f1b3c1d5f 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,8 +1,13 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' +import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' -import type { JournalLifecycleBatchInput } from './journal-store-contracts' +import type { + JournalLifecycleBatchInput, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,6 +17,7 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueEach: JournalRowWriter['enqueueEach'] } ) {} @@ -41,6 +47,28 @@ export class JournalLifecycleBatchAppender { }) } + /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive + * rows that nothing else lands between, in the order resolved. */ + appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { + return this.deps + .enqueueEach(() => + partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder( + this.deps.state, + chunk.settlementId, + chunk.mutations, + input + ) + ) + ) + .then((rows) => { + const last = rows.at(-1) + return last ? { epoch: last.epoch, sequence: last.seq } : null + }) + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts new file mode 100644 index 000000000000..d80d5a539965 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function open(root?: string) { + const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) + if (!root) { + roots.push(directory) + } + const journal = await journals.open({ + identity: { + sessionId: 'session-ref', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + return { journal, directory } +} + +const row: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'root', + turnId: 't1', + ordinal: 0 +} +const text = (value: string) => ({ + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] +}) +const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const + +describe('a row’s provider item reference', () => { + it('is the creating write’s, kept by revisions and indexed for its producer', async () => { + const { journal } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) + await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) + + expect(journal.item('codex:root:t1:0')).toMatchObject({ + providerItemRef: 'm0', + body: text('abc') + }) + expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + expect(journal.itemIdForProviderItemRef('m9')).toBeNull() + }) + + it('survives a reopen, and leaves the index with its row', async () => { + const { journal, directory } = await open() + await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) + await journals.closeAll() + + const { journal: reopened } = await open(directory) + expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') + await reopened.appendTombstone(row, { fence: 1 }) + expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..d26b52b144c4 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,6 +56,8 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map + /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ + providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -80,6 +82,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), + providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index fd58b2c25e4e..76a7abf054ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,6 +35,7 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, + ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..16bf1960e400 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,7 +49,8 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope + turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) }) } @@ -249,6 +250,7 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope + providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -268,6 +270,7 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, + ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..e8d1746ceb8d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,6 +20,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -51,6 +52,9 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope + /** On an item row: the provider's reference for the item it creates. Rides the base for the + * reason linkage does. */ + providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -249,7 +253,13 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + for (const field of [ + 'agentId', + 'parentAgentId', + 'providerParentRef', + 'producerKind', + 'providerItemRef' + ]) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459bc..17a7f551370d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -37,27 +37,40 @@ export class JournalRowWriter { build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. - this.deps.commit(row) - return row - }) + return this.deps.serialize(() => this.write(build, hook)) + } + + /** Rows chosen at this write's turn in the queue and written back to back, so no other write + * lands between them; each is built after the one before it is adopted. */ + enqueueEach( + resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => resolve().map((build) => this.write(build))) + } + + private write( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): JournalRow { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. + this.deps.commit(row) + return row } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 65ea665879b5..c00de57f6a66 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueEach: (resolve) => rowWriter.enqueueEach(resolve) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f9029..173e0c062e97 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,7 +62,10 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } + attribution: AgentJournalProducerLinkage & { + turnScope?: AgentJournalTurnScope + providerItemRef?: string + } ) => void export type JournalLifecycleBatchInput = { @@ -72,6 +75,11 @@ export type JournalLifecycleBatchInput = { recovered?: true } +export type JournalResolvedLifecycleBatchInput = Omit & { + /** Read from the fold with every earlier write landed; may return none. */ + resolve: () => readonly JournalLifecycleMutationInput[] +} + export type JournalSubmissionInput = { clientMessageId: string payloadFingerprint: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..53929d836e77 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -8,6 +8,7 @@ import type { AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalRenderItem, AgentJournalSnapshot, AgentJournalSubmission, AgentJournalThreadGoal, @@ -227,6 +228,11 @@ export class AgentSessionJournal { itemBody = (itemId: string): AgentJournalItemBody | null => this.state.items.get(itemId)?.body ?? null + /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ + item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null + /** The row that carries this `providerItemRef`, if any. */ + itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null + /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ visitItemsWithLinkage = (visit: JournalItemLinkageVisitor): void => { @@ -331,6 +337,11 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } + /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; + * null when none are. */ + appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => + this.lifecycleBatchAppender.appendResolved(input) + /** * Write-ahead submission row. It is durable before the caller dispatches * anything, and it doubles as the optimistic user bubble so an accepted echo 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 000000000000..d364b4d31acf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -0,0 +1,133 @@ +// How the assembler's addresses become persisted journal identities. +// +// The assembler decides WHICH rows are the same row: it resolves every provider join to an +// address — the provider session's namespace, the key, the thread and turn the item belongs to, +// the item's class, and its place among its turn's messages — or mints a key when the provider +// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, +// and it is the one place a provider's persisted identity shape lives, so moving a provider to a +// new identity arm is a change here. + +import type { + AgentJournalItemBody, + 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 } + +export type ProviderTimelineTurnAddress = { + /** The provider session whose ids the key belongs to. */ + namespace: string + key: ProviderTimelineKey +} + +/** Which family an item belongs to; families never share a key space. Streamed text and full + * item snapshots are one family, so a provider-named message is one row however it arrives. */ +export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' + +/** What kind of thing an item is, derived from what it carries: `message` is user or assistant + * prose; every other class is its row kind (`reasoning` text included). */ +export type ProviderTimelineItemClass = + | 'message' + | 'reasoning' + | Exclude + | 'frame' + +export type ProviderTimelineItemAddress = { + namespace: string + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + /** The provider thread it came from; null when the provider named none. */ + thread: string | null + /** The provider turn it belongs to; null for a row outside any turn. */ + turn: ProviderTimelineKey | null + itemClass: ProviderTimelineItemClass + /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and + * for every item of a scheme without `ordinalMessages`. */ + messageOrdinal: number | null + /** 1 for the first request under a key; a key reused after it settled is the next one. */ + incarnation: number +} + +export type ProviderTimelineIdentityScheme = { + /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. + * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as + * `providerItemRef`, since nothing else could find it again. */ + ordinalMessages: boolean + turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity + /** The id the turn row carries and a client's Stop names. */ + turnId(address: ProviderTimelineTurnAddress): string + /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ + turnOpener?(address: ProviderTimelineTurnAddress): string + /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), + * so a row is found again from its key alone. */ + item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity +} + +export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { + if (body.kind !== 'message') { + return body.kind + } + return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' +} + +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 scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row + * shape changes. 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 { + ordinalMessages: false, + 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.family === 'request' ? null : address.thread)}${ + address.incarnation > 1 ? `#${address.incarnation}` : '' + }` + ) + } +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts new file mode 100644 index 000000000000..b132802369f0 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalApprovalItem, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' +import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' + +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) + +async function openJournal(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) + roots.push(root) + return journals.open({ + identity: { + sessionId: 'session-joins', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'root' } + }, + stateDirectory: root, + now: () => 1_000 + }) +} + +const codexJoins = () => + new ProviderTimelineJoins({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: 'session-joins', + primaryThreadId: () => 'root' + }), + generation: 'gen-1', + namespace: 'ns' + }) + +const message = (id: string): ProviderTimelineItemJoin => ({ + family: 'item', + key: { source: 'provider', value: id }, + thread: 'root' +}) + +const inTurn = (turn: string) => ({ + thread: 'root', + turn: { source: 'provider', value: turn } as const, + scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const +}) + +async function write( + journal: AgentSessionJournal, + row: { identity: AgentJournalItemIdentity; ref?: string }, + text: string +) { + await journal.appendItem( + row.identity, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + { + fence: 1, + turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + ) +} + +describe('provider timeline joins', () => { + it('finds an ordinal message again from the reference its row carries, after a restart', async () => { + const journal = await openJournal() + const before = codexJoins() + const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) + await write(journal, m1, 'second') + expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) + + const after = codexJoins() + expect(after.find(message('m1'), journal)).toMatchObject({ + itemId: 'codex:root:t1:1', + scope: { kind: 'turn', turnItemId: 'turn:t1' } + }) + // A new message takes the next free place, past every row the turn holds. + expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:2' + ) + }) + + it('skips a place an echoed send holds through its submission alias', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:1' + ) + }) + + it('finds a row whose identity spells its key without any reference', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) + expect(tool.ref).toBeUndefined() + await write(journal, tool, 'x') + expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) + expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() + }) + + it('reads the current request incarnation back from the rows', async () => { + const journal = await openJournal() + const scheme = createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: 'session-joins' + }) + const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) + const key = { source: 'provider', value: 'p1' } as const + const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } + const approval: AgentJournalApprovalItem = { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + const before = joins() + for (let incarnation = 1; incarnation <= 2; incarnation += 1) { + const row = before.nextRequest(key, 'approval', thread, journal) + expect(row.incarnation).toBe(incarnation) + await journal.appendItem(row.identity, approval, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + expect(joins().request(key, journal)).toMatchObject({ + incarnation: 2, + itemId: agentJournalItemKey( + scheme.item({ + namespace: 'ns', + family: 'request', + key, + thread: null, + turn: null, + itemClass: 'approval', + messageOrdinal: null, + incarnation: 2 + }) + ) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts new file mode 100644 index 000000000000..1b85f32a0aa1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -0,0 +1,330 @@ +// Provider joins → journal rows: the assembler's canonical join index. +// +// Every row the assembler writes is found again through here, and every entry is a cache of a +// fact the journal holds. A row whose identity spells its provider key is found by recomputing +// that identity; a row whose identity does not (a message keyed by its place among its turn's +// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a +// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and +// request incarnations are read back from the rows that hold them. Only transition resolvers call +// the allocating members, so allocation happens in journal order and a refused event allocates +// nothing. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineItemAddress, + type ProviderTimelineItemClass, + type ProviderTimelineItemFamily, + type ProviderTimelineKey, + type ProviderTimelineTurnAddress +} from './provider-timeline-identity' + +const MAX_JOINED_ROWS = 1_024 +const MAX_JOINED_ROW_BYTES = 1024 * 1024 +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +/** One provider item as the provider names it. Item keys are the provider's per thread. */ +export type ProviderTimelineItemJoin = { + family: ProviderTimelineItemFamily + key: ProviderTimelineKey + thread: string | null +} + +export type ProviderTimelineRow = { + identity: AgentJournalItemIdentity + itemId: string + scope: AgentJournalTurnScope + /** The join's reference, on a row whose identity does not spell its key. */ + ref?: string + /** Which request under its key, 1 for the first. */ + incarnation: number +} + +export type ProviderTimelineTurnRef = { + address: ProviderTimelineTurnAddress + identity: AgentJournalItemIdentity + itemId: string + turnId: string +} + +/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ +export type ProviderTimelinePlacement = { + thread: string | null + turn: ProviderTimelineKey | null + scope: AgentJournalTurnScope +} + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineJoins { + private readonly rows = new BoundedMap({ + maxEntries: MAX_JOINED_ROWS, + maxBytes: MAX_JOINED_ROW_BYTES, + sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 + }) + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private serial = 0 + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + generation: string + namespace: string + } + ) {} + + get namespace(): string { + return this.deps.namespace + } + + /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ + reset(namespace: string): void { + this.deps.namespace = namespace + this.rows.clear() + this.ordinals.clear() + } + + /** A key unique to this acquisition, for something the provider names nothing. */ + mint(kind: string): ProviderTimelineKey { + this.serial += 1 + return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } + } + + /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ + turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { + const address = { namespace, key } + const identity = this.deps.scheme.turn(address) + return { + address, + identity, + itemId: agentJournalItemKey(identity), + turnId: this.deps.scheme.turnId(address) + } + } + + /** The user item a turn names when no send of Orca's opened it. */ + turnOpener(turn: ProviderTimelineTurnRef): string { + return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId + } + + /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ + reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { + const thread = join.family === 'request' ? null : join.thread + return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` + } + + /** The row the join already names, from memory or the fold; null when the journal holds none. */ + find( + join: ProviderTimelineItemJoin, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + const itemId = + journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) + const row = rowOf(journal.item(itemId), 1) + if (row) { + this.rows.set(ref, row) + } + return row + } + + /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ + place( + join: ProviderTimelineItemJoin, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const ordinal = this.ordinalFor(itemClass, placement, journal) + const identity = this.deps.scheme.item({ + ...this.address(join, 1), + turn: placement.turn, + itemClass, + messageOrdinal: ordinal + }) + const ref = this.reference(join) + const row: ProviderTimelineRow = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + ...(ordinal === null ? {} : { ref }), + incarnation: 1 + } + this.rows.set(ref, row) + return row + } + + /** An echoed send takes its message's place without a row (its send is the bubble). */ + reserveEcho( + join: ProviderTimelineItemJoin, + placement: ProviderTimelinePlacement, + journal: Journal + ) { + if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { + return + } + const row = this.place(join, 'message', placement, journal) + this.echoes.set(row.itemId, true) + } + + /** The request under `key` the journal holds last: the highest incarnation with a row. */ + request( + key: ProviderTimelineKey, + journal: Journal, + namespace = this.deps.namespace + ): ProviderTimelineRow | null { + const join = { family: 'request' as const, key, thread: null } + const ref = this.reference(join, namespace) + const cached = this.rows.get(ref) + if (cached || !journal || namespace !== this.deps.namespace) { + return cached ?? null + } + let current: ProviderTimelineRow | null = null + for (let incarnation = 1; ; incarnation += 1) { + const itemId = agentJournalItemKey(this.spelled(join, incarnation)) + // A scheme that spells no incarnation names one row for every request under the key. + const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) + if (!row) { + break + } + current = row + } + if (current) { + this.rows.set(ref, current) + } + return current + } + + /** The next request under `key`: resolvers only. */ + nextRequest( + key: ProviderTimelineKey, + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): ProviderTimelineRow { + const join = { family: 'request' as const, key, thread: null } + const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 + const identity = this.deps.scheme.item({ + ...this.address(join, incarnation), + thread: placement.thread, + turn: placement.turn, + itemClass + }) + const row = { + identity, + itemId: agentJournalItemKey(identity), + scope: placement.scope, + incarnation + } + this.rows.set(this.reference(join), row) + return row + } + + private address( + join: ProviderTimelineItemJoin, + incarnation: number + ): ProviderTimelineItemAddress { + return { + namespace: this.deps.namespace, + family: join.family, + key: join.key, + thread: join.thread, + turn: null, + itemClass: 'status', + messageOrdinal: null, + incarnation + } + } + + private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { + return this.deps.scheme.item(this.address(join, incarnation)) + } + + /** The next free place among the turn's messages: past every row and echoed send holding one. */ + private ordinalFor( + itemClass: ProviderTimelineItemClass, + placement: ProviderTimelinePlacement, + journal: Journal + ): number | null { + const turn = placement.turn + if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { + return null + } + const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace, + turn + )}` + let ordinal = this.ordinals.get(place) ?? 0 + while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { + return agentJournalItemKey( + this.deps.scheme.item({ + ...this.address({ family: 'item', key: turn, thread }, 1), + turn, + itemClass: 'message', + messageOrdinal: ordinal + }) + ) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} + +function rowOf( + item: AgentJournalRenderItem | null, + incarnation: number +): ProviderTimelineRow | null { + const identity = item ? parseAgentJournalItemKey(item.itemId) : null + if (!item || !identity) { + return null + } + return { + identity, + itemId: item.itemId, + scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, + ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), + incarnation + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index b1d4c483d9bd..eff850ae8e05 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -56,6 +56,10 @@ export type AgentSessionDeltaCoalescer = { dispose: () => void /** Bounded last-known state for terminalizing a rejected completion. */ snapshot: (key: string) => AgentSessionDeltaSnapshot | null + /** Streams with text not yet emitted, in arrival order, for a caller that writes them itself. */ + dirty: () => { key: string; snapshot: AgentSessionDeltaSnapshot }[] + /** The caller wrote this stream's current text itself; nothing is owed until it grows. */ + markFlushed: (key: string) => void } function defaultSchedule(run: () => void, ms: number): () => void { @@ -215,6 +219,27 @@ export function createAgentSessionDeltaCoalescer( truncated: stream.truncated } : null + }, + dirty: () => + [...streams].flatMap(([key, stream]) => + stream.dirty + ? [ + { + key, + snapshot: { + text: stream.chunks.join(''), + observedBytes: stream.observedBytes, + truncated: stream.truncated + } + } + ] + : [] + ), + markFlushed: (key) => { + const stream = streams.get(key) + if (stream) { + stream.dirty = false + } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index bdc07a3b3d54..bfa4b7520078 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -85,6 +86,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + journalItems = (): StructuredAgentSessionTransitionJournal | null => this.target?.journal ?? null + journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 49572abdcc00..5cdabdce713d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -12,6 +12,10 @@ import { estimateStructuredAgentSessionItemBytes } from './structured-agent-sess import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' import { createStructuredAgentSessionResolvedAppend } from './structured-agent-session-resolved-append' +import { + createStructuredAgentSessionTransitionMembers, + type StructuredAgentSessionTransitionSink +} from './structured-agent-session-transition' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionSinkAdmission = @@ -47,6 +51,8 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope + /** The provider's reference for the item, on the write that creates its row. */ + providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< @@ -80,7 +86,7 @@ export type StructuredAgentSessionRevisionOptions = StructuredAgentSessionItemAp /** Compatibility alias for lifecycle callers that already use this resolver. */ export type StructuredAgentSessionLifecycleIdentityResolver = StructuredAgentSessionIdentityResolver -export type StructuredAgentSessionEventSink = { +export type StructuredAgentSessionEventSink = StructuredAgentSessionTransitionSink & { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, @@ -286,6 +292,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { }, tryAppendItem: appendItem, ...resolvedAppend, + ...createStructuredAgentSessionTransitionMembers(queue), journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, journalStopDecidesTurn: queue.journalStopDecidesTurn, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index e7ce667ed2b6..57bb482f7c2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,6 +16,7 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, + ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts new file mode 100644 index 000000000000..1b242dd8d983 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalToolCallItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionSinkWatermarks +} from './structured-agent-session-event-sink' +import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import type { + StructuredAgentSessionTransition, + StructuredAgentSessionTransitionStep +} from './structured-agent-session-transition' + +const SESSION = 'session-transition' +const journals = createTrackedJournalOpener() +const roots: string[] = [] + +afterEach(async () => { + await journals.closeAll() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function identity(recordId: string): AgentJournalItemIdentity { + return { provider: 'legacy', agent: 'grok', sessionId: SESSION, recordId } +} + +function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJournalToolCallItem { + return { kind: 'tool-call', name, input: { name }, state } +} + +function itemStep( + resolve: Extract['resolve'] +): StructuredAgentSessionTransitionStep { + return { + kind: 'item', + reservedBytes: 4096, + resolve, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + } +} + +async function rig(watermarks: Partial = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-')) + roots.push(root) + const journal: AgentSessionJournal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) + const publishes: number[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + watermarks + }) + const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) + return { journal, deferred, sink: deferred.sink, publishes, bind } +} + +describe('structured agent-session transitions', () => { + it('lands its steps back to back, each resolved after the one before it', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const first = identity('first') + const transition: StructuredAgentSessionTransition = { + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: first, body: tool('read', 'running') })), + // Reads the row the step before it wrote. + itemStep((view) => { + const before = view.itemBody(agentJournalItemKey(first)) + return before?.kind === 'tool-call' + ? { identity: identity('second'), body: tool(`after-${before.name}`, 'running') } + : null + }) + ] + } + expect(sink.tryAppendTransition?.(transition)).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const start = items[0]?.sequence ?? 0 + expect(items.map((item) => [item.itemId, item.sequence - start])).toEqual([ + [agentJournalItemKey(first), 0], + [agentJournalItemKey(identity('second')), 1], + [agentJournalItemKey(identity('later')), 2] + ]) + expect(items[1]?.body).toMatchObject({ name: 'after-read' }) + expect(publishes).toHaveLength(1) + }) + + it('places a row where its resolver says, and hears every landing', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const landed: (readonly boolean[])[] = [] + const scope = { kind: 'turn', turnItemId: 'turn-row' } as const + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ + identity: identity('placed'), + body: tool('read', 'running'), + options: { turnScope: scope, providerItemRef: 'item:read' } + })), + itemStep(() => null) + ], + landed: (wrote) => landed.push(wrote) + }) + await deferred.drained() + + expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ + turnScope: scope, + providerItemRef: 'item:read' + }) + expect(landed).toEqual([[true, false]]) + }) + + it('refuses a transition whole, so none of its steps ever lands', async () => { + const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + const admitted = { lifecycle: false, publish: false, steps: [step('a')] } + const refused = { lifecycle: false, publish: false, steps: [step('b'), step('c')] } + + expect(sink.tryAppendTransition?.(admitted)).toEqual({ accepted: true }) + expect(sink.tryAppendTransition?.(refused)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + bind() + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('a')) + ]) + }) + + it('settles from the rows as they stand, in consecutive rows when one cannot hold them', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let wrote: readonly boolean[] = [] + expect( + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + // The settled row is a candidate too; the fold, not the caller, rules it out. + resolve: (view) => + [...running, identity('done')].flatMap((id) => { + const body = view.itemBody(agentJournalItemKey(id)) + return body?.kind === 'tool-call' && body.state === 'running' + ? [ + { + kind: 'item' as const, + identity: id, + body: { ...body, state: 'failed' as const }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + : [] + }) + } + ] + }) + ).toEqual({ accepted: true }) + sink.tryAppendItem?.(identity('after'), tool('after', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await deferred.drained() + + const items = journal.snapshot().items + const sequenceOf = (recordId: string) => + items.find((item) => item.itemId === agentJournalItemKey(identity(recordId)))?.sequence ?? 0 + expect( + items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toHaveLength(250) + expect( + items.find((item) => item.itemId === agentJournalItemKey(identity('done')))?.body + ).toMatchObject({ state: 'completed' }) + // Two batch rows, back to back, between the last append before it and the first after it. + expect(sequenceOf('after') - sequenceOf('done')).toBe(3) + expect(wrote).toEqual([true]) + expect(publishes).toHaveLength(1) + }) + + it('writes and announces nothing when a step resolves to nothing', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + let wrote: readonly boolean[] = [] + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + landed: (result) => { + wrote = result + }, + steps: [ + itemStep(() => null), + { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } + ] + }) + await deferred.drained() + + expect(journal.snapshot().items).toEqual([]) + expect(wrote).toEqual([false, false]) + expect(publishes).toEqual([]) + }) +}) + +describe('coalescer text a caller writes itself', () => { + it('reports unwritten streams and owes nothing once the caller marks them written', () => { + const emitted: string[] = [] + const coalescer = createAgentSessionDeltaCoalescer({ + emit: (_key, text) => { + emitted.push(text) + }, + schedule: () => () => {} + }) + coalescer.append('a', 'Hel') + coalescer.append('b', 'Wor') + coalescer.append('a', 'lo') + + expect(coalescer.dirty().map(({ key, snapshot }) => [key, snapshot.text])).toEqual([ + ['a', 'Hello'], + ['b', 'Wor'] + ]) + coalescer.markFlushed('a') + coalescer.flushAll() + expect(emitted).toEqual(['Wor']) + expect(coalescer.dirty()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts new file mode 100644 index 000000000000..4304338a148f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -0,0 +1,156 @@ +// One provider event's journal writes, admitted as a single sink operation. +// +// A producer that keeps state about what it wrote must change that state only for writes the sink +// took; when an event needs several rows, a refusal of the third after the first two were taken +// would leave the producer and the journal disagreeing. A transition is admitted whole or not at +// all. At execution its steps are issued in the same tick, so they sit together in the journal's +// write queue, and each step resolves against the fold with every earlier write landed — the +// steps before it included — so what a step writes is decided by the journal, not by memory. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' +import type { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' +import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' + +/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +export type StructuredAgentSessionTransitionJournal = Pick< + AgentSessionJournal, + | 'epoch' + | 'visitItems' + | 'itemBody' + | 'item' + | 'itemIdForProviderItemRef' + | 'canonicalItemId' + | 'visitItemsWithLinkage' +> + +export type StructuredAgentSessionTransitionStep = + | { + kind: 'item' + /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + reservedBytes: number + /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ + paced?: true + /** The row and its whole body; null writes nothing. Resolved `options` replace the planned + * ones, for a writer that learns the row's turn or producer only from the fold. */ + resolve: (journal: StructuredAgentSessionTransitionJournal) => { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions + } | null + options: StructuredAgentSessionItemAppendOptions + } + | { + kind: 'settlement' + /** Unique per settlement: the journal applies one id once. */ + settlementId: string + /** Paces the queue only; the mutations are the journal's to choose. */ + reservedBytes: number + /** Read at execution; none writes nothing. */ + resolve: ( + journal: StructuredAgentSessionTransitionJournal + ) => readonly JournalLifecycleMutationInput[] + } + +export type StructuredAgentSessionTransition = { + steps: readonly StructuredAgentSessionTransitionStep[] + /** Rides the sink's lifecycle budget: it ends or settles something. */ + lifecycle: boolean + /** Announce the writes once they land, when any step wrote. */ + publish: boolean + /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ + landed?: (wrote: readonly boolean[]) => void +} + +const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' + +/** The sink members a transition writer uses. */ +export type StructuredAgentSessionTransitionSink = { + /** Queues one event's writes as a single admitted operation. */ + tryAppendTransition?( + transition: StructuredAgentSessionTransition + ): StructuredAgentSessionSinkAdmission + /** The bound journal's rows as they stand now; null until bound. */ + journalItems?(): StructuredAgentSessionTransitionJournal | null +} + +export function createStructuredAgentSessionTransitionMembers( + queue: StructuredAgentSessionSinkQueue +): Required { + return { tryAppendTransition: transitionAppend(queue), journalItems: queue.journalItems } +} + +function transitionAppend( + queue: StructuredAgentSessionSinkQueue +): (transition: StructuredAgentSessionTransition) => StructuredAgentSessionSinkAdmission { + return (transition) => + queue.submit({ + bytes: + transition.steps.reduce((total, step) => total + step.reservedBytes, 0) + + (transition.publish ? 1 : 0), + lifecycle: transition.lifecycle, + run: async (bound) => { + const { journal, fence } = bound + // Issued in one tick, so no write submitted after this transition lands between its steps. + const writes = transition.steps.map((step) => + step.kind === 'item' + ? journal + .appendResolvedItem( + () => { + const resolved = step.resolve(journal) + if (!resolved) { + return null + } + if ( + !step.paced && + estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > + step.reservedBytes + ) { + throw new Error(STEP_OVERFLOW) + } + return { + identity: resolved.identity, + body: resolved.body, + ...(resolved.options + ? { + options: structuredAgentSessionJournalAppendOptions( + fence, + resolved.options + ) + } + : {}) + } + }, + structuredAgentSessionJournalAppendOptions(fence, step.options) + ) + .then((landed) => landed !== null) + : journal + .appendResolvedLifecycleBatch({ + settlementId: step.settlementId, + resolve: () => step.resolve(journal), + fence + }) + .then((landed) => landed !== null) + ) + let wrote: boolean[] = transition.steps.map(() => false) + try { + wrote = await Promise.all(writes) + } finally { + // Heard even when a write failed, so a writer's speculation about it always ends. + transition.landed?.(wrote) + } + if (transition.publish && wrote.some(Boolean)) { + bound.publish() + } + } + }) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..271053b1b214 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,8 +360,16 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } +/** The provider's own reference for the item a row is, as its producer joined it, on a row whose + * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its + * item ids on resume). Set by the write that creates the row and kept by every revision, like the + * turn scope. Only its producer reads it back, to find the row again after a restart; no client + * depends on it. */ +export type AgentJournalProviderItemRef = string + /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -387,6 +395,7 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope + providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts new file mode 100644 index 000000000000..ff0ecc5fed33 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that predates `providerItemRef` on journal rows and render items. +const BASELINE_REF = 'v1.4.218' + +const SESSION = 'session-provider-item-ref' + +function message(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +type OldRenderItem = { itemId: string; [field: string]: unknown } + +// Loads a real old build, including cold extraction and transforms. +test('an older host and client render a row carrying a provider item reference as if it had none', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: the same message twice, the first carrying the reference. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: directory, + now: () => 1_000 + }) + const body = { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'Hello' }] + } + const scope = { kind: 'thread' as const } + await journal.appendItem(message(0), body, { + fence: 1, + turnScope: scope, + providerItemRef: 'item:p:thread-1/item-1' + }) + await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + const current = journal.snapshot().items + expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [reducer, schemas, projection] = await Promise.all( + [ + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/shared/agent-session-journal-schemas.ts', + 'src/shared/structured-agent-session-projection.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( + reducer, + 'createJournalReducerState' + ) + const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( + reducer, + 'renderJournalState' + ) + const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( + schemas, + 'AgentJournalRenderItemSchema' + ) + const project = releaseExport<(item: unknown) => unknown>( + projection, + 'projectStructuredItemToNativeChat' + ) + + // The older host, after a downgrade, folds the rows as it always did. + const state = createState(SESSION, journal.epoch) + for (const row of rows) { + applyRow(state, row) + } + const folded = render(state).items + expect(folded.map((item) => item.itemId)).toEqual([ + 'codex:thread-1:turn-1:0', + 'codex:thread-1:turn-1:1' + ]) + const [first, second] = folded + if (!first || !second) { + throw new Error('the older host folded both rows') + } + expect(first).not.toHaveProperty('providerItemRef') + const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest + expect(same(first)).toEqual(same(second)) + + // The older client reads this host's render item without the field, and renders it the same. + const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) + if (!withRef || !withoutRef) { + throw new Error('the older client read both items') + } + expect(withRef).not.toHaveProperty('providerItemRef') + expect(project(withRef)).toEqual( + project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) + ) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From 7ad901be3caa50af68b5c3558142d7cb45abf2fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:51:51 -0700 Subject: [PATCH 03/26] 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. --- .../codex/codex-provider-timeline-identity.ts | 4 + .../provider-timeline-identity.ts | 5 + .../provider-timeline-joins.test.ts | 55 ++++++++ .../provider-timeline-joins.ts | 86 +++++------- .../provider-timeline-message-places.ts | 122 ++++++++++++++++++ .../structured-agent-session-transition.ts | 4 +- 6 files changed, 220 insertions(+), 56 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts index 029c13fe56e2..7da2bbd33aae 100644 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ b/src/main/codex/codex-provider-timeline-identity.ts @@ -20,6 +20,10 @@ export function createCodexProviderTimelineIdentityScheme(input: { codexTurnLifecycleIdentity(input.sessionId, address.key.value) return { ordinalMessages: true, + messageSlot: (identity) => + identity.provider === 'codex' + ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } + : null, turn, turnId: (address) => address.key.value, turnOpener: (address) => { 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 index d364b4d31acf..77286d029055 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -52,11 +52,16 @@ export type ProviderTimelineItemAddress = { incarnation: number } +export type ProviderTimelineMessageSlot = { place: string; ordinal: number } + export type ProviderTimelineIdentityScheme = { /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as * `providerItemRef`, since nothing else could find it again. */ ordinalMessages: boolean + /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and + * its ordinal; null for any other identity. Required with `ordinalMessages`. */ + messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity /** The id the turn row carries and a client's Stop names. */ turnId(address: ProviderTimelineTurnAddress): string diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index b132802369f0..8cddc98a2278 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,6 +62,13 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) +const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'root', + turnId: turn, + ordinal +}) + async function write( journal: AgentSessionJournal, row: { identity: AgentJournalItemIdentity; ref?: string }, @@ -118,6 +125,54 @@ describe('provider timeline joins', () => { ) }) + it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') + // Another turn and another thread keep their own places. + await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') + await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + const joins = codexJoins() + expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:3' + ) + expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:4' + ) + }) + + it('continues past an echoed send above the highest row', async () => { + const journal = await openJournal() + await write(journal, { identity: codexSlot('t1', 0) }, 'first') + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: codexSlot('t1', 4), + fence: 1 + }) + expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:5' + ) + }) + + it('forgets what it read from an epoch the journal replaced', async () => { + const journal = await openJournal() + const joins = codexJoins() + const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) + await write(journal, m0, 'first') + expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') + await journal.replaceEpochItems('handle_forked', 1, []) + expect(joins.find(message('m0'), journal)).toBeNull() + expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( + 'codex:root:t1:0' + ) + }) + it('finds a row whose identity spells its key without any reference', async () => { const journal = await openJournal() const scheme = createLegacyProviderTimelineIdentityScheme({ diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts index 1b85f32a0aa1..7cb48073933d 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts @@ -22,7 +22,6 @@ import { import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { - providerTimelineKeyPart, spellProviderTimelineKey, type ProviderTimelineIdentityScheme, type ProviderTimelineItemAddress, @@ -31,12 +30,10 @@ import { type ProviderTimelineKey, type ProviderTimelineTurnAddress } from './provider-timeline-identity' +import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' const MAX_JOINED_ROWS = 1_024 const MAX_JOINED_ROW_BYTES = 1024 * 1024 -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 /** One provider item as the provider names it. Item keys are the provider's per thread. */ export type ProviderTimelineItemJoin = { @@ -81,12 +78,9 @@ export class ProviderTimelineJoins { maxBytes: MAX_JOINED_ROW_BYTES, sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 }) - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ maxEntries: MAX_RESERVED_ECHOES }) + private readonly places: ProviderTimelineMessagePlaces + /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ + private epoch: string | null = null private serial = 0 constructor( @@ -95,7 +89,12 @@ export class ProviderTimelineJoins { generation: string namespace: string } - ) {} + ) { + this.places = new ProviderTimelineMessagePlaces({ + scheme: deps.scheme, + namespace: () => this.deps.namespace + }) + } get namespace(): string { return this.deps.namespace @@ -105,7 +104,19 @@ export class ProviderTimelineJoins { reset(namespace: string): void { this.deps.namespace = namespace this.rows.clear() - this.ordinals.clear() + this.places.clear() + } + + /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ + private sync(journal: Journal): void { + if (!journal || journal.epoch === this.epoch) { + return + } + if (this.epoch !== null) { + this.rows.clear() + this.places.clear() + } + this.epoch = journal.epoch } /** A key unique to this acquisition, for something the provider names nothing. */ @@ -143,6 +154,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const ref = this.reference(join, namespace) const cached = this.rows.get(ref) // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. @@ -165,7 +177,11 @@ export class ProviderTimelineJoins { placement: ProviderTimelinePlacement, journal: Journal ): ProviderTimelineRow { - const ordinal = this.ordinalFor(itemClass, placement, journal) + this.sync(journal) + const ordinal = + this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn + ? this.places.next(placement.thread, placement.turn, journal) + : null const identity = this.deps.scheme.item({ ...this.address(join, 1), turn: placement.turn, @@ -194,7 +210,7 @@ export class ProviderTimelineJoins { return } const row = this.place(join, 'message', placement, journal) - this.echoes.set(row.itemId, true) + this.places.reserve(row.itemId) } /** The request under `key` the journal holds last: the highest incarnation with a row. */ @@ -203,6 +219,7 @@ export class ProviderTimelineJoins { journal: Journal, namespace = this.deps.namespace ): ProviderTimelineRow | null { + this.sync(journal) const join = { family: 'request' as const, key, thread: null } const ref = this.reference(join, namespace) const cached = this.rows.get(ref) @@ -269,47 +286,6 @@ export class ProviderTimelineJoins { private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { return this.deps.scheme.item(this.address(join, incarnation)) } - - /** The next free place among the turn's messages: past every row and echoed send holding one. */ - private ordinalFor( - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): number | null { - const turn = placement.turn - if (!this.deps.scheme.ordinalMessages || itemClass !== 'message' || !turn) { - return null - } - const place = `${providerTimelineKeyPart(placement.thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace, - turn - )}` - let ordinal = this.ordinals.get(place) ?? 0 - while (this.taken(this.slot(placement.thread, turn, ordinal), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - private slot(thread: string | null, turn: ProviderTimelineKey, ordinal: number): string { - return agentJournalItemKey( - this.deps.scheme.item({ - ...this.address({ family: 'item', key: turn, thread }, 1), - turn, - itemClass: 'message', - messageOrdinal: ordinal - }) - ) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } } function rowOf( diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts new file mode 100644 index 000000000000..e5350c39c93a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts @@ -0,0 +1,122 @@ +// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages +// by that place. Read back from the journal on a miss, past every place a row or an echoed send +// holds, so a restart continues the sequence the journal already has. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineKeyPart, + spellProviderTimelineKey, + type ProviderTimelineIdentityScheme, + type ProviderTimelineKey +} from './provider-timeline-identity' + +const MAX_TURN_ORDINALS = 256 +/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ +const MAX_RESERVED_ECHOES = 64 + +type Journal = StructuredAgentSessionTransitionJournal | null + +function bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +export class ProviderTimelineMessagePlaces { + /** The next ordinal per place. */ + private readonly ordinals = new BoundedMap({ + maxEntries: MAX_TURN_ORDINALS, + maxBytes: MAX_TURN_ORDINALS * 1024, + sizeOf: (_next, place) => bytes(place) + 8 + }) + private readonly echoes = new BoundedMap({ + maxEntries: MAX_RESERVED_ECHOES, + maxBytes: MAX_RESERVED_ECHOES * 1024, + sizeOf: (_held, itemId) => bytes(itemId) + 8 + }) + + constructor( + private readonly deps: { + scheme: ProviderTimelineIdentityScheme + namespace: () => string + } + ) {} + + clear(): void { + this.ordinals.clear() + this.echoes.clear() + } + + /** Holds a place no row takes (an echoed send's). */ + reserve(itemId: string): void { + this.echoes.set(itemId, true) + } + + /** The next place in (thread, turn): allocating, so resolvers only. */ + next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( + this.deps.namespace(), + turn + )}` + let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) + // Another writer of the same turn may have taken places since this one cached its mark. + while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { + ordinal += 1 + } + this.ordinals.set(place, ordinal + 1) + return ordinal + } + + /** One past the highest ordinal the journal holds in the place, like the ordinal counter it + * continues: a gap below it (a removed or never-imported row) is never filled. */ + private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { + const read = this.deps.scheme.messageSlot + const place = read?.(this.slot(thread, turn, 0))?.place + if (!read || place === undefined || !journal) { + return 0 + } + let next = 0 + const consider = (itemId: string | null) => { + const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) + const slot = identity ? read(identity) : null + if (slot?.place === place) { + next = Math.max(next, slot.ordinal + 1) + } + } + journal.visitItems((itemId) => consider(itemId)) + // An echoed send holds its place through its submission alias, with no row of its own. + for (const submission of journal.submissions()) { + consider(submission.providerItemId) + } + return next + } + + private slot( + thread: string | null, + turn: ProviderTimelineKey, + ordinal: number + ): AgentJournalItemIdentity { + return this.deps.scheme.item({ + namespace: this.deps.namespace(), + family: 'item', + key: turn, + thread, + turn, + itemClass: 'message', + messageOrdinal: ordinal, + incarnation: 1 + }) + } + + private taken(itemId: string, journal: Journal): boolean { + return ( + this.echoes.has(itemId) || + (journal !== null && + (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 4304338a148f..c67dab93edef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -21,10 +21,12 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, and their turns. */ +/** What a transition step reads: rows by key or provider reference, every row, their turns, and + * the sends whose echo holds a provider item's place. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, | 'epoch' + | 'submissions' | 'visitItems' | 'itemBody' | 'item' From 620b8d7deb28d287d327de731468e2446c0d60c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:08 -0700 Subject: [PATCH 04/26] Spell the subagent thread's message slot without spreading an identity union --- .../agent-session-timeline/provider-timeline-joins.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts index 8cddc98a2278..6a3f1e8d9d3f 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts @@ -62,9 +62,9 @@ const inTurn = (turn: string) => ({ scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const }) -const codexSlot = (turn: string, ordinal: number): AgentJournalItemIdentity => ({ +const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ provider: 'codex', - threadId: 'root', + threadId, turnId: turn, ordinal }) @@ -130,7 +130,7 @@ describe('provider timeline joins', () => { await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') // Another turn and another thread keep their own places. await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: { ...codexSlot('t1', 9), threadId: 'sub' } }, 'subagent') + await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') const joins = codexJoins() expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( 'codex:root:t1:3' From e892640d72c612fec4866c73813a39f85005b980 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:52:59 -0700 Subject: [PATCH 05/26] 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. --- .../codex-provider-timeline-identity.test.ts | 409 ++++++++++++++++++ .../provider-timeline-apply-host.ts | 35 ++ .../provider-timeline-assembler-items.test.ts | 255 +++++++++++ .../provider-timeline-assembler-order.test.ts | 254 +++++++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 +++++++++++++++++ ...rovider-timeline-assembler-refusal.test.ts | 208 +++++++++ ...provider-timeline-assembler-replay.test.ts | 295 +++++++++++++ ...rovider-timeline-assembler-session.test.ts | 196 +++++++++ ...rovider-timeline-assembler-test-support.ts | 201 +++++++++ .../provider-timeline-assembler-turns.test.ts | 183 ++++++++ .../provider-timeline-assembler.ts | 250 +++++++++++ .../provider-timeline-budget.ts | 66 +++ .../provider-timeline-context.ts | 67 +++ .../provider-timeline-decision.ts | 102 +++++ .../provider-timeline-decisions.ts | 76 ++++ .../provider-timeline-event.ts | 128 ++++++ .../provider-timeline-item-decisions.ts | 196 +++++++++ .../provider-timeline-plan.ts | 144 ++++++ .../provider-timeline-settlement.ts | 147 +++++++ .../provider-timeline-state.ts | 172 ++++++++ .../provider-timeline-text-events.ts | 105 +++++ .../provider-timeline-text-streams.ts | 360 +++++++++++++++ .../provider-timeline-transition-layout.ts | 120 +++++ .../provider-timeline-turn-decisions.ts | 235 ++++++++++ 24 files changed, 4606 insertions(+) create mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.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-recovery.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-replay.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-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-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-decisions.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-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-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/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts new file mode 100644 index 000000000000..84fc68f15bf3 --- /dev/null +++ b/src/main/codex/codex-provider-timeline-identity.test.ts @@ -0,0 +1,409 @@ +// The migration contract for moving Codex onto the shared timeline assembler: the assembler with +// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys +// (live messages, history restored on resume, a late item naming its own turn, interleaved +// subagent threads) and the same row contents. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' +import { + codexItemIdentity, + codexJournalItem, + type CodexThreadItem +} from './codex-structured-item-translation' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +afterEach(closeProviderTimelineRigs) + +const THREAD = 'thread-abc' +const CHILD = 'thread-child' + +async function codexRig() { + return openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => THREAD + }), + ownThread: () => THREAD + }) +} + +function body(item: CodexThreadItem, started?: CodexThreadItem) { + const translated = codexJournalItem(item, undefined, started).body + if ( + !translated || + translated.kind === 'turn' || + translated.kind === 'approval' || + translated.kind === 'question' + ) { + throw new Error(`no item body for ${item.type}`) + } + return translated +} + +const keys = (rows: AgentJournalRenderItem[]) => + rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) + +/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ +const content = (rows: AgentJournalRenderItem[]) => + JSON.stringify( + rows.map( + ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) => ({ + itemId, + body, + turnScope, + agentId, + parentAgentId, + providerParentRef, + producerKind, + attempt + }) + ) + ) + +function liveTranslator(rig: Awaited>) { + return createCodexJournalTranslator({ + sink: rig.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => 1_000, + schedule: (run) => { + run() + return () => {} + } + }) +} + +function notify(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } +} + +describe('Codex identities through the shared assembler', () => { + it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { + const rig = await codexRig() + const message = ( + id: string, + text: string, + join?: { turn?: string } + ): ProviderTimelineEvent => ({ + type: 'item.close', + item: id, + body: body({ type: 'agentMessage', id, text }), + join: { thread: THREAD, ...join } + }) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply(message('item-0', 'one')) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + rig.assembler.apply(message('item-1', 'orphan')) + rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) + rig.assembler.apply(message('item-2', 'two')) + // A message naming another turn joins that turn, whatever is open. + rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) + + // The keys Codex's own translator writes for the same frames. + expect(keys(await rig.rows())).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-1', + 'codex:thread-abc:turn-2:0', + 'codex:thread-abc:turn-9:0' + ]) + }) + + it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-0', + body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), + join: { thread: CHILD, turn: 'turn-child' } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-1', + body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), + join: { thread: THREAD } + }) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.itemId)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-child:turn-child:0', + 'codex:thread-abc:turn-1:1' + ]) + expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) + }) + + it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { + const rig = await codexRig() + const ordinals = new ProviderTurnMessageOrdinals() + const oracle = (item: CodexThreadItem) => + agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) + const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } + const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } + const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } + const expected = [oracle(echo), oracle(reasoning), oracle(answer)] + + rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + rig.assembler.apply({ + type: 'input.accepted', + clientMessageId: 'send-1', + requestedAt: 900, + join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } + }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'reasoning', + text: 'why', + join: { thread: THREAD } + }) + rig.assembler.apply({ + type: 'item.close', + item: 'item-2', + body: body(answer), + join: { thread: THREAD } + }) + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) + + expect(keys(await rig.rows())).toEqual(expected.slice(1)) + expect(expected[2]).toBe('codex:thread-abc:turn-1:1') + }) +}) + +describe('Codex rows through the shared assembler', () => { + it('lands the same rows as the Codex translator for a live turn', async () => { + const codex = await openProviderTimelineRig() + let clock = 1_000 + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + now: () => clock, + schedule: (run) => { + run() + return () => {} + } + }) + const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params + }) + const started: CodexThreadItem = { + type: 'commandExecution', + id: 'exec-1', + command: 'ls', + status: 'inProgress' + } + const finished: CodexThreadItem = { + ...started, + status: 'completed', + exitCode: 0, + aggregatedOutput: 'ok' + } + const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/started', { item: { ...reply, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) + translator.handle(notify('item/completed', { item: reply })) + translator.handle(notify('item/started', { item: started })) + translator.handle(notify('item/completed', { item: finished })) + clock = 2_000 + translator.handle( + notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) + ) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-1' }, + channel: 'assistant', + text: 'Hello', + join + }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) + shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) + shared.assembler.apply({ + type: 'item.close', + item: 'exec-1', + body: body(finished, started), + join + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 2_000, + state: 'completed', + outcome: 'success', + durationMs: 900 + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows as the Codex translator for a turn restored from history', async () => { + const turn = { + id: 'turn-1', + status: 'completed', + startedAt: 1_700_000_000, + completedAt: 1_700_000_050, + items: [ + { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, + { type: 'reasoning', id: 'think-1', summary: ['why'] }, + { type: 'agentMessage', id: 'interim-1', text: 'looking' }, + { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, + { type: 'agentMessage', id: 'answer-1', text: 'answer' } + ] + } + const codex = await openProviderTimelineRig() + const translator = createCodexJournalTranslator({ + sink: codex.eventSink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) + for (const item of turn.items) { + const translated = codexJournalItem(item).body + if ( + translated && + translated.kind !== 'turn' && + translated.kind !== 'approval' && + translated.kind !== 'question' + ) { + shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) + } + } + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: turn.completedAt * 1000, + state: 'completed', + outcome: 'success' + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) + + it('lands the same rows across a restart in the middle of a streamed message', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } + const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: first })) + translator.handle(notify('item/started', { item: { ...second, text: '' } })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) + translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) + translator.handle(notify('item/completed', { item: second })) + + const shared = await codexRig() + const join = { thread: THREAD, turn: 'turn-1' } + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) + shared.assembler.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'Sec', + join + }) + await shared.rows() + // The host restarts while Codex is still streaming item-2. + const restarted = shared.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'item-2' }, + channel: 'assistant', + text: 'ond', + join + }) + restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) + + const rows = await shared.rows() + expect(content(rows)).toBe(content(await codex.rows())) + // Codex message rows carry the item id their ordinal identity cannot spell. + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) + ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) + }) + + it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { + const codex = await openProviderTimelineRig() + const translator = liveTranslator(codex) + const shared = await codexRig() + const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } + translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) + translator.handle(notify('item/completed', { item: old })) + translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) + translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + shared.assembler.apply({ + type: 'turn.end', + turn: 'turn-1', + at: 1_000, + state: 'completed', + outcome: 'success' + }) + shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) + for (let index = 0; index < 1_025; index += 1) { + const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } + translator.handle(notify('item/completed', { item })) + shared.assembler.apply({ + type: 'item.close', + item: item.id, + body: body(item), + join: { thread: THREAD } + }) + if (index % 32 === 31) { + await Promise.all([codex.rows(), shared.rows()]) + } + } + // Long after its join left memory, Codex repeats the old message with no turn of its own. + shared.assembler.apply({ + type: 'item.close', + item: 'old-1', + body: body(old), + join: { thread: THREAD } + }) + + expect(content(await shared.rows())).toBe(content(await codex.rows())) + }) +}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts new file mode 100644 index 000000000000..12804f3150b3 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts @@ -0,0 +1,35 @@ +// What the event appliers share with the assembler that owns them. + +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 type { ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineDropRule } from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' +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 +} + +/** An admitted transition that has not landed: what it changes in the forecast until it does. */ +export type ProviderTimelineInFlight = { + executed: boolean + commit: ((state: ProviderTimelineState) => void) | undefined +} + +export type ProviderTimelineApplyHost = { + context: ProviderTimelineContext + streams: ProviderTimelineTextStreams + /** The forecast now; it is replaced as transitions land. */ + forecast(): ProviderTimelineState + journal(): StructuredAgentSessionTransitionJournal | null + admits(hold: ProviderTimelineHold): boolean + submit( + plan: ProviderTimelinePlan, + entry: ProviderTimelineInFlight + ): StructuredAgentSessionSinkAdmission +} 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 000000000000..0172719fef48 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalPlainStatusItem +} from '../../../shared/agent-session-journal-types' +import { + assistantText, + 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('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 keeps one that outlives the turn open', 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: runningTool('background'), + outlivesTurn: true + }) + 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 rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ + state: 'running' + }) + + // It settles on its own close later, still scoped to the turn that started it. + rig.assembler.apply({ + type: 'item.close', + item: 'bg-1', + body: { ...runningTool('background'), state: 'completed' } + }) + const background = await rig.row(providerItemId('item', 'bg-1')) + expect(background?.body).toMatchObject({ state: 'completed' }) + expect(background?.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('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 000000000000..3c8ee4949fdf --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-order.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' +import { + assistantText, + 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 an item in the turn it opened in when its close arrives 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: runningTool('task'), + outlivesTurn: true + }) + 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: { ...runningTool('task'), state: 'completed' } + }) + const row = await rig.row(providerItemId('item', 'bg')) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: providerTurnItemId('t1') }) + expect(row?.body).toMatchObject({ state: 'completed' }) + }) + + 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-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts new file mode 100644 index 000000000000..d1bcda1b390c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -0,0 +1,402 @@ +// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal +// rejected, an event the sink refused) never changes which row an event lands on or what it says. + +import { afterEach, describe, expect, it } from 'vitest' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' +import { createProviderTimelineAssembler } from './provider-timeline-assembler' +import { + AGENT, + assistantText, + closeProviderTimelineRigs, + messageText, + NAMESPACE, + openProviderTimelineRig, + pendingApproval, + providerItemId, + providerTurnId, + providerTurnItemId, + refusingSink, + runningTool, + SESSION +} from './provider-timeline-assembler-test-support' +import { providerTimelineSink } from './provider-timeline-plan' + +afterEach(closeProviderTimelineRigs) + +const codexRig = () => + openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + +const messages = async (rig: Awaited>) => + (await rig.rows()).filter((row) => row.body.kind === 'message') + +describe('a forgotten join finds its original row', () => { + it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) + rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) + restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'First'], + ['codex:root:t1:1', 'Second'], + ['codex:root:t1:2', 'Third'] + ]) + }) + + it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { + const rig = await codexRig() + const join = { thread: 'root', turn: 't1' } + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'Hel', + join + }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'text.delta', + item: { id: 'm0' }, + channel: 'assistant', + text: 'lo', + join + }) + restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) + + expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ + ['codex:root:t1:0', 'Hello'] + ]) + }) + + it('reaches a message in its own turn after its join was evicted', async () => { + const rig = await codexRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ + type: 'item.close', + item: `m-${index}`, + body: assistantText('small'), + join: { thread: 'root' } + }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'old-message', + body: assistantText('Old'), + join: { thread: 'root' } + }) + + const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') + expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) + + it('keeps a late tool close in the turn it opened in after its join was evicted', 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: runningTool('background'), + outlivesTurn: true + }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) + for (let index = 0; index < 1_025; index += 1) { + rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) + if (index % 32 === 31) { + await rig.rows() + } + } + rig.assembler.apply({ + type: 'item.close', + item: 'background-tool', + body: { ...runningTool('background'), state: 'completed' } + }) + + expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('t1') + }) + }) + + it('withdraws the request incarnation a previous run opened', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) + rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) + + expect( + (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body + ).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) + +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 codexRig() + 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([ + ['codex:root:t1:0', 'Root'], + ['codex:child:child-turn:0', 'Child'] + ]) + }) + + it('never lets replayed text replace a message its settled turn completed', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + await rig.rows() + + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ + type: 'text.delta', + item: { id: 'm1' }, + channel: 'assistant', + text: 'Full', + join: { turn: 't1' } + }).dropped + ).toBe('item-settled') + restarted.flush() + + expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') + }) + + 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('a write the journal rejects changes nothing the assembler knows', () => { + it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + + 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-2', + namespace: NAMESPACE + }) + // Planned before bind: nothing tells the planner the journal already holds both turns. + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) + await deferred.drained() + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect(assembler.openTurnId).toBe(providerTurnId('live')) + + const closed = assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + expect(closed.dropped).toBeUndefined() + await deferred.drained() + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'completed' + }) + deferred.close() + assembler.dispose() + }) + + it('takes no message ordinal for an event the sink refused', async () => { + const rig = await codexRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + const join = { thread: 'root', turn: 't1' } + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + expect( + assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) + .admission + ).toEqual({ accepted: false, reason: 'backpressure' }) + refusing = false + assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) + + expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) + }) +}) + +describe('reset settles the old session whatever the assembler remembers', () => { + it('writes text a reset interrupts, though nothing else was open', async () => { + const rig = await openProviderTimelineRig({ schedule: () => () => {} }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Before reset' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + + expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) + }) + + it('settles the journal work of a previous run as lost', 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') }) + await rig.rows() + + rig.restart({ generation: 'gen-2' }).apply({ + type: 'session.reset', + namespace: 'provider-session-2' + }) + + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ + state: 'failed' + }) + }) +}) + +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-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts new file mode 100644 index 000000000000..4551013a3c64 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts @@ -0,0 +1,208 @@ +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.restart({ 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 }) + expect(assembler.apply(end).dropped).toBe('turn-unknown') + }) + + it('keeps observed text when the close that would write it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ + 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.restart({ + 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.restart({ 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.restart({ 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' }) + }) + + it('keeps the old session open when the reset that would settle it is refused', async () => { + const rig = await openProviderTimelineRig() + let refusing = false + const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) + refusing = true + const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const + expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) + expect(assembler.openTurnId).not.toBeNull() + refusing = false + assembler.apply(reset) + expect(assembler.openTurnId).toBeNull() + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + }) +}) + +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-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts new file mode 100644 index 000000000000..bb0eee27f429 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts @@ -0,0 +1,295 @@ +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('a replay never changes what the journal already holds', () => { + it('keeps an answered prompt answered when a restarted host replays its open and its turn', 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 itemId = providerItemId('request', 'p1') + const identity = parseAgentJournalItemKey(itemId) + if (!identity) { + throw new Error('request key did not parse') + } + // The answer path's compare-and-set lands first, with no word to the assembler. + await rig.journal.appendItem(identity, answered, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } + }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) + expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) + + const restarted = rig.restart({ generation: 'gen-2' }) + expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( + 'turn-replayed' + ) + expect( + restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped + ).toBe('request-replayed') + restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) + expect((await rig.row(itemId))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) + + it('does not let a replayed open of a settled turn supersede the live one', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) + const live = restarted.openTurnId + + expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(restarted.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running' }) + expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ + state: 'running' + }) + }) + + it('settles a turn the journal holds running even when the assembler that opened it is gone', 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') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + }) + + it('takes its open turn back from the journal when a planned open found the row already there', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) + await rig.rows() + const live = rig.assembler.openTurnId + // The plan reads no journal yet (as before it binds); the write still asks it when it runs. + let lagging = true + const stale = rig.restart({ + sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, + generation: 'gen-2' + }) + expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() + lagging = false + await rig.rows() + await Promise.resolve() + expect(stale.openTurnId).toBe(live) + expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) + }) + + 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' + }) + }) + + it('does not reopen a settled tool a restarted host replays', 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') }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool', + body: { ...runningTool('read'), state: 'completed' } + }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + expect( + restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-replayed') + expect( + restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped + ).toBe('item-settled') + 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 it could not see when it planned the open', 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') } + }) + const blind = rig.restart({ + sink: { ...rig.sink, journalItems: () => null }, + generation: 'gen-2' + }) + blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved', selectedOptionId: 'allow' } + }) + }) +}) + +describe('a new provider session is a new id space', () => { + it('gives a reused provider turn id its own row after a reset', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) + + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) + }) + + it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('first'), state: 'completed' } + }) + rig.assembler.apply({ + type: 'session.ended', + verdict: { state: 'interrupted', completedAt: 2_000 } + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ + type: 'item.close', + item: 'tool-1', + body: { ...runningTool('second'), state: 'completed' } + }) + const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(tools.map((row) => row.body)).toEqual([ + expect.objectContaining({ name: 'first' }), + expect.objectContaining({ name: 'second' }) + ]) + }) + + it('settles the open work of a session reset before anything ended it, as lost', 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') }) + rig.assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Half' + }) + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) + + expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) + expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) + expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) + }) +}) 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 000000000000..3bc3aeb3caa5 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-session.test.ts @@ -0,0 +1,196 @@ +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 until a reset starts a fresh namespace', 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() + + rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) + rig.assembler.apply({ type: 'turn.open', at: 3_000 }) + const second = rig.assembler.openTurnId + expect(second).not.toBeNull() + expect(second).not.toBe(first) + const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') + expect(turns.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnId: first, state: 'interrupted' }), + expect.objectContaining({ turnId: second, state: 'running' }) + ]) + }) +}) + +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' }) + expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).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-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts new file mode 100644 index 000000000000..0b1d20746c58 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -0,0 +1,201 @@ +// 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 { 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 { + 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. */ +export function providerItemId( + family: ProviderTimelineItemFamily, + key: string, + options: { namespace?: string; incarnation?: number } = {} +): string { + return agentJournalItemKey( + scheme.item({ + namespace: options.namespace ?? NAMESPACE, + family, + key: { source: 'provider', value: key }, + thread: null, + turn: null, + itemClass: 'message', + messageOrdinal: null, + incarnation: options.incarnation ?? 1 + }) + ) +} + +/** 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' } +} + +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() +} + +export type ProviderTimelineRig = { + journal: AgentSessionJournal + assembler: ProviderTimelineAssembler + sink: ProviderTimelineSink + /** The same journal's event sink, for a lane that writes it directly. */ + eventSink: StructuredAgentSessionEventSink + /** A fresh assembler on the same journal, as a restarted host builds. */ + restart(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: { kind: 'opaque', agent: AGENT, value: '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') + } + const build = (more: Partial = {}) => + createProviderTimelineAssembler({ + sink, + sessionId: SESSION, + agent: AGENT, + generation: GENERATION, + namespace: NAMESPACE, + // Every delta writes at once unless a test drives the window itself. + schedule: (run) => { + run() + return () => {} + }, + ...overrides, + ...more + }) + const rows = async () => { + await deferred.drained() + return journal.snapshot().items + } + const turns = async () => + (await rows()).flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + return { + journal, + assembler: build(), + sink, + eventSink: deferred.sink, + restart: 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) + ) + } + } +} 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 000000000000..69fa01b3f9d1 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-turns.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + 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, a repeated end, and an end for a turn it never opened', 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' }) + expect( + rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 3_000, state: 'interrupted' }) + .dropped + ).toBe('turn-unknown') + expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( + 'no-turn' + ) + // A replayed open of a settled turn neither reopens nor rewrites it. + expect(rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 4_000 }).dropped).toBe( + 'turn-replayed' + ) + expect(await rig.turn('turn-1')).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000 + }) + }) + + it('never rewrites a settled turn when a restarted host replays its history', 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() + + // A new host process has no memory of the turn; only the journal knows it ended. + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) + restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + 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 + }) + }) +}) 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 000000000000..480e40c88bac --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -0,0 +1,250 @@ +// 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 plans each event as ONE sink transition. Which row each write lands on, whether +// a replay writes at all, and every change to what the assembler knows (its ledger) are decided +// in the transition's resolvers, at the event's turn in the journal's write queue, against the +// journal as it stands then. So there is one clock: a refused event changed nothing, a write the +// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. +// - Planning reads a forecast — the ledger plus what admitted events still in the queue will +// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. +// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the +// answer compare-and-set. + +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 type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { + PROVIDER_TIMELINE_OVER_BUDGET, + providerTimelineBudgetAdmits, + type ProviderTimelineHold +} from './provider-timeline-budget' +import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import { + decideProviderTimelineEvent, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { + createLegacyProviderTimelineIdentityScheme, + type ProviderTimelineIdentityScheme +} from './provider-timeline-identity' +import { ProviderTimelineJoins } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineState } from './provider-timeline-state' +import { + applyProviderTimelineTextClose, + applyProviderTimelineTextDelta +} from './provider-timeline-text-events' +import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' +import { + planProviderTimelineBarrier, + planProviderTimelineSlots +} from './provider-timeline-transition-layout' + +export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' +export type { ProviderTimelineDropRule } from './provider-timeline-decisions' + +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 + dispose(): void +} + +export type ProviderTimelineAssemblerDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: AgentType + /** The acquisition: minted keys are unique per generation. */ + generation: string + /** The provider session whose ids the adapter forwards; the same one again after a restart that + * re-attaches it, so its replays are recognised. */ + namespace: string + /** The session's own provider thread, for providers that run subagents on threads of their own. */ + ownThread?: () => string | null + /** Defaults to the shared `legacy` identity arm. */ + scheme?: ProviderTimelineIdentityScheme + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function createProviderTimelineAssembler( + deps: ProviderTimelineAssemblerDeps +): ProviderTimelineAssembler { + const scheme = + deps.scheme ?? + createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) + let settlements = 0 + const context: ProviderTimelineContext = { + sessionId: deps.sessionId, + agent: deps.agent, + joins: new ProviderTimelineJoins({ + scheme, + generation: deps.generation, + namespace: deps.namespace + }), + ledger: new ProviderTimelineState(deps.namespace), + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), + settlementId: (what) => { + settlements += 1 + return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` + } + } + const streams = new ProviderTimelineTextStreams({ + sink: deps.sink, + context, + generation: deps.generation, + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}) + }) + let forecast = context.ledger.clone() + const inFlight: ProviderTimelineInFlight[] = [] + + /** The forecast again: the ledger, plus what admitted events not yet run will change. */ + const resync = () => { + forecast = context.ledger.clone() + for (const entry of inFlight) { + if (!entry.executed) { + entry.commit?.(forecast) + } + } + } + + const submit = ( + plan: ProviderTimelinePlan, + entry: InFlight + ): StructuredAgentSessionSinkAdmission => { + if (!plan.writes) { + return plan.submit(deps.sink) + } + inFlight.push(entry) + const settle = () => { + const at = inFlight.indexOf(entry) + if (at !== -1) { + inFlight.splice(at, 1) + resync() + } + } + const admission = plan.submit(deps.sink, settle) + if (!admission.accepted) { + inFlight.splice(inFlight.indexOf(entry), 1) + return admission + } + entry.commit?.(forecast) + return admission + } + + const admits = (hold: ProviderTimelineHold): boolean => + providerTimelineBudgetAdmits({ + hold, + forecast, + ledger: context.ledger, + streams: streams.open, + journal: deps.sink.journalItems() + }) + + const host: ProviderTimelineApplyHost = { + context, + streams, + forecast: () => forecast, + journal: () => deps.sink.journalItems(), + admits, + submit + } + + const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + const decision = decideProviderTimelineEvent( + { context, state: forecast, journal, execute: false }, + event + ) + if (decision.dropped) { + return { admission: ADMITTED, dropped: decision.dropped } + } + if (decision.hold && !admits(decision.hold)) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + const plan = new ProviderTimelinePlan() + planProviderTimelineBarrier(host, plan, event, decision) + const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } + let executed: ProviderTimelineDecision | null = null + // The event's decision on the ledger, taken once, by its first slot to run. + const decide = (at: StructuredAgentSessionTransitionJournal) => { + if (!executed) { + const ledger = providerTimelineLedger(context, at) + executed = decideProviderTimelineEvent( + { context, state: ledger, journal: at, execute: true }, + event + ) + entry.executed = true + executed.commit?.(ledger) + } + return executed.dropped ? null : executed + } + planProviderTimelineSlots(host, plan, event, decide) + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'session.ended' || + event.type === 'session.reset' + ) { + plan.onAdmitted(() => deps.sink.setActivity?.(null)) + } + return { admission: submit(plan, entry) } + } + + const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { + const journal = deps.sink.journalItems() + if (journal && !forecast.hydrated) { + forecast.hydrate(journal) + } + switch (event.type) { + case 'text.delta': + return applyProviderTimelineTextDelta(host, event) + case 'text.close': + return applyProviderTimelineTextClose(host, event) + case 'activity': { + const open = forecast.open + if (forecast.ended || !open) { + return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + } + deps.sink.setActivity?.( + event.text === null ? null : { turnId: open.turnId, text: event.text } + ) + return { admission: ADMITTED } + } + case 'turn.open': + return applyDecided( + event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event + ) + case 'provider.frame': + return applyDecided({ ...event, minted: context.joins.mint('f') }) + default: + return applyDecided(event) + } + } + + return { + apply, + get openTurnId() { + return forecast.open?.turnId ?? null + }, + flush: () => streams.flush(), + dispose: () => streams.dispose() + } +} 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 000000000000..864a193818fa --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -0,0 +1,66 @@ +// One admission budget for everything the assembler holds open: running items, pending requests +// and open text streams. It is derived, not booked: the open set is the forecast's obligations +// plus the open streams, one entry per item (an item and its stream share its join reference) and +// one per request key whatever its incarnation. Before refusing, the set is re-derived from the +// journal, so an answer a client gave or a row another writer 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 { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +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' + +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 + forecast: ProviderTimelineState + ledger: ProviderTimelineState + streams: readonly ProviderTimelineHold[] + journal: StructuredAgentSessionTransitionJournal | null +}): boolean { + if (fits(input)) { + return true + } + // The fold is never behind the ledger, so what it shows settled is settled in both. + for (const state of [input.forecast, input.ledger]) { + for (const [key, obligation] of state.obligations) { + const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null + if (body && !requiresTerminalSettlement(body)) { + state.obligations.delete(key) + } + } + } + return fits(input) +} + +function fits(input: { + hold: ProviderTimelineHold + forecast: ProviderTimelineState + streams: readonly ProviderTimelineHold[] +}): boolean { + const open = new Map() + for (const [key, obligation] of input.forecast.obligations) { + open.set(key, obligation.bytes) + } + for (const stream of input.streams) { + 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 000000000000..b451973b6710 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -0,0 +1,67 @@ +// What every planner and resolver of one assembler shares, and where a joined row goes. + +import type { + AgentJournalProducerLinkage, + AgentType +} from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineJoin } from './provider-timeline-event' +import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' + +export type ProviderTimelineContext = { + sessionId: string + agent: AgentType + joins: ProviderTimelineJoins + /** The truth decisions run on; resolvers only. */ + ledger: ProviderTimelineState + /** 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. */ + settlementId(what: string): string +} + +/** The ledger, with its open turn taken from the journal before its first decision runs. */ +export function providerTimelineLedger( + context: ProviderTimelineContext, + journal: StructuredAgentSessionTransitionJournal +): ProviderTimelineState { + if (!context.ledger.hydrated) { + context.ledger.hydrate(journal) + } + return context.ledger +} + +/** The turn a new row joins: the one the provider names (subagent work joins the open turn while + * keeping its own turn in its identity), else the open one, else none. */ +export function providerTimelinePlacement( + context: ProviderTimelineContext, + state: ProviderTimelineState, + join: ProviderTimelineJoin | undefined +): ProviderTimelinePlacement { + const thread = join?.thread ?? null + if (join?.turn === undefined) { + return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + } + const key = { source: 'provider', value: join.turn } as const + const own = context.ownThread?.() ?? null + const subagent = thread !== null && own !== null && thread !== own + return { + thread, + turn: key, + scope: subagent + ? state.scope + : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + } +} + +/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ +export function providerTimelineEntryBytes( + key: string, + body?: unknown, + producer?: AgentJournalProducerLinkage +): number { + const measure = (value: unknown) => + value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') + return Buffer.byteLength(key, 'utf8') + measure(body) + measure(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 000000000000..ae75f819f524 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -0,0 +1,102 @@ +// The shape of a decision on one non-text event, and the reads every rule shares. + +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 type { ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +export type ProviderTimelineDropRule = + | 'session-ended' + | 'turn-duplicate' + | 'turn-replayed' + | 'turn-unknown' + | 'no-turn' + | 'item-settled' + | 'item-replayed' + | 'request-duplicate' + | 'request-replayed' + | 'request-unknown' + | 'stream-unknown' + | 'stream-mismatch' + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ + +/** A non-text event with the keys planning minted for it, so execution names the same ones. */ +export type ProviderTimelineDecidedEvent = Exclude< + ProviderTimelineEvent, + { type: 'text.delta' | 'text.close' | 'activity' } +> & { minted?: ProviderTimelineKey } + +export type ProviderTimelineResolvedWrite = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options?: StructuredAgentSessionItemAppendOptions +} + +export type ProviderTimelineDecision = { + dropped?: ProviderTimelineDropRule + /** What the event would hold open, for the budget. */ + hold?: ProviderTimelineHold + /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + commit?: (state: ProviderTimelineState) => void + /** Execution only: the settlement step's mutations and the item step's write. */ + settle?: readonly JournalLifecycleMutationInput[] + write?: ProviderTimelineResolvedWrite | null + /** The item the event closes, whose stream it releases. */ + closes?: string +} + +export type ProviderTimelineDecisionInput = { + context: ProviderTimelineContext + state: ProviderTimelineState + journal: StructuredAgentSessionTransitionJournal | null + /** Execution: place rows and compute writes. Planning never does. */ + execute: boolean +} + +export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) + +export function settledTool(body: AgentJournalItemBody | null): boolean { + return body?.kind === 'tool-call' && body.state !== 'running' +} + +export function runningTool(body: AgentJournalItemBody): boolean { + return body.kind === 'tool-call' && body.state === 'running' +} + +export function pendingPrompt(body: AgentJournalItemBody | null): boolean { + return ( + (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' + ) +} + +export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { + return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null +} + +/** A turn's journal-derived settlement, at execution. */ +export function settlementOf( + input: ProviderTimelineDecisionInput, + turn: ProviderTimelineTurnRef, + end: ProviderTimelineTurnEnd +): readonly JournalLifecycleMutationInput[] | undefined { + if (!input.execute || !input.journal) { + return undefined + } + const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } + return providerTimelineSettlement(input.journal, scope, [turn], end) +} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts new file mode 100644 index 000000000000..7f03e7267c00 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts @@ -0,0 +1,76 @@ +// What each non-text event does: one rule set, run twice. +// +// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or +// not, and what it would hold open) without writing or allocating anything. Execution runs it +// again in the event's first resolver, on the ledger and the fold at that point in the journal's +// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. +// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — +// the journal's answer is the one that lands, and the ledger follows it. + +import { + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { + decideContextUsage, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' + +export type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDropRule +} from './provider-timeline-decision' + +export function decideProviderTimelineEvent( + input: ProviderTimelineDecisionInput, + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended && event.type !== 'session.reset') { + 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 '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.verdict, (state) => { + state.ended = true + state.endSession() + }) + case 'session.reset': + // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver + // finds nothing when an end already settled it. + return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { + state.reset(event.namespace) + if (input.execute) { + input.context.joins.reset(event.namespace) + } + }) + } +} 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 000000000000..1a1ca6ad1746 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -0,0 +1,128 @@ +// 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 +// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in +// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// +// Rules an adapter can rely on: +// - 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; the journal refuses a second settlement +// and a replayed open, even after a restart. +// - 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. +// - `dropped` is the verdict when the event was applied. The journal has the last word when the +// event's writes run: a replay the journal already holds writes nothing even if it was taken. +// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the +// provider's ids may repeat (a new process): its ids then name new rows. + +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.item`: the provider's id for its echo of the send, which takes that message's + * place among its turn's messages and writes no row (the send's own row is the bubble). */ + | { + type: 'input.accepted' + clientMessageId: string + requestedAt: number + join?: ProviderTimelineJoin & { item?: string } + } + /** 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. */ + | { + 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. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ + | ({ + type: 'item.open' + item: string + body: ProviderTimelineItemBody + outlivesTurn?: true + } & Produced & + Joined) + /** The item's whole current body. Content may be replaced; a settled tool never runs again. */ + | ({ 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. */ + | { 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 } + /** A new provider session began; its ids live in `namespace`. Work still open is settled first, + * as lost (`unverifiable`), since nothing ended it. */ + | { type: 'session.reset'; namespace: string } 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 000000000000..90200b66bae7 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-item-decisions.ts @@ -0,0 +1,196 @@ +// What item, request and frame events do, decided on the forecast and again on the ledger. + +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' +import { + pendingPrompt, + providerKey, + runningTool, + settledTool, + turnOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput +} from './provider-timeline-decision' +import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + +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 join: ProviderTimelineItemJoin = { + family: 'item', + key: providerKey(event.item), + thread: event.join?.thread ?? null + } + const ref = context.joins.reference(join, state.namespace) + const found = context.joins.find(join, journal, state.namespace) + const held = found ? (journal?.itemBody(found.itemId) ?? null) : null + // This run closed it: an open reopens the same row, a second close is a repeat. + const closed = state.closed.has(ref) + if (change === 'open' && !closed && held) { + return { dropped: 'item-replayed' } + } + if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + if (change === 'close' && (closed || settledTool(held))) { + return { dropped: 'item-settled' } + } + const known = state.obligations.get(ref) + const outlivesTurn = + (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true + const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + const row = + found ?? + (input.execute && journal + ? context.joins.place( + join, + providerTimelineItemClass(event.body), + providerTimelinePlacement(context, state, event.join), + journal + ) + : null) + const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const turnItemId = turnOf(scope) + const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + return { + ...(obligation ? { hold: { key: ref, bytes } } : {}), + write: row && { + identity: row.identity, + body: event.body, + options: { + ...event.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + }, + ...(change === 'close' ? { closes: ref } : {}), + commit: (next) => { + if (obligation) { + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + } else { + next.obligations.delete(ref) + } + if (change === 'close') { + next.closed.set(ref, turnItemId) + } else if (change === 'open') { + next.closed.delete(ref) + } + } + } +} + +export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { + return input.context.joins.reference( + { family: 'request', key, thread: null }, + input.state.namespace + ) +} + +export function decideRequest( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + const current = context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + // Pending in the journal, or opened here and not landed yet: the same request again. + if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + return { dropped: 'request-duplicate' } + } + const settledTurn = current ? turnOf(current.scope) : null + if ( + held && + settledTurn !== null && + state.status({ itemId: settledTurn }, journal) === 'settled' + ) { + return { dropped: 'request-replayed' } + } + const placement = providerTimelinePlacement(context, state, event.join) + const row = + input.execute && journal + ? context.joins.nextRequest(key, event.body.kind, placement, journal) + : null + const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + return { + hold: { key: ref, bytes }, + // A row already there is a client's answer or a replay; neither is overwritten. + write: + row && journal?.itemBody(row.itemId) === null + ? { + identity: row.identity, + body: event.body, + options: { ...event.producer, turnScope: row.scope, lifecycle: true } + } + : null, + commit: (next) => + next.obligations.set(ref, { + itemId: row?.itemId ?? null, + turnItemId: turnOf(row?.scope ?? placement.scope), + outlivesTurn: false, + bytes + }) + } +} + +export function decideWithdrawal( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const key = providerKey(event.request) + const ref = requestRef(input, key) + // The latest incarnation the journal holds, however long ago this run (or another) opened it. + const current = input.context.joins.request(key, journal, state.namespace) + const held = current ? (journal?.itemBody(current.itemId) ?? null) : null + if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + return { dropped: 'request-unknown' } + } + // Only while the journal holds it pending: a client's answer that landed first stands. + const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null + return { + ...(input.execute + ? { + settle: + current && cancelled + ? [ + { + kind: 'item' as const, + identity: current.identity, + body: cancelled, + turnScope: current.scope + } + ] + : [] + } + : {}), + commit: (next) => next.obligations.delete(ref) + } +} + +export function decideFrame( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (!frame || !event.minted || !input.execute || !journal) { + return {} + } + const row = context.joins.place( + { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + 'frame', + providerTimelinePlacement(context, state, event.join), + journal + ) + return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } +} 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 000000000000..44dec71dc576 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -0,0 +1,144 @@ +// One grammar event's journal writes, admitted as one sink transition. +// +// Planning lays out the writes the event may need and changes nothing. What each write says, and +// which row it lands on, is decided by its resolver when the transition reaches its turn in the +// journal's write queue; those resolvers are the only code that changes the assembler's ledger. +// `onAdmitted` work (the forecast, 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 { + StructuredAgentSessionTransitionJournal, + 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 readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] + private lifecycle = false + private refusal: StructuredAgentSessionSinkAdmission | null = null + + 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) + } + + /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ + atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { + this.executions.push(change) + } + + /** The event cannot be taken at all (over budget); nothing is submitted. */ + refuse(admission: StructuredAgentSessionSinkAdmission): void { + this.refusal ??= admission + } + + /** Whether submitting sends a transition (and so `landed` will be heard). */ + get writes(): boolean { + return this.steps.length > 0 || this.executions.length > 0 + } + + submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { + if (this.refusal) { + return this.refusal + } + if (this.executions.length > 0 && this.steps.length === 0) { + this.item({ + reservedBytes: 0, + resolve: () => null, + options: { turnScope: { kind: 'thread' } } + }) + } + if (this.steps.length > 0) { + const admission = sink.tryAppendTransition({ + steps: this.withExecutions(), + lifecycle: this.lifecycle, + publish: true, + ...(landed ? { landed: () => landed() } : {}) + }) + if (!admission.accepted) { + return admission + } + } + for (const change of this.admitted) { + change() + } + return ADMITTED + } + + private withExecutions(): StructuredAgentSessionTransitionStep[] { + const last = this.steps.at(-1) + if (!last || this.executions.length === 0) { + return this.steps + } + const executions = this.executions + const finish = (journal: StructuredAgentSessionTransitionJournal) => + executions.forEach((change) => change(journal)) + const wrapped: StructuredAgentSessionTransitionStep = + last.kind === 'item' + ? { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + : { + ...last, + resolve: (journal) => { + const resolved = last.resolve(journal) + finish(journal) + return resolved + } + } + return [...this.steps.slice(0, -1), wrapped] + } +} + +/** 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-settlement.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts new file mode 100644 index 000000000000..390160e99e1a --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -0,0 +1,147 @@ +// What a turn's end or the session's end settles, read from the journal when the settlement runs. +// +// The same mechanism the dead-generation settlement uses 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, a row a previous assembler opened is settled too, 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 { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +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' } + +/** What a row still open when its turn or the session ends becomes; null when it already stands. */ +export function settledProviderTimelineBody( + 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 +} + +/** 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. */ +export type ProviderTimelineSettlementScope = + | { turnItemId: string; outlivesTurn: ReadonlySet } + | 'session' + +/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +export function providerTimelineSettlement( + journal: StructuredAgentSessionTransitionJournal, + scope: ProviderTimelineSettlementScope, + turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], + end: ProviderTimelineTurnEnd +): 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.outlivesTurn.has(itemId)) + const settled = covered ? settledProviderTimelineBody(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 turns) { + const ended = endedTurnRow(journal, turn, end) + 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)), + 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 000000000000..d3daf3ef7056 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -0,0 +1,172 @@ +// What the assembler knows about the session, held twice. +// +// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at +// each event's turn in the journal's write queue, and everything in it is either a cache of the +// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the +// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue +// are expected to change; it only answers `apply` before those events land, and is rebuilt from +// the ledger as they do. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { BoundedMap } from '../../../shared/bounded-map' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + +/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the + * journal decides from the row alone. */ +const MAX_CLOSED_ITEMS = 512 +/** Turns remembered as settled without asking the journal. */ +const MAX_SETTLED_TURNS = 256 +/** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ +export const MAX_PENDING_INPUTS = 64 + +export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { + running: AgentJournalTurnLifecycle +} + +export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } + +/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending + * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +export type ProviderTimelineObligation = { + /** The row, once a resolver placed it. */ + itemId: string | null + turnItemId: string | null + outlivesTurn: boolean + bytes: number +} + +export type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' + +export class ProviderTimelineState { + ended = false + open: ProviderTimelineOpenTurn | null = null + latest: ProviderTimelineTurnRef | null = null + inputs: ProviderTimelinePendingInput[] = [] + /** Items this run closed, with the turn they closed in. */ + closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) + settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + obligations = new Map() + /** Whether the open turn was taken from the journal yet (ledger only). */ + hydrated = false + + constructor(public namespace: string) {} + + get scope(): AgentJournalTurnScope { + return this.open ? { kind: 'turn', turnItemId: this.open.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } + + clone(): ProviderTimelineState { + const copy = new ProviderTimelineState(this.namespace) + copy.ended = this.ended + copy.open = this.open && { ...this.open } + copy.latest = this.latest + copy.inputs = [...this.inputs] + for (const [key, value] of this.closed.entries()) { + copy.closed.set(key, value) + } + for (const [key, value] of this.settled.entries()) { + copy.settled.set(key, value) + } + copy.obligations = new Map(this.obligations) + copy.hydrated = this.hydrated + return copy + } + + status( + turn: { itemId: string }, + journal: StructuredAgentSessionTransitionJournal | null + ): ProviderTimelineTurnStatus { + if (this.open?.itemId === turn.itemId) { + return 'running' + } + if (this.settled.has(turn.itemId)) { + return 'settled' + } + const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) + return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + } + + /** The obligations a turn's end leaves open: the ones that outlive it. */ + outliving(turnItemId: string): Set { + const outliving = new Set() + for (const obligation of this.obligations.values()) { + if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { + outliving.add(obligation.itemId) + } + } + return outliving + } + + /** The turn is over, and so is everything its settlement covered. */ + endTurn(turn: ProviderTimelineTurnRef): void { + if (this.open?.itemId === turn.itemId) { + this.open = null + } + this.latest = turn + this.settled.set(turn.itemId, true) + for (const [key, obligation] of this.obligations) { + if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + this.obligations.delete(key) + } + } + // A settled turn's rows are judged by the turn now. + for (const [key, turnItemId] of this.closed.entries()) { + if (turnItemId === turn.itemId) { + this.closed.delete(key) + } + } + } + + /** The session is over: nothing it held is open any more. */ + endSession(): void { + this.open = null + this.obligations.clear() + this.closed.clear() + } + + reset(namespace: string): void { + this.namespace = namespace + this.ended = false + this.open = null + this.latest = null + this.inputs = [] + this.obligations.clear() + this.closed.clear() + this.settled.clear() + } + + /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the + * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ + hydrate(journal: StructuredAgentSessionTransitionJournal): void { + this.hydrated = true + if (this.open) { + return + } + let newest = -1 + journal.visitItems((itemId, sequence, body) => { + const running = readAgentJournalTurn(body) + const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null + if (!running || !identity || sequence < newest) { + return + } + newest = sequence + this.open = { + address: { + namespace: this.namespace, + key: { source: 'provider', value: running.turnId } + }, + identity, + itemId, + turnId: running.turnId, + running + } + }) + } +} 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 000000000000..8b793ef2139f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-events.ts @@ -0,0 +1,105 @@ +// Text events: planning-side streams, admitted like every other event. + +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' +import type { + ProviderTimelineApplyHost, + ProviderTimelineApplyResult, + ProviderTimelineInFlight +} from './provider-timeline-apply-host' +import { PROVIDER_TIMELINE_OVER_BUDGET } from './provider-timeline-budget' +import type { ProviderTimelineEvent } from './provider-timeline-event' +import { ProviderTimelinePlan } from './provider-timeline-plan' + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function applyProviderTimelineTextDelta( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const journal = host.journal() + const plan = new ProviderTimelinePlan() + let stream = streams.get(streams.key(event.item, event.join, forecast)) + 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 ('id' in event.item && settledNamedItem(host, event, journal)) { + return { admission: ADMITTED, dropped: 'item-settled' } + } + stream = streams.start({ + item: event.item, + join: event.join, + channel: event.channel, + producer: event.producer, + state: forecast + }) + if (!host.admits({ key: stream.key, bytes: stream.bytes })) { + return { admission: PROVIDER_TIMELINE_OVER_BUDGET } + } + } + streams.planAppend(plan, stream, event.text) + return { admission: host.submit(plan, { executed: false, commit: undefined }) } +} + +/** A named delta for an item this run closed, or one the journal holds in a settled turn. */ +function settledNamedItem( + host: ProviderTimelineApplyHost, + event: Extract, + journal: StructuredAgentSessionTransitionJournal | null +): boolean { + const { context } = host + const forecast = host.forecast() + if (!('id' in event.item)) { + return false + } + const join = { + family: 'item' as const, + key: { source: 'provider' as const, value: event.item.id }, + thread: event.join?.thread ?? null + } + if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { + return true + } + const row = context.joins.find(join, journal, forecast.namespace) + const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null + return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' +} + +export function applyProviderTimelineTextClose( + host: ProviderTimelineApplyHost, + event: Extract +): ProviderTimelineApplyResult { + const { streams } = host + const forecast = host.forecast() + if (forecast.ended) { + return { admission: ADMITTED, dropped: 'session-ended' } + } + const stream = streams.get(streams.key(event.item, event.join, forecast)) + if (!stream) { + return { admission: ADMITTED, dropped: 'stream-unknown' } + } + const plan = new ProviderTimelinePlan() + streams.planFlush(plan, stream) + streams.planClose(plan, stream, event.text) + const entry: ProviderTimelineInFlight = { + executed: false, + commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + } + plan.atExecution(() => { + entry.executed = true + }) + return { admission: host.submit(plan, entry) } +} 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 000000000000..90dc99135631 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -0,0 +1,360 @@ +// 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. +// +// Which row a stream writes is decided when its first write runs, like every other row: a +// provider-named message the journal already holds is a message this run is resuming (its turn +// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or +// this run closed it: nothing is written). + +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage +} 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 { + StructuredAgentSessionItemAppendOptions, + StructuredAgentSessionSinkAdmission +} from '../agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineEntryBytes, + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import type { + ProviderTimelineJoin, + ProviderTimelineTextChannel, + ProviderTimelineTextItem +} from './provider-timeline-event' +import { providerTimelineKeyPart } from './provider-timeline-identity' +import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' +import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import type { ProviderTimelineState } from './provider-timeline-state' + +/** One open text stream, as planning knows it. */ +export type ProviderTimelineStream = { + /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + id: string + /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + key: string + join: ProviderTimelineItemJoin + /** The turn the provider named, if any. */ + turn: string | undefined + /** The turn planning expects its row in, so a turn's end releases it. */ + turnItemId: string | null + named: boolean + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + /** Whether any text was written; a whitespace-only stream completes nothing. */ + written: boolean + bytes: number +} + +/** The row a stream writes, and the text it held before this run streamed into it. */ +type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' + +type Resolved = { + identity: ProviderTimelineRow['identity'] + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions +} + +export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineTextStreams { + private readonly streams = new Map() + private readonly byId = new Map() + /** Ledger: the row each stream writes, decided by its first write. */ + private readonly owned = new Map() + private readonly coalescer + private serial = 0 + + constructor( + private readonly deps: { + sink: ProviderTimelineSink + context: ProviderTimelineContext + generation: string + 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()] + } + + /** The stream slot `item` names on its thread. */ + key( + item: ProviderTimelineTextItem, + join: ProviderTimelineJoin | undefined, + state: ProviderTimelineState + ): string { + return 'id' in item + ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) + : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + } + + get(key: string): ProviderTimelineStream | undefined { + return this.streams.get(key) + } + + /** A new stream; the caller has checked the budget for its `bytes`. */ + start(input: { + item: ProviderTimelineTextItem + join: ProviderTimelineJoin | undefined + channel: ProviderTimelineTextChannel + producer: AgentJournalProducerLinkage | undefined + state: ProviderTimelineState + }): ProviderTimelineStream { + this.serial += 1 + const named = 'id' in input.item + const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + return { + id: `${this.deps.generation}:s${this.serial}`, + key: this.key(input.item, input.join, input.state), + join, + turn: input.join?.turn, + turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + named, + channel: input.channel, + producer: input.producer, + written: false, + bytes: providerTimelineEntryBytes( + 'id' in input.item ? input.item.id : input.item.stream, + undefined, + input.producer + ) + } + } + + /** 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) + } + } + } + + /** Ends the streams `which` selects; their text was flushed by the same event. */ + planRelease( + plan: ProviderTimelinePlan, + which: (stream: ProviderTimelineStream) => boolean + ): void { + const released = [...this.streams.values()].filter(which) + if (released.length === 0) { + return + } + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) + plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + } + + /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + 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 { + this.planText( + plan, + stream, + boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + true + ) + } + this.planRelease(plan, (each) => each === stream) + if (stream.named) { + // The close settles the item for full snapshots too. + plan.atExecution(() => { + const owned = this.owned.get(stream.id) + const row = owned && owned !== 'replayed' ? owned.row : null + this.deps.context.ledger.closed.set( + stream.key, + row?.scope.kind === 'turn' ? row.scope.turnItemId : null + ) + }) + } + } + + flush(): void { + this.coalescer.flushAll() + } + + dispose(): void { + this.open.forEach((stream) => this.forget(stream)) + this.owned.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: StructuredAgentSessionSinkAdmission = 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 placeholder = this.message(stream, text) + plan.item({ + // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. + reservedBytes: estimateStructuredAgentSessionItemBytes( + { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, + placeholder + ), + paced: true, + resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), + options: { ...stream.producer, turnScope: { kind: 'thread' } } + }) + plan.onAdmitted(() => { + stream.written = true + this.coalescer.markFlushed(stream.id) + }) + } + + /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + private resolveText( + stream: ProviderTimelineStream, + text: string, + providerFinal: boolean, + journal: StructuredAgentSessionTransitionJournal + ): Resolved | null { + const ledger = providerTimelineLedger(this.deps.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + const { row, prefix } = owned + return { + identity: row.identity, + body: this.message(stream, providerFinal ? text : `${prefix}${text}`), + options: { + ...stream.producer, + turnScope: row.scope, + ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + } + } + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.deps.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.deps.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { + return { + kind: 'message', + role: stream.channel === 'assistant' ? 'assistant' : 'reasoning', + blocks: [{ type: 'text', text }] + } + } + + private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { + family: 'item', + key: { source: 'provider', value: item.id }, + thread: join?.thread ?? null + } + } + + private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { + return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } + } + + 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 000000000000..726070438901 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts @@ -0,0 +1,120 @@ +// The writes one non-text event may need, laid out before anything is decided: the text owed +// ahead of it, then its fixed settlement and item slots, each resolved when it runs. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +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 type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision +} from './provider-timeline-decisions' +import type { ProviderTimelinePlan } from './provider-timeline-plan' + +/** Room for a settled turn row and its context facts. */ +const TURN_ROW_RESERVED_BYTES = 64 * 1024 +/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ +const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const + +/** 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. */ +export function planProviderTimelineBarrier( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decision: ProviderTimelineDecision +): void { + const { streams, context } = host + const forecast = host.forecast() + // 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' || event.type === 'session.reset') { + streams.planRelease(plan, () => true) + return + } + if (event.type === 'turn.end' || event.type === 'turn.open') { + const ending = + event.type === 'turn.end' && event.turn !== undefined + ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId + : forecast.open?.itemId + streams.planRelease( + plan, + (stream) => + (!stream.named && stream.producer?.agentId === undefined) || + (ending !== undefined && stream.turnItemId === 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) + } +} + +export function planProviderTimelineSlots( + host: ProviderTimelineApplyHost, + plan: ProviderTimelinePlan, + event: ProviderTimelineDecidedEvent, + decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null +): void { + const { context } = host + const settlement = (what: string) => + plan.settlement({ + settlementId: context.settlementId(what), + reservedBytes: TURN_ROW_RESERVED_BYTES, + resolve: (journal) => decide(journal)?.settle ?? [] + }) + const item = (reservedBytes: number, lifecycle: boolean) => + plan.item( + { + reservedBytes, + resolve: (journal) => decide(journal)?.write ?? null, + options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + }, + lifecycle + ) + switch (event.type) { + case 'turn.open': + settlement('turn-superseded') + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'turn.end': + settlement('turn-end') + return + case 'request.withdrawn': + settlement('request-withdrawn') + return + case 'session.ended': + settlement('session-end') + return + case 'session.reset': + settlement('session-reset') + return + case 'input.accepted': + case 'context.usage': + item(TURN_ROW_RESERVED_BYTES, true) + return + case 'item.open': + case 'item.update': + case 'item.close': + item( + estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), + event.type !== 'item.update' + ) + return + case 'request.open': + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) + return + case 'provider.frame': { + const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) + if (frame) { + item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) + } + } + } +} 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 000000000000..2ae960936e0c --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-turn-decisions.ts @@ -0,0 +1,235 @@ +// What turn, send, context and session events do, decided on the forecast and again on the ledger. + +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 { + providerKey, + settlementOf, + type ProviderTimelineDecidedEvent, + type ProviderTimelineDecision, + type ProviderTimelineDecisionInput, + type ProviderTimelineResolvedWrite +} from './provider-timeline-decision' +import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineSettlement, + runningProviderTimelineTurns, + type ProviderTimelineTurnEnd +} from './provider-timeline-settlement' +import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' + +export function decideTurnOpen( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, context } = input + const open = state.open + const key = event.turn === undefined ? event.minted : providerKey(event.turn) + if (!key || (event.turn === undefined && open)) { + return { dropped: 'turn-duplicate' } + } + const turn = context.joins.turn(key, state.namespace) + if (open?.itemId === turn.itemId) { + return { dropped: 'turn-duplicate' } + } + if (state.status(turn, input.journal) !== 'absent') { + return { dropped: 'turn-replayed' } + } + const pending = state.inputs[0] + const running: AgentJournalTurnLifecycle = { + turnId: turn.turnId, + state: 'running', + userItemId: pending + ? agentJournalSubmissionKey(pending.clientMessageId) + : context.joins.turnOpener(turn), + startedAt: event.at, + ...(pending ? { requestedAt: pending.requestedAt } : {}) + } + // A newer turn ended this one, whoever asked for it. + const superseded = open + ? settlementOf(input, open, { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + : [] + return { + ...(superseded ? { settle: superseded } : {}), + write: input.execute + ? { + identity: turn.identity, + body: agentJournalTurnBody(running), + // 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 } + } + : null, + commit: (next) => { + if (next.open) { + next.endTurn(next.open) + } + if (pending) { + next.inputs.shift() + } + next.open = { ...turn, running } + } + } +} + +export function decideTurnEnd( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state } = input + const turn = + event.turn === undefined + ? state.open + : input.context.joins.turn(providerKey(event.turn), state.namespace) + if (!turn) { + return { dropped: 'no-turn' } + } + if (state.status(turn, input.journal) !== 'running') { + return { dropped: 'turn-unknown' } + } + const settle = settlementOf(input, turn, { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } +} + +export function decideInput( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal, context } = input + const named = event.join?.turn + const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) + if (input.execute && journal && turnKey && event.join?.item !== undefined) { + context.joins.reserveEcho( + { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, + { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + journal + ) + } + const open = state.open + // A late echo of a turn already over names nothing. + if ( + named !== undefined && + context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId + ) { + return {} + } + const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } + if (!open) { + return { + commit: (next) => { + next.inputs.push(pending) + next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) + } + } + } + if (open.running.userItemId !== context.joins.turnOpener(open)) { + return {} + } + const userItemId = agentJournalSubmissionKey(event.clientMessageId) + const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + return { + write: + input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + commit: (next) => { + if (next.open?.itemId === open.itemId) { + next.open = { ...next.open, running } + } + } + } +} + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ + +/** Only the opener fields change; the rest are the row's as the journal holds it. */ +export function reviseOpener( + journal: StructuredAgentSessionTransitionJournal, + open: ProviderTimelineTurnRef, + userItemId: string, + requestedAt: number +): ProviderTimelineResolvedWrite | null { + const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) + if (row?.state !== 'running') { + return null + } + const target = { identity: open.identity } + const write = { + lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + onlyWhileRunning: true as const + } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return ( + resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } + ) +} + +export function decideSessionEnd( + input: ProviderTimelineDecisionInput, + end: ProviderTimelineTurnEnd, + commit: (state: ProviderTimelineState) => void +): ProviderTimelineDecision { + const { journal } = input + return { + ...(input.execute && journal + ? { + settle: providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + end + ) + } + : {}), + commit + } +} + +export function decideContextUsage( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { state, journal } = input + const named = event.join?.turn + const turn = + named === undefined + ? (state.open ?? state.latest) + : input.context.joins.turn(providerKey(named), state.namespace) + if (!input.execute || !journal) { + return {} + } + const target = turn ? { identity: turn.identity } : ({ newest: true } as const) + const write = { contextUsage: event.usage } + const resolved = resolveAgentJournalTurnRowWrite( + journal, + target, + write, + agentJournalTurnRowReservedBytes(target, write) + ) + return { + write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + } +} From 9408e432e69a150a7eb979cf4011be9b5e1d4ae1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:18 -0700 Subject: [PATCH 06/26] Fix the types and the exhaustive event switch CI reported for the assembler --- .../provider-timeline-assembler.ts | 13 +++++++++++-- .../provider-timeline-text-streams.ts | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) 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 index 480e40c88bac..4fcb4459190a 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -127,7 +127,7 @@ export function createProviderTimelineAssembler( const submit = ( plan: ProviderTimelinePlan, - entry: InFlight + entry: ProviderTimelineInFlight ): StructuredAgentSessionSinkAdmission => { if (!plan.writes) { return plan.submit(deps.sink) @@ -234,7 +234,16 @@ export function createProviderTimelineAssembler( ) case 'provider.frame': return applyDecided({ ...event, minted: context.joins.mint('f') }) - default: + case 'input.accepted': + case 'turn.end': + case 'item.open': + case 'item.update': + case 'item.close': + case 'request.open': + case 'request.withdrawn': + case 'context.usage': + case 'session.ended': + case 'session.reset': return applyDecided(event) } } 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 index 90dc99135631..27c663b4239b 100644 --- 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 @@ -134,7 +134,8 @@ export class ProviderTimelineTextStreams { }): ProviderTimelineStream { this.serial += 1 const named = 'id' in input.item - const join = named ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) + const join = + 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) return { id: `${this.deps.generation}:s${this.serial}`, From c491688b29ed80939476f99af40df40b34b3d22d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:15:06 -0700 Subject: [PATCH 07/26] 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. --- ...der-timeline-assembler-attribution.test.ts | 265 ++++++++++++++++++ .../provider-timeline-assembler-items.test.ts | 24 +- .../provider-timeline-assembler-order.test.ts | 19 +- ...ovider-timeline-assembler-recovery.test.ts | 8 +- ...rovider-timeline-assembler-streams.test.ts | 177 ++++++++++++ ...rovider-timeline-assembler-test-support.ts | 75 +++++ .../provider-timeline-assembler.ts | 8 +- .../provider-timeline-background-tasks.ts | 68 +++++ .../provider-timeline-context.ts | 28 +- .../provider-timeline-decision.ts | 3 +- .../provider-timeline-event.ts | 13 +- .../provider-timeline-item-decisions.ts | 50 ++-- .../provider-timeline-settlement.ts | 15 +- .../provider-timeline-state.ts | 78 ++++-- .../provider-timeline-stream-rows.ts | 168 +++++++++++ .../provider-timeline-text-streams.ts | 123 ++++---- .../provider-timeline-transition-layout.ts | 20 +- .../provider-timeline-turn-decisions.ts | 45 +-- 18 files changed, 997 insertions(+), 190 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-streams.test.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-stream-rows.ts 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 000000000000..92238e697090 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-attribution.test.ts @@ -0,0 +1,265 @@ +// 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 { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' +import { + assistantText, + backgroundTask, + backgroundTaskState, + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + SESSION, + 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('is a replay when it names the turn that is over', async () => { + const rig = await answeredInAnEarlierTurn() + expect( + rig.assembler.apply({ + type: 'request.open', + request: 'p1', + body: pendingApproval, + join: { turn: 't1' } + }).dropped + ).toBe('request-replayed') + expect(await rig.row(second)).toBeUndefined() + }) + + it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { + const rig = await answeredInAnEarlierTurn() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + const join = { turn: 't1' } + expect( + assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + ).toBeUndefined() + await bind() + expect(await rig.row(second)).toBeUndefined() + expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ + resolution: { state: 'resolved' } + }) + }) + + it('decides at execution: queued before bind, a new prompt in the live turn opens', 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 }) + await bind() + expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) + }) + + it('decides at execution: a turn another writer ended while it was queued takes no prompt', 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(second)).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 after a restart, 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') }) + await rig.rows() + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('working') + expect( + restarted.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( + restarted.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') + }) +}) + +describe('message places after a restart', () => { + it('continues past the highest place the journal holds, not the first gap', async () => { + const rig = await openProviderTimelineRig({ + scheme: createCodexProviderTimelineIdentityScheme({ + sessionId: SESSION, + primaryThreadId: () => 'root' + }), + ownThread: () => 'root' + }) + await rig.journal.appendItem( + { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, + assistantText('old'), + { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } + ) + const restarted = rig.restart({ generation: 'gen-2' }) + restarted.apply({ + type: 'item.close', + item: 'new', + body: assistantText('new'), + join: { thread: 'root', turn: 't1' } + }) + const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') + expect(placed?.itemId).toBe('codex:root:t1:3') + }) +}) 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 index 0172719fef48..7d6a12178a11 100644 --- 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 @@ -5,6 +5,8 @@ import type { } from '../../../shared/agent-session-journal-types' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, openProviderTimelineRig, providerItemId, @@ -133,15 +135,14 @@ describe('provider timeline items', () => { expect(rows[0]?.turnScope).toEqual({ kind: 'thread' }) }) - it('fails a tool call its turn left running, and keeps one that outlives the turn open', async () => { + 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: runningTool('background'), - outlivesTurn: true + body: backgroundTask('bg-1', 'working') }) rig.assembler.apply({ type: 'turn.end', @@ -152,19 +153,12 @@ describe('provider timeline items', () => { expect((await rig.row(providerItemId('item', 'call-a')))?.body).toMatchObject({ state: 'failed' }) - expect((await rig.row(providerItemId('item', 'bg-1')))?.body).toMatchObject({ - state: 'running' - }) + expect(await backgroundTaskState(rig, 'bg-1')).toBe('working') - // It settles on its own close later, still scoped to the turn that started it. - rig.assembler.apply({ - type: 'item.close', - item: 'bg-1', - body: { ...runningTool('background'), state: 'completed' } - }) - const background = await rig.row(providerItemId('item', 'bg-1')) - expect(background?.body).toMatchObject({ state: 'completed' }) - expect(background?.turnScope).toEqual({ + // 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') }) 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 index 3c8ee4949fdf..9c7be28bd349 100644 --- 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 @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from './provider-timeline-budget' import { assistantText, + backgroundTask, + backgroundTaskState, closeProviderTimelineRigs, messageText, openProviderTimelineRig, @@ -217,25 +219,16 @@ describe('explicit provider attribution', () => { }) }) - it('keeps an item in the turn it opened in when its close arrives after the next turn opened', async () => { + 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: runningTool('task'), - outlivesTurn: true - }) + 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: { ...runningTool('task'), state: 'completed' } - }) + 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(row?.body).toMatchObject({ state: 'completed' }) + expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) it('writes context facts onto the turn the provider names, not the open one', async () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts index d1bcda1b390c..b63560978336 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts @@ -10,6 +10,7 @@ import { createProviderTimelineAssembler } from './provider-timeline-assembler' import { AGENT, assistantText, + backgroundTask, closeProviderTimelineRigs, messageText, NAMESPACE, @@ -119,14 +120,13 @@ describe('a forgotten join finds its original row', () => { expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) }) - it('keeps a late tool close in the turn it opened in after its join was evicted', async () => { + it('keeps a late background task update in the turn it opened in after its join was evicted', 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: runningTool('background'), - outlivesTurn: true + body: backgroundTask('background-tool', 'working') }) rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) @@ -139,7 +139,7 @@ describe('a forgotten join finds its original row', () => { rig.assembler.apply({ type: 'item.close', item: 'background-tool', - body: { ...runningTool('background'), state: 'completed' } + body: backgroundTask('background-tool', 'done') }) expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ 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 000000000000..d2094d9a4399 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-streams.test.ts @@ -0,0 +1,177 @@ +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, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) + +/** A journal that already holds a settled `old` turn and a running `live` one. */ +async function oldAndLiveTurns(): Promise { + const rig = await openProviderTimelineRig() + rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + await rig.rows() + return rig +} + +async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { + return (await rig.rows()) + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) +} + +describe('a stream follows the row it resolved to, not the one planning expected', () => { + it('stops at the end of the turn its row landed in, though planning expected another', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind, drained } = openUnboundProviderTimelineAssembler(rig.journal) + // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + await bind() + assembler.flush() + await drained() + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + + assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) + await drained() + expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.flush() + await drained() + 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('a message boundary is the journal decision', () => { + it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Hel' + }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'lo' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + }) + + it('still splits an anonymous message at a turn the journal really opened', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'One' + }) + assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) + assembler.apply({ + type: 'text.delta', + item: { stream: 'reply' }, + channel: 'assistant', + text: 'Two' + }) + assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) + await bind() + expect(await messages(rig)).toEqual(['One', 'Two']) + }) + + it('resumes a named message after a queued open the journal held as a replay', async () => { + const rig = await oldAndLiveTurns() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) + assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) + assembler.apply({ type: 'text.close', item: { id: 'm' } }) + await bind() + expect(await messages(rig)).toEqual(['Hello']) + expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId('live') + }) + }) +}) + +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 index 0b1d20746c58..dbcacbe9e1dc 100644 --- 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 @@ -14,6 +14,11 @@ import type { 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 { @@ -75,6 +80,35 @@ 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 }] } } @@ -104,6 +138,47 @@ export async function closeProviderTimelineRigs(): Promise { 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: it plans 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: () => deferred.drained() + } +} + export type ProviderTimelineRig = { journal: AgentSessionJournal assembler: ProviderTimelineAssembler 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 index 4fcb4459190a..af1918c24b1c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -180,9 +180,11 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier(host, plan, event, decision) const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } let executed: ProviderTimelineDecision | null = null + // A message boundary is the event's only when its own decision, at execution, wrote. + const landed = () => executed !== null && !executed.dropped && executed.write !== null + planProviderTimelineBarrier(host, plan, event, decision, landed) // The event's decision on the ledger, taken once, by its first slot to run. const decide = (at: StructuredAgentSessionTransitionJournal) => { if (!executed) { @@ -210,8 +212,8 @@ export function createProviderTimelineAssembler( const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() - if (journal && !forecast.hydrated) { - forecast.hydrate(journal) + if (journal) { + forecast.reconcile(journal) } switch (event.type) { case 'text.delta': 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 000000000000..3b4d004160fd --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-background-tasks.ts @@ -0,0 +1,68 @@ +// 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 { + backgroundTaskFallbackText, + canReplaceBackgroundTaskState, + isSettledBackgroundTaskState +} from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' + +/** The row once the host lost the session that ran its tasks: every task still in flight is + * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when + * no task in it is in flight. */ +export function lostProviderTimelineBackgroundTasks( + body: AgentJournalItemBody +): AgentJournalItemBody | null { + if (body.kind !== 'message') { + return null + } + const restated = new Map() + const blocks = body.blocks.map((block) => { + if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { + return block + } + const lost = { ...block, state: 'unverifiable' as const } + restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) + return lost + }) + if (restated.size === 0) { + return null + } + return { + ...body, + blocks: blocks.map((block) => + block.type === 'text' && restated.has(block.text) + ? { ...block, text: restated.get(block.text) ?? block.text } + : block + ) + } +} + +/** 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-context.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts index b451973b6710..16cdee07da9e 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -21,14 +21,13 @@ export type ProviderTimelineContext = { settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs. */ +/** The ledger, with its open turn taken from the journal before its first decision runs, and + * ended when another writer of the journal ended it. */ export function providerTimelineLedger( context: ProviderTimelineContext, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineState { - if (!context.ledger.hydrated) { - context.ledger.hydrate(journal) - } + context.ledger.reconcile(journal) return context.ledger } @@ -55,13 +54,20 @@ export function providerTimelinePlacement( } } -/** What an entry the assembler holds open costs: every provider string it keeps, and its body. */ -export function providerTimelineEntryBytes( - key: string, - body?: unknown, - producer?: AgentJournalProducerLinkage -): number { +/** 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') - return Buffer.byteLength(key, 'utf8') + measure(body) + measure(producer) + 64 + 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 index ae75f819f524..ee13faf44a36 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -97,6 +97,5 @@ export function settlementOf( if (!input.execute || !input.journal) { return undefined } - const scope = { turnItemId: turn.itemId, outlivesTurn: input.state.outliving(turn.itemId) } - return providerTimelineSettlement(input.journal, scope, [turn], end) + return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } 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 index 1a1ca6ad1746..b3f01ff9f7e4 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -87,14 +87,11 @@ export type ProviderTimelineEvent = /** The provider's own measured duration. */ durationMs?: number } - /** Work began. `outlivesTurn` keeps it open past its turn's end (a backgrounded task). */ - | ({ - type: 'item.open' - item: string - body: ProviderTimelineItemBody - outlivesTurn?: true - } & Produced & - Joined) + /** 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 never runs again. */ | ({ type: 'item.update'; item: string; body: ProviderTimelineItemBody } & Produced & Joined) /** The item's whole terminal body; it replaces any text streamed into the same item. */ 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 index 90200b66bae7..d5eb0ffb62ca 100644 --- 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 @@ -3,6 +3,7 @@ import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import { unhandledProviderFrameJournalItem } from '../agent-session-wire/unhandled-provider-frame' +import { relightsProviderTimelineBackgroundTask } from './provider-timeline-background-tasks' import { providerTimelineEntryBytes, providerTimelinePlacement } from './provider-timeline-context' import { pendingPrompt, @@ -43,23 +44,24 @@ export function decideItem( if (change === 'close' && (closed || settledTool(held))) { return { dropped: 'item-settled' } } - const known = state.obligations.get(ref) - const outlivesTurn = - (event.type === 'item.open' && event.outlivesTurn === true) || known?.outlivesTurn === true - const obligation = change !== 'close' && (requiresTerminalSettlement(event.body) || outlivesTurn) + if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + return { dropped: 'item-settled' } + } + const obligation = change !== 'close' && requiresTerminalSettlement(event.body) + const placement = providerTimelinePlacement(context, state, event.join) const row = found ?? (input.execute && journal - ? context.joins.place( - join, - providerTimelineItemClass(event.body), - providerTimelinePlacement(context, state, event.join), - journal - ) + ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) : null) - const scope = row?.scope ?? providerTimelinePlacement(context, state, event.join).scope + const scope = row?.scope ?? placement.scope const turnItemId = turnOf(scope) - const bytes = providerTimelineEntryBytes(event.item, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.item, + join: event.join, + body: event.body, + producer: event.producer + }) return { ...(obligation ? { hold: { key: ref, bytes } } : {}), write: row && { @@ -74,7 +76,7 @@ export function decideItem( ...(change === 'close' ? { closes: ref } : {}), commit: (next) => { if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, outlivesTurn, bytes }) + next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) } else { next.obligations.delete(ref) } @@ -107,20 +109,31 @@ export function decideRequest( if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { return { dropped: 'request-duplicate' } } - const settledTurn = current ? turnOf(current.scope) : null + // The turn this open lands in decides whether it repeats its predecessor: the same key again + // in a turn that is over is that turn's replay; in another, live turn it is a new request. + const placement = providerTimelinePlacement(context, state, event.join) + const target = turnOf(placement.scope) + const previous = current ? turnOf(current.scope) : null if ( held && - settledTurn !== null && - state.status({ itemId: settledTurn }, journal) === 'settled' + previous !== null && + state.status({ itemId: previous }, journal) === 'settled' && + (target === null || + target === previous || + state.status({ itemId: target }, journal) === 'settled') ) { return { dropped: 'request-replayed' } } - const placement = providerTimelinePlacement(context, state, event.join) const row = input.execute && journal ? context.joins.nextRequest(key, event.body.kind, placement, journal) : null - const bytes = providerTimelineEntryBytes(event.request, event.body, event.producer) + const bytes = providerTimelineEntryBytes({ + key: event.request, + join: event.join, + body: event.body, + producer: event.producer + }) return { hold: { key: ref, bytes }, // A row already there is a client's answer or a replay; neither is overwritten. @@ -136,7 +149,6 @@ export function decideRequest( next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId: turnOf(row?.scope ?? placement.scope), - outlivesTurn: false, bytes }) } 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 index 390160e99e1a..dd4e92166551 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -20,6 +20,7 @@ import { import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -74,9 +75,7 @@ export function endedProviderTimelineTurn( } /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = - | { turnItemId: string; outlivesTurn: ReadonlySet } - | 'session' +export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' /** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ export function providerTimelineSettlement( @@ -90,10 +89,12 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && - turnScope.turnItemId === scope.turnItemId && - !scope.outlivesTurn.has(itemId)) - const settled = covered ? settledProviderTimelineBody(body) : null + (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + // Background tasks outlive turns; only the session's end leaves them past seeing. + const settled = !covered + ? null + : (settledProviderTimelineBody(body) ?? + (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { // No linkage: a revision keeps the row's own producer. 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 index d3daf3ef7056..4c1a6bde83c3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -24,21 +24,31 @@ const MAX_CLOSED_ITEMS = 512 /** Turns remembered as settled without asking the journal. */ const MAX_SETTLED_TURNS = 256 /** Sends waiting for a turn; a provider that never opens one cannot grow this without bound. */ -export const MAX_PENDING_INPUTS = 64 +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 } -export type ProviderTimelinePendingInput = { clientMessageId: string; requestedAt: number } +export type ProviderTimelinePendingInput = { + clientMessageId: string + requestedAt: number + /** The turn row the provider said the send opens; absent: the next turn to open. */ + turnItemId?: string +} /** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null + /** The turn whose end settles it; null for a row in no turn. */ turnItemId: string | null - outlivesTurn: boolean bytes: number } @@ -50,8 +60,16 @@ export class ProviderTimelineState { latest: ProviderTimelineTurnRef | null = null inputs: ProviderTimelinePendingInput[] = [] /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ maxEntries: MAX_CLOSED_ITEMS }) - settled = new BoundedMap({ maxEntries: MAX_SETTLED_TURNS }) + closed = new BoundedMap({ + maxEntries: MAX_CLOSED_ITEMS, + maxBytes: MAX_CLOSED_ITEMS * 1024, + sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 + }) + settled = new BoundedMap({ + maxEntries: MAX_SETTLED_TURNS, + maxBytes: MAX_SETTLED_TURNS * 1024, + sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 + }) obligations = new Map() /** Whether the open turn was taken from the journal yet (ledger only). */ hydrated = false @@ -79,29 +97,55 @@ export class ProviderTimelineState { return copy } + /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). + * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ status( turn: { itemId: string }, journal: StructuredAgentSessionTransitionJournal | null ): ProviderTimelineTurnStatus { - if (this.open?.itemId === turn.itemId) { - return 'running' - } if (this.settled.has(turn.itemId)) { return 'settled' } const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - return row === null ? 'absent' : row.state === 'running' ? 'running' : 'settled' + if (row) { + return row.state === 'running' ? 'running' : 'settled' + } + return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** The obligations a turn's end leaves open: the ones that outlive it. */ - outliving(turnItemId: string): Set { - const outliving = new Set() - for (const obligation of this.obligations.values()) { - if (obligation.outlivesTurn && obligation.itemId && obligation.turnItemId === turnItemId) { - outliving.add(obligation.itemId) + /** Ends the open turn when the journal holds it settled: another writer ended it. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (!this.hydrated) { + this.hydrate(journal) + } + const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) + if (this.open && row && row.state !== 'running') { + this.endTurn(this.open) + } + } + + /** A send 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.clientMessageId) + 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) } - return outliving + } + + /** The send 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) + ) } /** The turn is over, and so is everything its settlement covered. */ @@ -112,7 +156,7 @@ export class ProviderTimelineState { this.latest = turn this.settled.set(turn.itemId, true) for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId && !obligation.outlivesTurn) { + if (obligation.turnItemId === turn.itemId) { this.obligations.delete(key) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts new file mode 100644 index 000000000000..7c49dc89e790 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -0,0 +1,168 @@ +// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. +// +// A stream's row is decided by its first write, like every other row: a provider-named message +// the journal already holds is one this run resumes (its turn still running: the stream adopts the +// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is +// written). An anonymous stream continues the row of the one before it when the event that ended +// that one turned out a replay. Every later write checks again that the row is not over, against +// the journal, so a turn another writer ended stops the stream there too. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +import { + providerTimelineLedger, + providerTimelinePlacement, + type ProviderTimelineContext +} from './provider-timeline-context' +import { turnOf } from './provider-timeline-decision' +import type { ProviderTimelineRow } from './provider-timeline-joins' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineStream } from './provider-timeline-text-streams' + +/** The row a stream writes, and the text it held before this run streamed into it. */ +export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } + +type Owned = ProviderTimelineStreamRow | 'replayed' + +function providerTimelineMessageText(body: AgentJournalItemBody | null): string { + return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' +} + +export class ProviderTimelineStreamRows { + private readonly owned = new Map() + + constructor( + private readonly context: ProviderTimelineContext, + /** Drops a stream planning still holds open, so its next delta starts another. */ + private readonly forget: (stream: ProviderTimelineStream) => void + ) {} + + /** At a write: the row it lands on, or null when it writes nothing. */ + resolve( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): ProviderTimelineStreamRow | null { + const ledger = providerTimelineLedger(this.context, journal) + let owned = this.owned.get(stream.id) + if (!owned) { + owned = this.own(stream, ledger, journal) + this.owned.set(stream.id, owned) + } + if (owned === 'replayed' || ledger.ended) { + return null + } + // 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. + if (this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + return null + } + return owned + } + + /** The event that released `stream` ran: `held` when it really ended the stream's message. */ + release(stream: ProviderTimelineStream, held: boolean): void { + stream.boundary = held ? 'held' : 'void' + // A named stream's next delta finds its row again through the journal; an anonymous one is + // kept for the stream that follows it. + if (held || stream.named) { + this.owned.delete(stream.id) + } + } + + /** Stops every stream whose row is over now. */ + retire( + streams: readonly ProviderTimelineStream[], + journal: StructuredAgentSessionTransitionJournal + ): void { + const ledger = providerTimelineLedger(this.context, journal) + for (const stream of streams) { + const owned = this.owned.get(stream.id) + if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { + this.stop(stream) + } + } + } + + /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ + turn(stream: ProviderTimelineStream): string | null { + const owned = this.owned.get(stream.id) + return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId + } + + clear(): void { + this.owned.clear() + } + + private own( + stream: ProviderTimelineStream, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): Owned { + const { joins } = this.context + if (stream.named && ledger.closed.has(stream.key)) { + return 'replayed' + } + const carried = this.carried(stream, journal) + if (carried) { + return carried + } + const found = stream.named ? joins.find(stream.join, journal) : null + if (found) { + const turnItemId = turnOf(found.scope) + if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { + return 'replayed' + } + return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } + } + const placement = providerTimelinePlacement(this.context, ledger, { + ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), + ...(stream.turn === undefined ? {} : { turn: stream.turn }) + }) + const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' + return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } + } + + /** The row of the message this one continues: an earlier anonymous stream whose release the + * journal did not take (its event was a replay). */ + private carried( + stream: ProviderTimelineStream, + journal: StructuredAgentSessionTransitionJournal + ): Owned | null { + let previous = stream.follows + delete stream.follows + for (; previous?.boundary === 'void'; previous = previous.follows) { + const owned = this.owned.get(previous.id) + if (owned) { + this.owned.delete(previous.id) + return owned === 'replayed' + ? owned + : { + row: owned.row, + prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) + } + } + } + return null + } + + /** Whether the row is past streaming into: its item closed, or its turn settled. */ + private over( + stream: ProviderTimelineStream, + row: ProviderTimelineRow, + ledger: ProviderTimelineState, + journal: StructuredAgentSessionTransitionJournal + ): boolean { + const turn = turnOf(row.scope) + return ( + (stream.named && ledger.closed.has(stream.key)) || + (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') + ) + } + + /** The stream writes nothing more; a later delta under its key starts another. */ + private stop(stream: ProviderTimelineStream): void { + this.owned.set(stream.id, 'replayed') + this.forget(stream) + } +} 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 index 27c663b4239b..3d18328b9fa2 100644 --- 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 @@ -6,11 +6,7 @@ // 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. -// -// Which row a stream writes is decided when its first write runs, like every other row: a -// provider-named message the journal already holds is a message this run is resuming (its turn -// still running: the stream adopts the row's text as its prefix) or a replay (its turn settled, or -// this run closed it: nothing is written). +// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). import type { AgentJournalItemBody, @@ -32,7 +28,6 @@ import type { import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineEntryBytes, - providerTimelineLedger, providerTimelinePlacement, type ProviderTimelineContext } from './provider-timeline-context' @@ -44,6 +39,7 @@ import type { import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' import type { ProviderTimelineState } from './provider-timeline-state' /** One open text stream, as planning knows it. */ @@ -63,26 +59,25 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number + /** The anonymous stream an event released just before this one began: this one continues its + * row unless that event's boundary landed. */ + follows?: ProviderTimelineStream + /** Execution: whether the event that released this stream ended its message. */ + boundary?: 'held' | 'void' } -/** The row a stream writes, and the text it held before this run streamed into it. */ -type Owned = { row: ProviderTimelineRow; prefix: string } | 'replayed' - type Resolved = { identity: ProviderTimelineRow['identity'] body: AgentJournalItemBody options: StructuredAgentSessionItemAppendOptions } -export function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - /** Ledger: the row each stream writes, decided by its first write. */ - private readonly owned = new Map() + private readonly rows: ProviderTimelineStreamRows + /** Per anonymous slot, the stream a boundary not yet decided released last. */ + private readonly released = new Map() private readonly coalescer private serial = 0 @@ -95,6 +90,7 @@ export class ProviderTimelineTextStreams { schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { + this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -137,9 +133,13 @@ export class ProviderTimelineTextStreams { const join = 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) + const key = this.key(input.item, input.join, input.state) + const follows = named ? undefined : this.released.get(key) + this.released.delete(key) return { id: `${this.deps.generation}:s${this.serial}`, - key: this.key(input.item, input.join, input.state), + key, + ...(follows ? { follows } : {}), join, turn: input.join?.turn, turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, @@ -147,11 +147,11 @@ export class ProviderTimelineTextStreams { channel: input.channel, producer: input.producer, written: false, - bytes: providerTimelineEntryBytes( - 'id' in input.item ? input.item.id : input.item.stream, - undefined, - input.producer - ) + bytes: providerTimelineEntryBytes({ + key: 'id' in input.item ? input.item.id : input.item.stream, + join: input.join, + producer: input.producer + }) } } @@ -185,17 +185,40 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. */ + /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts + * the next message at once; `landed`, asked at execution, says whether the event really ended + * them: when it did not (a replay the journal held), the next anonymous message continues + * this one's row, and a named stream's next delta resumes its row from the journal. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean + which: (stream: ProviderTimelineStream) => boolean, + landed?: () => boolean ): void { const released = [...this.streams.values()].filter(which) if (released.length === 0) { return } - plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) - plan.atExecution(() => released.forEach((stream) => this.owned.delete(stream.id))) + plan.onAdmitted(() => + released.forEach((stream) => { + this.forget(stream) + if (landed && !stream.named) { + this.released.set(stream.key, stream) + } + }) + ) + plan.atExecution(() => + released.forEach((stream) => this.rows.release(stream, !landed || landed())) + ) + } + + /** At a turn's or the session's end: every stream whose row is over now stops. */ + planRetire(plan: ProviderTimelinePlan): void { + plan.atExecution((journal) => this.rows.retire(this.open, journal)) + } + + /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ + rowTurn(stream: ProviderTimelineStream): string | null { + return this.rows.turn(stream) } /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ @@ -213,18 +236,13 @@ export class ProviderTimelineTextStreams { true ) } - this.planRelease(plan, (each) => each === stream) if (stream.named) { - // The close settles the item for full snapshots too. - plan.atExecution(() => { - const owned = this.owned.get(stream.id) - const row = owned && owned !== 'replayed' ? owned.row : null - this.deps.context.ledger.closed.set( - stream.key, - row?.scope.kind === 'turn' ? row.scope.turnItemId : null - ) - }) + // The close settles the item for full snapshots too, in the turn its row is in. + plan.atExecution(() => + this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) + ) } + this.planRelease(plan, (each) => each === stream) } flush(): void { @@ -233,7 +251,8 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.owned.clear() + this.rows.clear() + this.released.clear() this.coalescer.dispose() } @@ -285,13 +304,8 @@ export class ProviderTimelineTextStreams { providerFinal: boolean, journal: StructuredAgentSessionTransitionJournal ): Resolved | null { - const ledger = providerTimelineLedger(this.deps.context, journal) - let owned = this.owned.get(stream.id) + const owned = this.rows.resolve(stream, journal) if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { return null } const { row, prefix } = owned @@ -306,31 +320,6 @@ export class ProviderTimelineTextStreams { } } - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.deps.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = found.scope.kind === 'turn' ? found.scope.turnItemId : null - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.deps.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { return { kind: 'message', 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 index 726070438901..35fd7abc14b5 100644 --- 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 @@ -23,7 +23,9 @@ export function planProviderTimelineBarrier( host: ProviderTimelineApplyHost, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision + decision: ProviderTimelineDecision, + /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ + landed: () => boolean ): void { const { streams, context } = host const forecast = host.forecast() @@ -31,10 +33,11 @@ export function planProviderTimelineBarrier( const replaced = decision.closes ? streams.get(decision.closes) : undefined streams.planFlush(plan, replaced) if (replaced) { - streams.planRelease(plan, (stream) => stream === replaced) + streams.planRelease(plan, (stream) => stream === replaced, landed) } if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true) + streams.planRelease(plan, () => true, landed) + streams.planRetire(plan) return } if (event.type === 'turn.end' || event.type === 'turn.open') { @@ -46,13 +49,20 @@ export function planProviderTimelineBarrier( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && stream.turnItemId === ending) + (ending !== undefined && streams.rowTurn(stream) === ending), + landed ) + // The turn that really ended is the journal's: its streams stop wherever planning placed them. + streams.planRetire(plan) 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) + streams.planRelease( + plan, + (stream) => !stream.named && stream.producer?.agentId === agentId, + landed + ) } } 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 index 2ae960936e0c..a6ee99b15bf1 100644 --- 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 @@ -22,13 +22,14 @@ import { type ProviderTimelineDecisionInput, type ProviderTimelineResolvedWrite } from './provider-timeline-decision' +import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { providerTimelineSettlement, runningProviderTimelineTurns, type ProviderTimelineTurnEnd } from './provider-timeline-settlement' -import { MAX_PENDING_INPUTS, type ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineState } from './provider-timeline-state' export function decideTurnOpen( input: ProviderTimelineDecisionInput, @@ -47,7 +48,7 @@ export function decideTurnOpen( if (state.status(turn, input.journal) !== 'absent') { return { dropped: 'turn-replayed' } } - const pending = state.inputs[0] + const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', @@ -80,7 +81,9 @@ export function decideTurnOpen( next.endTurn(next.open) } if (pending) { - next.inputs.shift() + next.inputs = next.inputs.filter( + (input) => input.clientMessageId !== pending.clientMessageId + ) } next.open = { ...turn, running } } @@ -117,32 +120,38 @@ export function decideInput( ): ProviderTimelineDecision { const { state, journal, context } = input const named = event.join?.turn - const turnKey = named === undefined ? (state.open?.address.key ?? null) : providerKey(named) - if (input.execute && journal && turnKey && event.join?.item !== undefined) { + const placement = providerTimelinePlacement(context, state, event.join) + if (input.execute && journal && placement.turn && event.join?.item !== undefined) { context.joins.reserveEcho( { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - { thread: event.join.thread ?? null, turn: turnKey, scope: state.scope }, + placement, journal ) } const open = state.open + const namedTurn = + named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) + const status = namedTurn ? state.status(namedTurn, journal) : null // A late echo of a turn already over names nothing. - if ( - named !== undefined && - context.joins.turn(providerKey(named), state.namespace).itemId !== open?.itemId - ) { + if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { return {} } - const pending = { clientMessageId: event.clientMessageId, requestedAt: event.requestedAt } - if (!open) { + const pending = { + clientMessageId: event.clientMessageId, + requestedAt: event.requestedAt, + ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) + } + // No turn open, or the send names one still to open: it waits for that turn. + if (!open || pending.turnItemId !== undefined) { return { - commit: (next) => { - next.inputs.push(pending) - next.inputs.splice(0, Math.max(0, next.inputs.length - MAX_PENDING_INPUTS)) - } + commit: (next) => next.wait(pending) } } - if (open.running.userItemId !== context.joins.turnOpener(open)) { + // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. + const opener = + readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + open.running.userItemId + if (opener !== context.joins.turnOpener(open)) { return {} } const userItemId = agentJournalSubmissionKey(event.clientMessageId) @@ -158,8 +167,6 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ - /** Only the opener fields change; the rest are the row's as the journal holds it. */ export function reviseOpener( journal: StructuredAgentSessionTransitionJournal, From a9dbaaa0ee0da42841c8f5a631c78c048d14dfc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:19:45 -0700 Subject: [PATCH 08/26] Type the unbound assembler helper's drain as the void it reports --- .../provider-timeline-assembler-test-support.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index dbcacbe9e1dc..c7e66edb98bb 100644 --- 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 @@ -175,7 +175,9 @@ export function openUnboundProviderTimelineAssembler( deferred.bind({ journal, fence: 1, publish: () => {} }) await deferred.drained() }, - drained: () => deferred.drained() + drained: async () => { + await deferred.drained() + } } } From 50c93fb9f3c973e1cbf50e5af62c642c9654fca9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:28:22 -0700 Subject: [PATCH 09/26] 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. --- .../provider-timeline-state.ts | 4 +- .../provider-timeline-stream-rows.ts | 49 +++++++++++++------ .../provider-timeline-text-streams.ts | 3 +- 3 files changed, 37 insertions(+), 19 deletions(-) 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 index 4c1a6bde83c3..a74fd943bcdd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -42,8 +42,8 @@ export type ProviderTimelinePendingInput = { turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool, a backgrounded task, a pending - * request. Keyed by the item's join reference (a request's by its key, whatever its incarnation). */ +/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by + * the item's join reference (a request's by its key, whatever its incarnation). */ export type ProviderTimelineObligation = { /** The row, once a resolver placed it. */ itemId: string | null diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts index 7c49dc89e790..b362062b25ca 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts @@ -8,6 +8,7 @@ // the journal, so a turn another writer ended stops the stream there too. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { BoundedMap } from '../../../shared/bounded-map' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { providerTimelineLedger, @@ -24,12 +25,22 @@ export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: stri type Owned = ProviderTimelineStreamRow | 'replayed' +/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight + * whose queued writes must still write nothing. */ +const MAX_CARRIED_ROWS = 64 +const MAX_STOPPED_STREAMS = 512 + function providerTimelineMessageText(body: AgentJournalItemBody | null): string { return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' } export class ProviderTimelineStreamRows { + /** The streams planning still holds open. */ private readonly owned = new Map() + private readonly carried = new BoundedMap({ + maxEntries: MAX_CARRIED_ROWS + }) + private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) constructor( private readonly context: ProviderTimelineContext, @@ -42,6 +53,9 @@ export class ProviderTimelineStreamRows { stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineStreamRow | null { + if (this.stopped.has(stream.id)) { + return null + } const ledger = providerTimelineLedger(this.context, journal) let owned = this.owned.get(stream.id) if (!owned) { @@ -63,10 +77,12 @@ export class ProviderTimelineStreamRows { /** The event that released `stream` ran: `held` when it really ended the stream's message. */ release(stream: ProviderTimelineStream, held: boolean): void { stream.boundary = held ? 'held' : 'void' - // A named stream's next delta finds its row again through the journal; an anonymous one is - // kept for the stream that follows it. - if (held || stream.named) { - this.owned.delete(stream.id) + const owned = this.owned.get(stream.id) + this.owned.delete(stream.id) + // A named stream's next delta finds its row again through the journal; an anonymous one's row + // is kept for the stream that follows it. + if (!held && !stream.named && owned && owned !== 'replayed') { + this.carried.set(stream.id, owned) } } @@ -92,6 +108,8 @@ export class ProviderTimelineStreamRows { clear(): void { this.owned.clear() + this.carried.clear() + this.stopped.clear() } private own( @@ -103,7 +121,7 @@ export class ProviderTimelineStreamRows { if (stream.named && ledger.closed.has(stream.key)) { return 'replayed' } - const carried = this.carried(stream, journal) + const carried = this.continued(stream, journal) if (carried) { return carried } @@ -125,22 +143,20 @@ export class ProviderTimelineStreamRows { /** The row of the message this one continues: an earlier anonymous stream whose release the * journal did not take (its event was a replay). */ - private carried( + private continued( stream: ProviderTimelineStream, journal: StructuredAgentSessionTransitionJournal ): Owned | null { let previous = stream.follows delete stream.follows for (; previous?.boundary === 'void'; previous = previous.follows) { - const owned = this.owned.get(previous.id) - if (owned) { - this.owned.delete(previous.id) - return owned === 'replayed' - ? owned - : { - row: owned.row, - prefix: providerTimelineMessageText(journal.itemBody(owned.row.itemId)) - } + const kept = this.carried.get(previous.id) + if (kept) { + this.carried.delete(previous.id) + return { + row: kept.row, + prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) + } } } return null @@ -162,7 +178,8 @@ export class ProviderTimelineStreamRows { /** The stream writes nothing more; a later delta under its key starts another. */ private stop(stream: ProviderTimelineStream): void { - this.owned.set(stream.id, 'replayed') + this.owned.delete(stream.id) + this.stopped.set(stream.id, true) this.forget(stream) } } 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 index 3d18328b9fa2..1557873426b6 100644 --- 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 @@ -36,6 +36,7 @@ import type { ProviderTimelineTextChannel, ProviderTimelineTextItem } from './provider-timeline-event' +import { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' @@ -77,7 +78,7 @@ export class ProviderTimelineTextStreams { private readonly byId = new Map() private readonly rows: ProviderTimelineStreamRows /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new Map() + private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer private serial = 0 From ebb07e6461a8eb0d23a54811ccaa571a7b34253d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:07 -0700 Subject: [PATCH 10/26] Keep the journal store under its line limit after the main merge --- .../native-chat/agent-session-journal/journal-store.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index c101c21fd556..19a8b61a62cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -69,7 +69,7 @@ import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' -import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' +import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStopMarks } from './journal-stop-marks' @@ -306,12 +306,8 @@ export class AgentSessionJournal { } /** An upsert whose row is chosen from the fold at its own turn in the queue; null writes nothing. */ - appendResolvedItem( - resolve: () => JournalResolvedItem | null, - options: JournalItemAppendOptions - ): Promise { - return this.itemAppender.appendResolved(resolve, options) - } + appendResolvedItem: JournalItemAppender['appendResolved'] = (resolve, options) => + this.itemAppender.appendResolved(resolve, options) appendTombstone( identity: AgentJournalItemIdentity, From 03b9f2f3d2b358d7ea3e64e0a0f680feda58f6b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 11/26] 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. --- config/scripts/ci-shard-timings.json | 2 +- .../codex/codex-provider-timeline-identity.ts | 47 --- .../codex-structured-item-translation.test.ts | 28 +- .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 4 +- .../codex-structured-journal-settlement.ts | 4 +- ...-structured-journal-translation-restore.ts | 7 +- ...ctured-journal-translation-streams.test.ts | 4 +- ...ex-structured-journal-translation-turns.ts | 2 +- src/main/codex/codex-thread-item-identity.ts | 8 +- .../codex-turn-ordinals.test.ts} | 25 +- .../codex-turn-ordinals.ts} | 19 +- .../journal-item-fold.ts | 10 +- .../journal-provider-item-ref.test.ts | 75 ----- .../agent-session-journal/journal-reducer.ts | 3 - .../journal-render-item.ts | 1 - .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 12 +- .../journal-store-contracts.ts | 5 +- .../agent-session-journal/journal-store.ts | 2 - .../provider-timeline-identity.ts | 138 -------- .../provider-timeline-joins.test.ts | 231 ------------- .../provider-timeline-joins.ts | 306 ------------------ .../provider-timeline-message-places.ts | 122 ------- .../structured-agent-session-event-sink.ts | 2 - ...ed-agent-session-journal-append-options.ts | 1 - src/shared/agent-session-journal-types.ts | 9 - ...n-provider-item-ref-downgrade.unit.test.ts | 127 -------- 28 files changed, 59 insertions(+), 1148 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.ts rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts => codex/codex-turn-ordinals.test.ts} (64%) rename src/main/{native-chat/agent-session-timeline/provider-turn-message-ordinals.ts => codex/codex-turn-ordinals.ts} (88%) delete mode 100644 src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts delete mode 100644 tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index fb242a4788c8..73d0226943b8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1307,7 +1307,7 @@ "src/main/codex/codex-trust-grant-ledger.test.ts": 54, "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 43, "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, - "src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts": 96, + "src/main/codex/codex-turn-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, diff --git a/src/main/codex/codex-provider-timeline-identity.ts b/src/main/codex/codex-provider-timeline-identity.ts deleted file mode 100644 index 7da2bbd33aae..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Codex's persisted identities, spelled for the shared timeline assembler. -// -// The same rows Codex writes today (`codexItemIdentity`, `codexTurnLifecycleIdentity`): a user or -// assistant message inside a turn keys by (thread, turn, message ordinal), which survives a resume -// because the ordinal counts messages only; anything else keys by its Codex item id; a turn keys by -// its Codex turn id. Codex ids name one conversation for good, so the namespace spells nothing. - -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { ProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' -import { - codexTurnLifecycleIdentity, - codexTurnUserItemId -} from './codex-structured-journal-translation-turns' - -export function createCodexProviderTimelineIdentityScheme(input: { - sessionId: string - primaryThreadId: () => string | null -}): ProviderTimelineIdentityScheme { - const turn: ProviderTimelineIdentityScheme['turn'] = (address) => - codexTurnLifecycleIdentity(input.sessionId, address.key.value) - return { - ordinalMessages: true, - messageSlot: (identity) => - identity.provider === 'codex' - ? { place: `${identity.threadId}\u001f${identity.turnId}`, ordinal: identity.ordinal } - : null, - turn, - turnId: (address) => address.key.value, - turnOpener: (address) => { - const thread = input.primaryThreadId() - return thread === null - ? agentJournalItemKey(turn(address)) - : codexTurnUserItemId(thread, address.key.value) - }, - item: (address) => { - const threadId = address.thread ?? input.primaryThreadId() ?? '' - return address.itemClass === 'message' && address.turn && address.messageOrdinal !== null - ? { - provider: 'codex', - threadId, - turnId: address.turn.value, - ordinal: address.messageOrdinal - } - : { provider: 'orca', clientMessageId: `codex-item:${threadId}:${address.key.value}` } - } - } -} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 29562552a02c..ad63d624f58b 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -11,9 +11,9 @@ import { codexJournalItem, codexMessageBlocks, codexStreamingJournalItem, - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES, + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES, isCodexMessageItemType, readCodexThreadItem, type CodexThreadItem @@ -59,7 +59,7 @@ const RESUMED_TURN: CodexThreadItem[] = [ ] function keysFor(items: CodexThreadItem[]): string[] { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() return items .filter((item) => isCodexMessageItemType(item.type)) .map((item) => @@ -71,15 +71,15 @@ function keysFor(items: CodexThreadItem[]): string[] { describe('codex turn ordinals', () => { it('bounds forgotten turn tombstones while retaining the recent window', () => { - const ordinals = new ProviderTurnMessageOrdinals() - const total = MAX_PROVIDER_TURN_ORDINAL_ENTRIES + 12 + const ordinals = new CodexTurnOrdinals() + const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12 for (let index = 0; index < total; index += 1) { const turnId = `turn-${index}` expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0) ordinals.forgetTurn('thread-many', turnId) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) // The newest completed turn still keeps its counter for a late frame. expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1) // The oldest turn was deterministically evicted and starts a fresh key. @@ -87,7 +87,7 @@ describe('codex turn ordinals', () => { }) it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) @@ -102,12 +102,12 @@ describe('codex turn ordinals', () => { }) it('bounds aggregate provider identifier bytes retained by one active turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 3_000; index += 1) { ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) }) }) @@ -117,7 +117,7 @@ describe('codex item identity', () => { }) it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const live = LIVE_TURN.map((item) => codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) ) @@ -136,7 +136,7 @@ describe('codex item identity', () => { }) it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) @@ -144,14 +144,14 @@ describe('codex item identity', () => { }) it('restarts numbering per turn', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) }) it('keys a non-message item and a turnless message in the orca namespace', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() const command = codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 3f4229cb6e54..25b821d90d9f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -28,10 +28,10 @@ export { type CodexThreadItem } from './codex-thread-item-identity' export { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' // Codex thread items → journal item bodies. diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 22bb16c8ef08..ec6ac67df5d4 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -7,7 +7,7 @@ import { requiresTerminalSettlement } from '../native-chat/agent-session-journal import { codexItemIdentity, codexJournalItem, - ProviderTurnMessageOrdinals, + CodexTurnOrdinals, readCodexThreadItem, type CodexThreadItem } from './codex-structured-item-translation' @@ -35,7 +35,7 @@ import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { - readonly ordinals = new ProviderTurnMessageOrdinals() + readonly ordinals = new CodexTurnOrdinals() readonly activeItems = new Map() readonly streams private readonly identities = new Map() diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e7d0a376eca8..131de9824aae 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -17,7 +17,7 @@ import { codexJournalItem, codexStreamingJournalItem, type CodexThreadItem, - type ProviderTurnMessageOrdinals + type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexHelperName } from './codex-collab-agent-item-translation' @@ -56,7 +56,7 @@ export function settleCodexJournalSession(input: { pendingPrompts: ReadonlyMap currentTurnIds: ReadonlyMap> primaryThreadId: string | null - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index a3b4652f366e..9f7efc15ae4c 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,8 +1,5 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { - readCodexThreadItem, - type ProviderTurnMessageOrdinals -} from './codex-structured-item-translation' +import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalItems } from './codex-structured-journal-items' import { @@ -58,7 +55,7 @@ export function restoreCodexJournalThread(input: { threadId: string thread: Record currentTurnIds: Map> - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 7bb88edc3a6f..54a2c017aa05 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -6,7 +6,7 @@ import type { import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { projectStructuredItemsToNativeChat } from '../../shared/structured-agent-session-projection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ProviderTurnMessageOrdinals } from './codex-structured-item-translation' +import { CodexTurnOrdinals } from './codex-structured-item-translation' import { createCodexJournalTranslator, MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES, @@ -164,7 +164,7 @@ describe('codex journal translation', () => { }) it('releases a turn ordinal map when the turn completes', () => { - const spy = vi.spyOn(ProviderTurnMessageOrdinals.prototype, 'forgetTurn') + const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') try { const { translator } = translatorWith() translator.handle(TURN_STARTED) diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index dc955f70385c..33f138c6578d 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -8,7 +8,7 @@ import { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-thread-item-identity' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts index 663b497f7731..0488e5c00e98 100644 --- a/src/main/codex/codex-thread-item-identity.ts +++ b/src/main/codex/codex-thread-item-identity.ts @@ -1,5 +1,5 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' +import type { CodexTurnOrdinals } from './codex-turn-ordinals' // Codex thread items → durable journal identities. // @@ -15,10 +15,6 @@ import type { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-t // including ones this build does not model — is skipped identically on both // sides, which is what makes the key survive a Codex release that adds one. -/** Codex records the user message first in a turn, so a restored submission is - * ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 - /** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) @@ -54,7 +50,7 @@ export function codexItemIdentity(input: { threadId: string turnId: string | null item: CodexThreadItem - ordinals: ProviderTurnMessageOrdinals + ordinals: CodexTurnOrdinals }): AgentJournalItemIdentity { const { item, turnId } = input if (turnId && isCodexMessageItemType(item.type)) { diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts b/src/main/codex/codex-turn-ordinals.test.ts similarity index 64% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts rename to src/main/codex/codex-turn-ordinals.test.ts index b070b449f264..5ae12011a855 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.test.ts +++ b/src/main/codex/codex-turn-ordinals.test.ts @@ -1,17 +1,16 @@ import { expect, it } from 'vitest' import { - ProviderTurnMessageOrdinals, - MAX_PROVIDER_TURN_ORDINAL_BYTES, - MAX_PROVIDER_TURN_ORDINAL_ENTRIES -} from './provider-turn-message-ordinals' + CodexTurnOrdinals, + MAX_CODEX_TURN_ORDINAL_BYTES, + MAX_CODEX_TURN_ORDINAL_ENTRIES +} from './codex-turn-ordinals' it('does not rescan the forgotten window for each new streamed item', () => { - const ordinals = new ProviderTurnMessageOrdinals() - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + const ordinals = new CodexTurnOrdinals() + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('thread', String(index), 'item') ordinals.forgetTurn('thread', String(index)) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test counts reads of the class's private turn map, whose entries carry `active`. const turns = (ordinals as unknown as { turns: Map }).turns let reads = 0 for (const turn of turns.values()) { @@ -30,30 +29,30 @@ it('does not rescan the forgotten window for each new streamed item', () => { expect(ordinals.ordinalFor('thread', 'live', String(index))).toBe(index) } expect(reads).toBe(0) - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) }) it('retains ordinal continuity on late reactivation and evicts oldest forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() expect(ordinals.ordinalFor('t', 'first', 'a')).toBe(0) ordinals.forgetTurn('t', 'first') expect(ordinals.ordinalFor('t', 'first', 'b')).toBe(1) expect(ordinals.forgottenTurnCount).toBe(0) ordinals.forgetTurn('t', 'first') - for (let index = 0; index < MAX_PROVIDER_TURN_ORDINAL_ENTRIES; index += 1) { + for (let index = 0; index < MAX_CODEX_TURN_ORDINAL_ENTRIES; index += 1) { ordinals.ordinalFor('t', String(index), 'a') ordinals.forgetTurn('t', String(index)) } - expect(ordinals.forgottenTurnCount).toBe(MAX_PROVIDER_TURN_ORDINAL_ENTRIES) + expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES) expect(ordinals.ordinalFor('t', 'first', 'c')).toBe(0) }) it('keeps byte eviction bounded for active and forgotten turns', () => { - const ordinals = new ProviderTurnMessageOrdinals() + const ordinals = new CodexTurnOrdinals() for (let index = 0; index < 4000; index += 1) { ordinals.ordinalFor('thread', 'live', `${index}-${'x'.repeat(240)}`) } - expect(ordinals.bytes).toBeLessThanOrEqual(MAX_PROVIDER_TURN_ORDINAL_BYTES) + expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES) ordinals.forgetTurn('thread', 'live') expect(ordinals.bytes).toBeLessThan(100) expect(ordinals.forgottenTurnCount).toBe(1) diff --git a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts similarity index 88% rename from src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts rename to src/main/codex/codex-turn-ordinals.ts index 9405ff9281ef..7e21b900d965 100644 --- a/src/main/native-chat/agent-session-timeline/provider-turn-message-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -1,11 +1,18 @@ -import { boundPayload, digestPayload } from '../agent-session-journal/journal-payload-bounds' +import { + boundPayload, + digestPayload +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 /** Maximum forgotten turn keys retained for late-frame reconciliation. */ -export const MAX_PROVIDER_TURN_ORDINAL_ENTRIES = 256 -export const MAX_PROVIDER_TURN_ORDINAL_BYTES = 512 * 1024 +export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 +export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 /** Assigns stable message ordinals while retaining a bounded late-frame window. */ -export class ProviderTurnMessageOrdinals { +export class CodexTurnOrdinals { private readonly turns = new Map< string, { assigned: Map; next: number; active: boolean } @@ -39,7 +46,7 @@ export class ProviderTurnMessageOrdinals { } private trimForgotten(): void { - while (this.forgottenTurnCount > MAX_PROVIDER_TURN_ORDINAL_ENTRIES) { + while (this.forgottenTurnCount > MAX_CODEX_TURN_ORDINAL_ENTRIES) { const oldest = this.forgottenTurns.values().next().value if (!oldest) { break @@ -60,7 +67,7 @@ export class ProviderTurnMessageOrdinals { private trimBytes(currentTurnKey: string): void { this.trimForgotten() - while (this.retainedBytes > MAX_PROVIDER_TURN_ORDINAL_BYTES) { + while (this.retainedBytes > MAX_CODEX_TURN_ORDINAL_BYTES) { const forgotten = this.forgottenTurns.values().next().value const oldest = forgotten ?? this.turns.keys().next().value if (typeof oldest !== 'string') { diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts index aa1a02e4a70a..10aee4a99c41 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -39,9 +39,6 @@ export function upsertJournalItem( return } state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) - if (next.providerItemRef !== undefined && existing?.providerItemRef === undefined) { - state.providerItemRefs.set(next.providerItemRef, itemId) - } if (!existing) { state.items.set(itemId, next) state.itemFences.set(itemId, fence) @@ -68,9 +65,7 @@ export function upsertJournalItem( sequence: existing.sequence, ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), observedAt: existing.observedAt, - turnScope: existing.turnScope ?? next.turnScope, - // The creating write's, as the scope is: a revision never re-points the row at another item. - ...(existing.providerItemRef === undefined ? {} : { providerItemRef: existing.providerItemRef }) + turnScope: existing.turnScope ?? next.turnScope }) state.tombstones.delete(itemId) } @@ -97,9 +92,6 @@ export function removeJournalItem( ) } state.tombstones.set(itemId, revision) - if (existing?.providerItemRef !== undefined) { - state.providerItemRefs.delete(existing.providerItemRef) - } state.items.delete(itemId) state.itemFences.delete(itemId) } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts b/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts deleted file mode 100644 index d80d5a539965..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-provider-item-ref.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from './journal-host-database-test-support' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function open(root?: string) { - const directory = root ?? (await mkdtemp(join(tmpdir(), 'orca-provider-item-ref-'))) - if (!root) { - roots.push(directory) - } - const journal = await journals.open({ - identity: { - sessionId: 'session-ref', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - return { journal, directory } -} - -const row: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'root', - turnId: 't1', - ordinal: 0 -} -const text = (value: string) => ({ - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: value }] -}) -const scope = { kind: 'turn', turnItemId: 'turn:t1' } as const - -describe('a row’s provider item reference', () => { - it('is the creating write’s, kept by revisions and indexed for its producer', async () => { - const { journal } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journal.appendItem(row, text('ab'), { fence: 1, turnScope: scope, providerItemRef: 'm9' }) - await journal.appendItem(row, text('abc'), { fence: 1, turnScope: scope }) - - expect(journal.item('codex:root:t1:0')).toMatchObject({ - providerItemRef: 'm0', - body: text('abc') - }) - expect(journal.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - expect(journal.itemIdForProviderItemRef('m9')).toBeNull() - }) - - it('survives a reopen, and leaves the index with its row', async () => { - const { journal, directory } = await open() - await journal.appendItem(row, text('a'), { fence: 1, turnScope: scope, providerItemRef: 'm0' }) - await journals.closeAll() - - const { journal: reopened } = await open(directory) - expect(reopened.itemIdForProviderItemRef('m0')).toBe('codex:root:t1:0') - await reopened.appendTombstone(row, { fence: 1 }) - expect(reopened.itemIdForProviderItemRef('m0')).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index c4e892de8038..e2d1b1ef3ca3 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -56,8 +56,6 @@ export type JournalReducerState = { /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ aliases: Map - /** `providerItemRef` → the row that carries it, so a producer finds that row again in O(1). */ - providerItemRefs: Map appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope @@ -82,7 +80,6 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - providerItemRefs: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 76a7abf054ec..fd58b2c25e4e 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -35,7 +35,6 @@ export function journalRenderItem( ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), turnScope, - ...(row.providerItemRef === undefined ? {} : { providerItemRef: row.providerItemRef }), ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 16bf1960e400..0865df3a41fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -49,8 +49,7 @@ export function journalItemRowBuilder( ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, - turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }) + turnScope: options.turnScope }) } @@ -250,7 +249,6 @@ export function buildJournalItemRow(input: { recovered?: true linkage?: AgentJournalProducerLinkage turnScope: AgentJournalTurnScope - providerItemRef?: string }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -270,7 +268,6 @@ export function buildJournalItemRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, - ...(input.providerItemRef === undefined ? {} : { providerItemRef: input.providerItemRef }), ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e8d1746ceb8d..9276b1a8c05f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -20,7 +20,6 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, - type AgentJournalProviderItemRef, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -52,9 +51,6 @@ type JournalRowBase = AgentJournalProducerLinkage & { * for the reason linkage does. Absent on rows from hosts that predate it: the reducer * derives one for them on read. */ turnScope?: AgentJournalTurnScope - /** On an item row: the provider's reference for the item it creates. Rides the base for the - * reason linkage does. */ - providerItemRef?: AgentJournalProviderItemRef } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -253,13 +249,7 @@ export function parseJournalRow(line: string): JournalRowParse { * survives is a real one: the reader scopes on PRESENCE, so `''` or a * non-string left in place would hide the row from its own author for good. */ function dropUnusableProducerLinkage(record: Record): void { - for (const field of [ - 'agentId', - 'parentAgentId', - 'providerParentRef', - 'producerKind', - 'providerItemRef' - ]) { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { const value = record[field] if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { delete record[field] diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index b5a960360368..a85b3adc3172 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -62,10 +62,7 @@ export type JournalItemLinkageVisitor = ( itemId: string, sequence: number, body: AgentJournalItemBody, - attribution: AgentJournalProducerLinkage & { - turnScope?: AgentJournalTurnScope - providerItemRef?: string - } + attribution: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope } ) => void export type JournalLifecycleBatchInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 19a8b61a62cd..d12257447cfd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -230,8 +230,6 @@ export class AgentSessionJournal { /** One reduced item with its attribution, for a writer that needs the turn a row joined. */ item = (itemId: string): AgentJournalRenderItem | null => this.state.items.get(itemId) ?? null - /** The row that carries this `providerItemRef`, if any. */ - itemIdForProviderItemRef = (ref: string) => this.state.providerItemRefs.get(ref) ?? null /** Visits reduced items with the producer that wrote each, for a producer re-deriving what an * earlier run of this session left. */ 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 deleted file mode 100644 index 77286d029055..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ /dev/null @@ -1,138 +0,0 @@ -// How the assembler's addresses become persisted journal identities. -// -// The assembler decides WHICH rows are the same row: it resolves every provider join to an -// address — the provider session's namespace, the key, the thread and turn the item belongs to, -// the item's class, and its place among its turn's messages — or mints a key when the provider -// names nothing. A scheme only spells an address as an `AgentJournalItemIdentity`. It must be pure, -// and it is the one place a provider's persisted identity shape lives, so moving a provider to a -// new identity arm is a change here. - -import type { - AgentJournalItemBody, - 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 } - -export type ProviderTimelineTurnAddress = { - /** The provider session whose ids the key belongs to. */ - namespace: string - key: ProviderTimelineKey -} - -/** Which family an item belongs to; families never share a key space. Streamed text and full - * item snapshots are one family, so a provider-named message is one row however it arrives. */ -export type ProviderTimelineItemFamily = 'item' | 'request' | 'frame' - -/** What kind of thing an item is, derived from what it carries: `message` is user or assistant - * prose; every other class is its row kind (`reasoning` text included). */ -export type ProviderTimelineItemClass = - | 'message' - | 'reasoning' - | Exclude - | 'frame' - -export type ProviderTimelineItemAddress = { - namespace: string - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - /** The provider thread it came from; null when the provider named none. */ - thread: string | null - /** The provider turn it belongs to; null for a row outside any turn. */ - turn: ProviderTimelineKey | null - itemClass: ProviderTimelineItemClass - /** Its place among its (thread, turn)'s `message` items, by first write; null for any other, and - * for every item of a scheme without `ordinalMessages`. */ - messageOrdinal: number | null - /** 1 for the first request under a key; a key reused after it settled is the next one. */ - incarnation: number -} - -export type ProviderTimelineMessageSlot = { place: string; ordinal: number } - -export type ProviderTimelineIdentityScheme = { - /** Messages inside a turn are keyed by their place among the turn's messages, not by their key. - * Such an identity must spell only (thread, turn, ordinal), and its row carries the key as - * `providerItemRef`, since nothing else could find it again. */ - ordinalMessages: boolean - /** Reads an ordinal message identity back: its (thread, turn) place, spelled by the scheme, and - * its ordinal; null for any other identity. Required with `ordinalMessages`. */ - messageSlot?(identity: AgentJournalItemIdentity): ProviderTimelineMessageSlot | null - turn(address: ProviderTimelineTurnAddress): AgentJournalItemIdentity - /** The id the turn row carries and a client's Stop names. */ - turnId(address: ProviderTimelineTurnAddress): string - /** The user item a turn names when no send of Orca's opened it; absent ⇒ the turn row itself. */ - turnOpener?(address: ProviderTimelineTurnAddress): string - /** With a null `messageOrdinal` it must spell only (namespace, family, key, thread, incarnation), - * so a row is found again from its key alone. */ - item(address: ProviderTimelineItemAddress): AgentJournalItemIdentity -} - -export function providerTimelineItemClass(body: AgentJournalItemBody): ProviderTimelineItemClass { - if (body.kind !== 'message') { - return body.kind - } - return body.role === 'user' || body.role === 'assistant' ? 'message' : 'reasoning' -} - -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 scheme for a provider with no identity arm of its own: the existing `legacy` arm, so no row - * shape changes. 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 { - ordinalMessages: false, - 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.family === 'request' ? null : address.thread)}${ - address.incarnation > 1 ? `#${address.incarnation}` : '' - }` - ) - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts deleted file mode 100644 index 6a3f1e8d9d3f..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalApprovalItem, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' -import { ProviderTimelineJoins, type ProviderTimelineItemJoin } from './provider-timeline-joins' - -const journals = createTrackedJournalOpener() -const roots: string[] = [] - -afterEach(async () => { - await journals.closeAll() - for (const root of roots.splice(0)) { - await rm(root, { recursive: true, force: true }) - } -}) - -async function openJournal(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-joins-')) - roots.push(root) - return journals.open({ - identity: { - sessionId: 'session-joins', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } - }, - stateDirectory: root, - now: () => 1_000 - }) -} - -const codexJoins = () => - new ProviderTimelineJoins({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: 'session-joins', - primaryThreadId: () => 'root' - }), - generation: 'gen-1', - namespace: 'ns' - }) - -const message = (id: string): ProviderTimelineItemJoin => ({ - family: 'item', - key: { source: 'provider', value: id }, - thread: 'root' -}) - -const inTurn = (turn: string) => ({ - thread: 'root', - turn: { source: 'provider', value: turn } as const, - scope: { kind: 'turn', turnItemId: `turn:${turn}` } as const -}) - -const codexSlot = (turn: string, ordinal: number, threadId = 'root'): AgentJournalItemIdentity => ({ - provider: 'codex', - threadId, - turnId: turn, - ordinal -}) - -async function write( - journal: AgentSessionJournal, - row: { identity: AgentJournalItemIdentity; ref?: string }, - text: string -) { - await journal.appendItem( - row.identity, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, - { - fence: 1, - turnScope: { kind: 'turn', turnItemId: 'turn:t1' }, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - ) -} - -describe('provider timeline joins', () => { - it('finds an ordinal message again from the reference its row carries, after a restart', async () => { - const journal = await openJournal() - const before = codexJoins() - const m0 = before.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - const m1 = before.place(message('m1'), 'message', inTurn('t1'), journal) - await write(journal, m1, 'second') - expect([m0.itemId, m1.itemId]).toEqual(['codex:root:t1:0', 'codex:root:t1:1']) - - const after = codexJoins() - expect(after.find(message('m1'), journal)).toMatchObject({ - itemId: 'codex:root:t1:1', - scope: { kind: 'turn', turnItemId: 'turn:t1' } - }) - // A new message takes the next free place, past every row the turn holds. - expect(after.place(message('m2'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:2' - ) - }) - - it('skips a place an echoed send holds through its submission alias', async () => { - const journal = await openJournal() - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 0 }, - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:1' - ) - }) - - it('continues past the highest ordinal the journal holds, never into a gap below it', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 2), ref: 'item:old' }, 'old') - // Another turn and another thread keep their own places. - await write(journal, { identity: codexSlot('t2', 7) }, 'other turn') - await write(journal, { identity: codexSlot('t1', 9, 'sub') }, 'subagent') - const joins = codexJoins() - expect(joins.place(message('new'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:3' - ) - expect(joins.place(message('next'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:4' - ) - }) - - it('continues past an echoed send above the highest row', async () => { - const journal = await openJournal() - await write(journal, { identity: codexSlot('t1', 0) }, 'first') - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'accepted', - providerIdentity: codexSlot('t1', 4), - fence: 1 - }) - expect(codexJoins().place(message('reply'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:5' - ) - }) - - it('forgets what it read from an epoch the journal replaced', async () => { - const journal = await openJournal() - const joins = codexJoins() - const m0 = joins.place(message('m0'), 'message', inTurn('t1'), journal) - await write(journal, m0, 'first') - expect(joins.find(message('m0'), journal)?.itemId).toBe('codex:root:t1:0') - await journal.replaceEpochItems('handle_forked', 1, []) - expect(joins.find(message('m0'), journal)).toBeNull() - expect(joins.place(message('m1'), 'message', inTurn('t1'), journal).itemId).toBe( - 'codex:root:t1:0' - ) - }) - - it('finds a row whose identity spells its key without any reference', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const tool = joins().place(message('tool'), 'tool-call', inTurn('t1'), journal) - expect(tool.ref).toBeUndefined() - await write(journal, tool, 'x') - expect(joins().find(message('tool'), journal)?.itemId).toBe(tool.itemId) - expect(joins().find({ ...message('tool'), thread: 'other' }, journal)).toBeNull() - }) - - it('reads the current request incarnation back from the rows', async () => { - const journal = await openJournal() - const scheme = createLegacyProviderTimelineIdentityScheme({ - agent: 'grok', - sessionId: 'session-joins' - }) - const joins = () => new ProviderTimelineJoins({ scheme, generation: 'g', namespace: 'ns' }) - const key = { source: 'provider', value: 'p1' } as const - const thread = { thread: null, turn: null, scope: AGENT_JOURNAL_THREAD_SCOPE } - const approval: AgentJournalApprovalItem = { - kind: 'approval', - title: 'Run?', - detail: null, - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - const before = joins() - for (let incarnation = 1; incarnation <= 2; incarnation += 1) { - const row = before.nextRequest(key, 'approval', thread, journal) - expect(row.incarnation).toBe(incarnation) - await journal.appendItem(row.identity, approval, { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - expect(joins().request(key, journal)).toMatchObject({ - incarnation: 2, - itemId: agentJournalItemKey( - scheme.item({ - namespace: 'ns', - family: 'request', - key, - thread: null, - turn: null, - itemClass: 'approval', - messageOrdinal: null, - incarnation: 2 - }) - ) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts deleted file mode 100644 index 7cb48073933d..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-joins.ts +++ /dev/null @@ -1,306 +0,0 @@ -// Provider joins → journal rows: the assembler's canonical join index. -// -// Every row the assembler writes is found again through here, and every entry is a cache of a -// fact the journal holds. A row whose identity spells its provider key is found by recomputing -// that identity; a row whose identity does not (a message keyed by its place among its turn's -// messages) carries the key as `providerItemRef`, which the journal indexes. So a miss — after a -// restart or an eviction — recovers the original row; it never re-places it. Message ordinals and -// request incarnations are read back from the rows that hold them. Only transition resolvers call -// the allocating members, so allocation happens in journal order and a refused event allocates -// nothing. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity, - type AgentJournalRenderItem, - type AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineItemAddress, - type ProviderTimelineItemClass, - type ProviderTimelineItemFamily, - type ProviderTimelineKey, - type ProviderTimelineTurnAddress -} from './provider-timeline-identity' -import { ProviderTimelineMessagePlaces } from './provider-timeline-message-places' - -const MAX_JOINED_ROWS = 1_024 -const MAX_JOINED_ROW_BYTES = 1024 * 1024 - -/** One provider item as the provider names it. Item keys are the provider's per thread. */ -export type ProviderTimelineItemJoin = { - family: ProviderTimelineItemFamily - key: ProviderTimelineKey - thread: string | null -} - -export type ProviderTimelineRow = { - identity: AgentJournalItemIdentity - itemId: string - scope: AgentJournalTurnScope - /** The join's reference, on a row whose identity does not spell its key. */ - ref?: string - /** Which request under its key, 1 for the first. */ - incarnation: number -} - -export type ProviderTimelineTurnRef = { - address: ProviderTimelineTurnAddress - identity: AgentJournalItemIdentity - itemId: string - turnId: string -} - -/** Where a new row goes: the provider thread and turn, and the journal turn scope. */ -export type ProviderTimelinePlacement = { - thread: string | null - turn: ProviderTimelineKey | null - scope: AgentJournalTurnScope -} - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineJoins { - private readonly rows = new BoundedMap({ - maxEntries: MAX_JOINED_ROWS, - maxBytes: MAX_JOINED_ROW_BYTES, - sizeOf: (row, key) => bytes(key) + 2 * bytes(row.itemId) + 64 - }) - private readonly places: ProviderTimelineMessagePlaces - /** The journal epoch every cache entry was read from; a replaced epoch holds other rows. */ - private epoch: string | null = null - private serial = 0 - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - generation: string - namespace: string - } - ) { - this.places = new ProviderTimelineMessagePlaces({ - scheme: deps.scheme, - namespace: () => this.deps.namespace - }) - } - - get namespace(): string { - return this.deps.namespace - } - - /** A new provider session: its ids name new rows. Minted keys stay unique as they are. */ - reset(namespace: string): void { - this.deps.namespace = namespace - this.rows.clear() - this.places.clear() - } - - /** Drops every cache read from an epoch the journal has since replaced (a rewind, an import). */ - private sync(journal: Journal): void { - if (!journal || journal.epoch === this.epoch) { - return - } - if (this.epoch !== null) { - this.rows.clear() - this.places.clear() - } - this.epoch = journal.epoch - } - - /** A key unique to this acquisition, for something the provider names nothing. */ - mint(kind: string): ProviderTimelineKey { - this.serial += 1 - return { source: 'minted', value: `${this.deps.generation}:${kind}${this.serial}` } - } - - /** A turn's row. `namespace` defaults to the current one; a planner names the one it expects. */ - turn(key: ProviderTimelineKey, namespace = this.deps.namespace): ProviderTimelineTurnRef { - const address = { namespace, key } - const identity = this.deps.scheme.turn(address) - return { - address, - identity, - itemId: agentJournalItemKey(identity), - turnId: this.deps.scheme.turnId(address) - } - } - - /** The user item a turn names when no send of Orca's opened it. */ - turnOpener(turn: ProviderTimelineTurnRef): string { - return this.deps.scheme.turnOpener?.(turn.address) ?? turn.itemId - } - - /** The join as one bounded string: what rows carry as `providerItemRef` and caches key by. */ - reference(join: ProviderTimelineItemJoin, namespace = this.deps.namespace): string { - const thread = join.family === 'request' ? null : join.thread - return `${join.family}:${spellProviderTimelineKey(namespace, join.key, thread)}` - } - - /** The row the join already names, from memory or the fold; null when the journal holds none. */ - find( - join: ProviderTimelineItemJoin, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - // A planner ahead of a reset asks about a namespace whose rows this index does not spell yet. - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - const itemId = - journal.itemIdForProviderItemRef(ref) ?? agentJournalItemKey(this.spelled(join, 1)) - const row = rowOf(journal.item(itemId), 1) - if (row) { - this.rows.set(ref, row) - } - return row - } - - /** A new row for a join no row names yet. Allocates its message ordinal: resolvers only. */ - place( - join: ProviderTimelineItemJoin, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - this.sync(journal) - const ordinal = - this.deps.scheme.ordinalMessages && itemClass === 'message' && placement.turn - ? this.places.next(placement.thread, placement.turn, journal) - : null - const identity = this.deps.scheme.item({ - ...this.address(join, 1), - turn: placement.turn, - itemClass, - messageOrdinal: ordinal - }) - const ref = this.reference(join) - const row: ProviderTimelineRow = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - ...(ordinal === null ? {} : { ref }), - incarnation: 1 - } - this.rows.set(ref, row) - return row - } - - /** An echoed send takes its message's place without a row (its send is the bubble). */ - reserveEcho( - join: ProviderTimelineItemJoin, - placement: ProviderTimelinePlacement, - journal: Journal - ) { - if (!this.deps.scheme.ordinalMessages || !placement.turn || this.find(join, journal)) { - return - } - const row = this.place(join, 'message', placement, journal) - this.places.reserve(row.itemId) - } - - /** The request under `key` the journal holds last: the highest incarnation with a row. */ - request( - key: ProviderTimelineKey, - journal: Journal, - namespace = this.deps.namespace - ): ProviderTimelineRow | null { - this.sync(journal) - const join = { family: 'request' as const, key, thread: null } - const ref = this.reference(join, namespace) - const cached = this.rows.get(ref) - if (cached || !journal || namespace !== this.deps.namespace) { - return cached ?? null - } - let current: ProviderTimelineRow | null = null - for (let incarnation = 1; ; incarnation += 1) { - const itemId = agentJournalItemKey(this.spelled(join, incarnation)) - // A scheme that spells no incarnation names one row for every request under the key. - const row = itemId === current?.itemId ? null : rowOf(journal.item(itemId), incarnation) - if (!row) { - break - } - current = row - } - if (current) { - this.rows.set(ref, current) - } - return current - } - - /** The next request under `key`: resolvers only. */ - nextRequest( - key: ProviderTimelineKey, - itemClass: ProviderTimelineItemClass, - placement: ProviderTimelinePlacement, - journal: Journal - ): ProviderTimelineRow { - const join = { family: 'request' as const, key, thread: null } - const incarnation = (this.request(key, journal)?.incarnation ?? 0) + 1 - const identity = this.deps.scheme.item({ - ...this.address(join, incarnation), - thread: placement.thread, - turn: placement.turn, - itemClass - }) - const row = { - identity, - itemId: agentJournalItemKey(identity), - scope: placement.scope, - incarnation - } - this.rows.set(this.reference(join), row) - return row - } - - private address( - join: ProviderTimelineItemJoin, - incarnation: number - ): ProviderTimelineItemAddress { - return { - namespace: this.deps.namespace, - family: join.family, - key: join.key, - thread: join.thread, - turn: null, - itemClass: 'status', - messageOrdinal: null, - incarnation - } - } - - private spelled(join: ProviderTimelineItemJoin, incarnation: number): AgentJournalItemIdentity { - return this.deps.scheme.item(this.address(join, incarnation)) - } -} - -function rowOf( - item: AgentJournalRenderItem | null, - incarnation: number -): ProviderTimelineRow | null { - const identity = item ? parseAgentJournalItemKey(item.itemId) : null - if (!item || !identity) { - return null - } - return { - identity, - itemId: item.itemId, - scope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE, - ...(item.providerItemRef === undefined ? {} : { ref: item.providerItemRef }), - incarnation - } -} diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts deleted file mode 100644 index e5350c39c93a..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-message-places.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Where a new message goes among its (thread, turn)'s messages, for a scheme that keys messages -// by that place. Read back from the journal on a miss, past every place a row or an echoed send -// holds, so a restart continues the sequence the journal already has. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineKeyPart, - spellProviderTimelineKey, - type ProviderTimelineIdentityScheme, - type ProviderTimelineKey -} from './provider-timeline-identity' - -const MAX_TURN_ORDINALS = 256 -/** Echoed sends whose slot no row holds yet; a lane records each on its send soon after. */ -const MAX_RESERVED_ECHOES = 64 - -type Journal = StructuredAgentSessionTransitionJournal | null - -function bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -export class ProviderTimelineMessagePlaces { - /** The next ordinal per place. */ - private readonly ordinals = new BoundedMap({ - maxEntries: MAX_TURN_ORDINALS, - maxBytes: MAX_TURN_ORDINALS * 1024, - sizeOf: (_next, place) => bytes(place) + 8 - }) - private readonly echoes = new BoundedMap({ - maxEntries: MAX_RESERVED_ECHOES, - maxBytes: MAX_RESERVED_ECHOES * 1024, - sizeOf: (_held, itemId) => bytes(itemId) + 8 - }) - - constructor( - private readonly deps: { - scheme: ProviderTimelineIdentityScheme - namespace: () => string - } - ) {} - - clear(): void { - this.ordinals.clear() - this.echoes.clear() - } - - /** Holds a place no row takes (an echoed send's). */ - reserve(itemId: string): void { - this.echoes.set(itemId, true) - } - - /** The next place in (thread, turn): allocating, so resolvers only. */ - next(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const place = `${providerTimelineKeyPart(thread ?? '')}\u001f${spellProviderTimelineKey( - this.deps.namespace(), - turn - )}` - let ordinal = this.ordinals.get(place) ?? this.highWater(thread, turn, journal) - // Another writer of the same turn may have taken places since this one cached its mark. - while (this.taken(agentJournalItemKey(this.slot(thread, turn, ordinal)), journal)) { - ordinal += 1 - } - this.ordinals.set(place, ordinal + 1) - return ordinal - } - - /** One past the highest ordinal the journal holds in the place, like the ordinal counter it - * continues: a gap below it (a removed or never-imported row) is never filled. */ - private highWater(thread: string | null, turn: ProviderTimelineKey, journal: Journal): number { - const read = this.deps.scheme.messageSlot - const place = read?.(this.slot(thread, turn, 0))?.place - if (!read || place === undefined || !journal) { - return 0 - } - let next = 0 - const consider = (itemId: string | null) => { - const identity = itemId === null ? null : parseAgentJournalItemKey(itemId) - const slot = identity ? read(identity) : null - if (slot?.place === place) { - next = Math.max(next, slot.ordinal + 1) - } - } - journal.visitItems((itemId) => consider(itemId)) - // An echoed send holds its place through its submission alias, with no row of its own. - for (const submission of journal.submissions()) { - consider(submission.providerItemId) - } - return next - } - - private slot( - thread: string | null, - turn: ProviderTimelineKey, - ordinal: number - ): AgentJournalItemIdentity { - return this.deps.scheme.item({ - namespace: this.deps.namespace(), - family: 'item', - key: turn, - thread, - turn, - itemClass: 'message', - messageOrdinal: ordinal, - incarnation: 1 - }) - } - - private taken(itemId: string, journal: Journal): boolean { - return ( - this.echoes.has(itemId) || - (journal !== null && - (journal.itemBody(itemId) !== null || journal.canonicalItemId(itemId) !== itemId)) - ) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 5cdabdce713d..8e997bf8477f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -51,8 +51,6 @@ export type StructuredAgentSessionPublishOptions = Pick< /** An item write states which turn its row belongs to; the write that creates the row decides. */ export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { turnScope: AgentJournalTurnScope - /** The provider's reference for the item, on the write that creates its row. */ - providerItemRef?: string } export type StructuredAgentSessionLifecycleJournal = Pick< diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index 57bb482f7c2b..e7ce667ed2b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -16,7 +16,6 @@ export function structuredAgentSessionJournalAppendOptions( fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, - ...(options.providerItemRef === undefined ? {} : { providerItemRef: options.providerItemRef }), ...agentJournalLinkageFields(options) } } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 271053b1b214..e5f8125a5c0f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -360,16 +360,8 @@ export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread /** Who produced a row and which turn it belongs to: what every item write states. */ export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { turnScope: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } -/** The provider's own reference for the item a row is, as its producer joined it, on a row whose - * identity does not spell it (a Codex message keys by its turn ordinal, and Codex renumbers its - * item ids on resume). Set by the write that creates the row and kept by every revision, like the - * turn scope. Only its producer reads it back, to find the row again after a restart; no client - * depends on it. */ -export type AgentJournalProviderItemRef = string - /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -395,7 +387,6 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope - providerItemRef?: AgentJournalProviderItemRef } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts deleted file mode 100644 index ff0ecc5fed33..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-provider-item-ref-downgrade.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// A release that predates `providerItemRef` on journal rows and render items. -const BASELINE_REF = 'v1.4.218' - -const SESSION = 'session-provider-item-ref' - -function message(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function releaseExport(module: Record, name: string): T { - const value = module[name] - if (value === undefined) { - throw new Error(`the pinned release exports no ${name}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the pinned release has; each caller names the shape it uses, and a changed one fails the test. - return value as T -} - -type OldRenderItem = { itemId: string; [field: string]: unknown } - -// Loads a real old build, including cold extraction and transforms. -test('an older host and client render a row carrying a provider item reference as if it had none', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-provider-item-ref-downgrade-')) - const journals = createTrackedJournalOpener() - try { - // This build: the same message twice, the first carrying the reference. - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - stateDirectory: directory, - now: () => 1_000 - }) - const body = { - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: 'Hello' }] - } - const scope = { kind: 'thread' as const } - await journal.appendItem(message(0), body, { - fence: 1, - turnScope: scope, - providerItemRef: 'item:p:thread-1/item-1' - }) - await journal.appendItem(message(1), body, { fence: 1, turnScope: scope }) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - const rows: JournalRow[] = since.rows - const current = journal.snapshot().items - expect(current[0]?.providerItemRef).toBe('item:p:thread-1/item-1') - - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const [reducer, schemas, projection] = await Promise.all( - [ - 'src/main/native-chat/agent-session-journal/journal-reducer.ts', - 'src/shared/agent-session-journal-schemas.ts', - 'src/shared/structured-agent-session-projection.ts' - ].map((path) => importReleaseCheckoutModule(checkout, path)) - ) - const createState = releaseExport<(sessionId: string, epoch: string) => unknown>( - reducer, - 'createJournalReducerState' - ) - const applyRow = releaseExport<(state: unknown, row: JournalRow) => void>( - reducer, - 'applyJournalRow' - ) - const render = releaseExport<(state: unknown) => { items: OldRenderItem[] }>( - reducer, - 'renderJournalState' - ) - const renderItemSchema = releaseExport<{ parse: (value: unknown) => OldRenderItem }>( - schemas, - 'AgentJournalRenderItemSchema' - ) - const project = releaseExport<(item: unknown) => unknown>( - projection, - 'projectStructuredItemToNativeChat' - ) - - // The older host, after a downgrade, folds the rows as it always did. - const state = createState(SESSION, journal.epoch) - for (const row of rows) { - applyRow(state, row) - } - const folded = render(state).items - expect(folded.map((item) => item.itemId)).toEqual([ - 'codex:thread-1:turn-1:0', - 'codex:thread-1:turn-1:1' - ]) - const [first, second] = folded - if (!first || !second) { - throw new Error('the older host folded both rows') - } - expect(first).not.toHaveProperty('providerItemRef') - const same = ({ itemId: _id, sequence: _seq, ...rest }: OldRenderItem) => rest - expect(same(first)).toEqual(same(second)) - - // The older client reads this host's render item without the field, and renders it the same. - const [withRef, withoutRef] = current.map((item) => renderItemSchema.parse(item)) - if (!withRef || !withoutRef) { - throw new Error('the older client read both items') - } - expect(withRef).not.toHaveProperty('providerItemRef') - expect(project(withRef)).toEqual( - project({ ...withoutRef, itemId: withRef.itemId, sequence: withRef.sequence }) - ) - } finally { - await journals.closeAll() - rmSync(directory, { recursive: true, force: true }) - } -}) From 7d048776c85f0ef254cd9f674e1c51844e0c0103 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 12/26] 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. --- .../journal-lifecycle-batch-appender.ts | 37 ++++++++--- .../journal-row-writer.ts | 61 +++++++------------ .../journal-store-collaborators.ts | 1 - 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index 9f1e0cce218f..cc300a7b7497 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -1,13 +1,16 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' import { partitionJournalLifecycleMutations } from './journal-lifecycle-batch-partition' -import { journalLifecycleBatchRowBuilder } from './journal-row-builders' +import { + journalLifecycleBatchRowBuilder, + type JournalLifecycleMutationInput +} from './journal-row-builders' import type { JournalLifecycleBatchInput, JournalResolvedLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -18,8 +21,9 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - enqueueEach: JournalRowWriter['enqueueEach'] - enqueueRows: JournalRowWriter['enqueueRows'] + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} @@ -75,11 +79,15 @@ export class JournalLifecycleBatchAppender { } /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows that nothing else lands between, in the order resolved. */ + * rows, committed in one transaction, in the order resolved. */ appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { return this.deps - .enqueueEach(() => - partitionJournalLifecycleMutations(input.settlementId, input.resolve()) + .enqueueRows(() => { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) .filter((chunk) => !this.wasApplied(chunk.settlementId)) .map((chunk) => journalLifecycleBatchRowBuilder( @@ -89,13 +97,26 @@ export class JournalLifecycleBatchAppender { input ) ) - ) + }) .then((rows) => { const last = rows.at(-1) return last ? { epoch: last.epoch, sequence: last.seq } : null }) } + private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { + const { aliases } = this.deps.state() + const seen = new Set() + for (const mutation of mutations) { + const itemId = agentJournalItemKey(mutation.identity) + const resolved = aliases.get(itemId) ?? itemId + if (seen.has(resolved)) { + throw new Error('journal_resolved_lifecycle_batch_names_item_twice') + } + seen.add(resolved) + } + } + private wasApplied(settlementId: string): boolean { return this.deps.state().appliedSettlementIds.has(settlementId) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 312635c60a77..dde1a66741d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -46,44 +46,29 @@ export class JournalRowWriter { hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { - return this.deps.serialize(() => this.write(build, hook, receipt)) - } - - /** Rows chosen at this write's turn in the queue and written back to back, so no other write - * lands between them; each commits alone and is built after the one before it is adopted, so a - * later row may revise an earlier one. `enqueueRows` is the all-or-nothing form. */ - enqueueEach( - resolve: () => readonly ((seq: number, ts: number) => JournalRow)[] - ): Promise { - return this.deps.serialize(() => resolve().map((build) => this.write(build))) - } - - private write( - build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, - receipt?: JournalOperationReceipt - ): JournalRow { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const row = build(this.deps.nextSequence(), this.deps.now()) - assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => { - insertJournalRow(db, this.deps.sessionId, row) - hook?.(db, row) - receipt?.write(db) - this.runBookkeeping(db, row) - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - // COMMIT landed, so the row is durable: adopt it before anything that can - // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. The ledger first: it cannot throw, the fold can. - receipt?.committed() - this.deps.commit(row) - return row + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const row = build(this.deps.nextSequence(), this.deps.now()) + assertJournalFence(row.fence, this.deps.highestFence()) + try { + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + receipt?.write(db) + this.runBookkeeping(db, row) + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + // COMMIT landed, so the row is durable: adopt it before anything that can + // fail. Rejecting here instead would leave the next append reusing a + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() + this.deps.commit(row) + return row + }) } /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 6a77b8001032..ba8796964692 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -125,7 +125,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, cursor: host.cursor, enqueue: host.enqueue, - enqueueEach: (resolve) => rowWriter.enqueueEach(resolve), enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } From e1c3ccdaa3c93c99c08d95764b979948f3d162b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:13:40 -0700 Subject: [PATCH 13/26] 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. --- .../agent-journal-turn-row-revision.ts | 9 +- ...tructured-agent-session-transition.test.ts | 119 +++++++++++++++--- .../structured-agent-session-transition.ts | 24 +--- 3 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index 69356c406784..dfede8c46f7c 100644 --- a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -24,8 +24,7 @@ import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/s import type { StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal, - StructuredAgentSessionRevisionOptions, - StructuredAgentSessionSinkAdmission + StructuredAgentSessionRevisionOptions } from '../agent-session-wire/structured-agent-session-event-sink' /** The row a write revises: a known one, or the newest turn in the journal. */ @@ -203,7 +202,7 @@ export function writeAgentJournalTurnRow( target: AgentJournalTurnRowTarget, write: AgentJournalTurnRowWrite, { publish, options: delivery = {} }: AgentJournalTurnRowDelivery -): StructuredAgentSessionSinkAdmission { +): void { // A turn record belongs to no turn. const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem @@ -214,10 +213,10 @@ export function writeAgentJournalTurnRow( sink.publish() } } - return { accepted: true } + return } const reservedBytes = agentJournalTurnRowReservedBytes(target, write) - return revise.call( + revise.call( sink, reservedBytes, (journal) => resolveAgentJournalTurnRowWrite(journal, target, write, reservedBytes), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 1b242dd8d983..f752a2147afc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -8,7 +8,11 @@ import { type AgentJournalItemIdentity, type AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -38,6 +42,15 @@ function tool(name: string, state: AgentJournalToolCallItem['state']): AgentJour return { kind: 'tool-call', name, input: { name }, state } } +function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: id, + body: tool('read', 'failed'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} + function itemStep( resolve: Extract['resolve'] ): StructuredAgentSessionTransitionStep { @@ -69,7 +82,7 @@ async function rig(watermarks: Partial = { watermarks }) const bind = () => deferred.bind({ journal, fence: 1, publish: () => publishes.push(1) }) - return { journal, deferred, sink: deferred.sink, publishes, bind } + return { root, journal, deferred, sink: deferred.sink, publishes, bind } } describe('structured agent-session transitions', () => { @@ -108,10 +121,9 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says, and hears every landing', async () => { + it('places a row where its resolver says', async () => { const { journal, deferred, sink, bind } = await rig() bind() - const landed: (readonly boolean[])[] = [] const scope = { kind: 'turn', turnItemId: 'turn-row' } as const sink.tryAppendTransition?.({ lifecycle: false, @@ -120,19 +132,44 @@ describe('structured agent-session transitions', () => { itemStep(() => ({ identity: identity('placed'), body: tool('read', 'running'), - options: { turnScope: scope, providerItemRef: 'item:read' } + options: { turnScope: scope } })), itemStep(() => null) - ], - landed: (wrote) => landed.push(wrote) + ] }) await deferred.drained() expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope, - providerItemRef: 'item:read' + turnScope: scope }) - expect(landed).toEqual([[true, false]]) + }) + + it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('kept'), body: tool('read', 'running') })), + itemStep(() => { + throw new Error('resolver failed') + }) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('kept')) + ]) + expect(publishes).toEqual([]) + expect( + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [itemStep(() => null)] + }) + ).toEqual({ accepted: false, reason: 'failed' }) }) it('refuses a transition whole, so none of its steps ever lands', async () => { @@ -165,14 +202,10 @@ describe('structured agent-session transitions', () => { sink.tryAppendItem?.(identity('done'), tool('read', 'completed'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - let wrote: readonly boolean[] = [] expect( sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ { kind: 'settlement', @@ -213,20 +246,50 @@ describe('structured agent-session transitions', () => { ).toMatchObject({ state: 'completed' }) // Two batch rows, back to back, between the last append before it and the first after it. expect(sequenceOf('after') - sequenceOf('done')).toBe(3) - expect(wrote).toEqual([true]) expect(publishes).toHaveLength(1) }) + it('leaves no row of a settlement durable when a later one of its rows fails to write', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + const running = Array.from({ length: 250 }, (_, index) => identity(`tool-${index}`)) + for (const id of running) { + sink.tryAppendItem?.(id, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + } + await deferred.drained() + const before = journal.cursor().sequence + // The settlement needs two rows; the second one's insert aborts inside the transaction. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_chunk +BEFORE INSERT ON main.journal_rows WHEN NEW.seq = ${before + 2} +BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + { + kind: 'settlement', + settlementId: 'settle-all', + reservedBytes: 1, + resolve: () => running.map(failedTool) + } + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.cursor().sequence).toBe(before) + expect( + journal + .snapshot() + .items.filter((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toEqual([]) + }) + it('writes and announces nothing when a step resolves to nothing', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() - let wrote: readonly boolean[] = [] sink.tryAppendTransition?.({ lifecycle: true, publish: true, - landed: (result) => { - wrote = result - }, steps: [ itemStep(() => null), { kind: 'settlement', settlementId: 'none', reservedBytes: 1, resolve: () => [] } @@ -235,11 +298,27 @@ describe('structured agent-session transitions', () => { await deferred.drained() expect(journal.snapshot().items).toEqual([]) - expect(wrote).toEqual([false, false]) expect(publishes).toEqual([]) }) }) +describe('resolved lifecycle batches', () => { + it('refuses, before writing anything, a settlement that names one item twice', async () => { + const { journal } = await rig() + const before = journal.cursor().sequence + const twice = identity('twice') + + await expect( + journal.appendResolvedLifecycleBatch({ + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + }) + ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') + expect(journal.cursor().sequence).toBe(before) + }) +}) + describe('coalescer text a caller writes itself', () => { it('reports unwritten streams and owes nothing once the caller marks them written', () => { const emitted: string[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index c67dab93edef..a185695d5ea5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,6 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. +// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and +// fails the sink, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -21,18 +23,10 @@ import type { import type { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' -/** What a transition step reads: rows by key or provider reference, every row, their turns, and - * the sends whose echo holds a provider item's place. */ +/** What a transition step reads: rows by key, every row, and the turns they joined. */ export type StructuredAgentSessionTransitionJournal = Pick< AgentSessionJournal, - | 'epoch' - | 'submissions' - | 'visitItems' - | 'itemBody' - | 'item' - | 'itemIdForProviderItemRef' - | 'canonicalItemId' - | 'visitItemsWithLinkage' + 'epoch' | 'visitItems' | 'itemBody' | 'item' | 'visitItemsWithLinkage' > export type StructuredAgentSessionTransitionStep = @@ -69,8 +63,6 @@ export type StructuredAgentSessionTransition = { lifecycle: boolean /** Announce the writes once they land, when any step wrote. */ publish: boolean - /** Which steps wrote a row, once every step has landed or one failed (then none counts). */ - landed?: (wrote: readonly boolean[]) => void } const STEP_OVERFLOW = 'structured agent-session transition step exceeded its reserved size' @@ -143,13 +135,7 @@ function transitionAppend( }) .then((landed) => landed !== null) ) - let wrote: boolean[] = transition.steps.map(() => false) - try { - wrote = await Promise.all(writes) - } finally { - // Heard even when a write failed, so a writer's speculation about it always ends. - transition.landed?.(wrote) - } + const wrote = await Promise.all(writes) if (transition.publish && wrote.some(Boolean)) { bound.publish() } From a4df9df2f38d307492628a3d441b5c1657140da6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:32:46 -0700 Subject: [PATCH 14/26] 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. --- .../journal-item-appender.ts | 9 +- .../agent-session-journal/journal-store.ts | 5 + .../journal-write-queue.ts | 16 ++- ...tructured-agent-session-transition.test.ts | 106 +++++++++++++----- .../structured-agent-session-transition.ts | 78 ++++++++----- 5 files changed, 152 insertions(+), 62 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 479f0bc04a9d..146078d830c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -10,12 +10,7 @@ import type { JournalRow } from './journal-row-schema' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') -export type JournalResolvedItem = { - identity: AgentJournalItemIdentity - body: AgentJournalItemBody - /** Replaces the call's options when only the fold could say where the row belongs. */ - options?: JournalItemAppendOptions -} +export type JournalResolvedItem = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } export class JournalItemAppender { constructor( @@ -53,7 +48,7 @@ export class JournalItemAppender { this.deps.state, resolved.identity, resolved.body, - resolved.options ?? options + options )(seq, ts) }) .then( diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index d12257447cfd..3cabb7bd53ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -208,6 +208,11 @@ export class AgentSessionJournal { return this.queue.owing } + /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ + get completedWrites(): number { + return this.queue.completedWrites + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd4..ae4f5c96d893 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,6 +30,7 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null + private completed = 0 constructor(private readonly sessionId: string) {} @@ -41,9 +42,20 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } + const counted = (): JournalWriteResult => { + const result = run() + this.completed++ + return result + } return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + ? this.join(counted, this.owed !== null) + : this.runNow(counted) + } + + /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads + * this learns synchronously whether the write just ahead of it landed. */ + get completedWrites(): number { + return this.completed } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index f752a2147afc..b4232642dee0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -52,11 +52,12 @@ function failedTool(id: AgentJournalItemIdentity): JournalLifecycleMutationInput } function itemStep( - resolve: Extract['resolve'] + resolve: Extract['resolve'], + reservedBytes = 4096 ): StructuredAgentSessionTransitionStep { return { kind: 'item', - reservedBytes: 4096, + reservedBytes, resolve, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } @@ -121,29 +122,6 @@ describe('structured agent-session transitions', () => { expect(publishes).toHaveLength(1) }) - it('places a row where its resolver says', async () => { - const { journal, deferred, sink, bind } = await rig() - bind() - const scope = { kind: 'turn', turnItemId: 'turn-row' } as const - sink.tryAppendTransition?.({ - lifecycle: false, - publish: true, - steps: [ - itemStep(() => ({ - identity: identity('placed'), - body: tool('read', 'running'), - options: { turnScope: scope } - })), - itemStep(() => null) - ] - }) - await deferred.drained() - - expect(journal.item(agentJournalItemKey(identity('placed')))).toMatchObject({ - turnScope: scope - }) - }) - it('admitted whole is not executed whole: a failed step keeps the ones before it and fails the sink', async () => { const { journal, deferred, sink, publishes, bind } = await rig() bind() @@ -172,6 +150,82 @@ describe('structured agent-session transitions', () => { ).toEqual({ accepted: false, reason: 'failed' }) }) + it('writes nothing after a step that overflows its reservation', async () => { + const { journal, deferred, sink, publishes, bind } = await rig() + bind() + const after = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(after) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + expect(after).not.toHaveBeenCalled() + expect(publishes).toEqual([]) + }) + + it('opens no next-turn work after a settlement the journal refused', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + const old = identity('old-tool') + sink.tryAppendItem?.(old, tool('read', 'running'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await deferred.drained() + sink.tryAppendTransition?.({ + lifecycle: true, + publish: true, + steps: [ + itemStep(() => ({ identity: identity('before'), body: tool('read', 'completed') })), + // Names one item twice, which the journal refuses. + { + kind: 'settlement', + settlementId: 'settle', + reservedBytes: 1, + resolve: () => [failedTool(old), failedTool(old)] + }, + itemStep(() => ({ identity: identity('next-turn-work'), body: tool('read', 'running') })) + ] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(old), + agentJournalItemKey(identity('before')) + ]) + expect(journal.item(agentJournalItemKey(old))?.body).toMatchObject({ state: 'running' }) + }) + + it('writes nothing after a step whose row the database refused', async () => { + const { root, journal, deferred, sink, bind } = await rig() + bind() + // Refuses only the second step's row, so the third would land in its place. + openTestJournalHostDatabase(root).db.exec(`CREATE TEMP TRIGGER fail_second_step +BEFORE INSERT ON main.journal_rows WHEN instr(NEW.row_json, 'refused-step') > 0 +BEGIN SELECT RAISE(ABORT, 'second step refused'); END`) + const step = (recordId: string) => + itemStep(() => ({ identity: identity(recordId), body: tool(recordId, 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: true, + steps: [step('first'), step('refused-step'), step('third')] + }) + + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalItemKey(identity('first')) + ]) + }) + it('refuses a transition whole, so none of its steps ever lands', async () => { const { journal, deferred, sink, bind } = await rig({ maxQueuedOperations: 1 }) const step = (recordId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..57c0c7d0f5e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -6,8 +6,8 @@ // all. At execution its steps are issued in the same tick, so they sit together in the journal's // write queue, and each step resolves against the fold with every earlier write landed — the // steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole is not executed whole: a step that fails leaves the steps before it written, and -// fails the sink, so nothing more is admitted after it. +// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the +// steps before it stay written, and the sink fails, so nothing more is admitted after it. import type { AgentJournalItemBody, @@ -32,16 +32,12 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true - /** The row and its whole body; null writes nothing. Resolved `options` replace the planned - * ones, for a writer that learns the row's turn or producer only from the fold. */ + /** The row and its whole body; null writes nothing. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions } | null options: StructuredAgentSessionItemAppendOptions } @@ -94,35 +90,25 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound + const gate = createStepGate(journal) // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step) => + const writes = transition.steps.map((step, index) => step.kind === 'item' ? journal .appendResolvedItem( () => { - const resolved = step.resolve(journal) + const resolved = gate.open(index) ? step.resolve(journal) : null if (!resolved) { + gate.wroteNothing() return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } - return { - identity: resolved.identity, - body: resolved.body, - ...(resolved.options - ? { - options: structuredAgentSessionJournalAppendOptions( - fence, - resolved.options - ) - } - : {}) - } + return resolved }, structuredAgentSessionJournalAppendOptions(fence, step.options) ) @@ -130,15 +116,53 @@ function transitionAppend( : journal .appendResolvedLifecycleBatch({ settlementId: step.settlementId, - resolve: () => step.resolve(journal), + resolve: () => (gate.open(index) ? step.resolve(journal) : []), fence }) .then((landed) => landed !== null) ) - const wrote = await Promise.all(writes) - if (transition.publish && wrote.some(Boolean)) { + // Every step, the ones a failure turned into no-ops included, has run before this settles. + const outcomes = await Promise.allSettled(writes) + const failure = outcomes.find( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + ) + if (failure) { + throw failure.reason + } + if ( + transition.publish && + outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) + ) { bound.publish() } } }) } + +/** Lets a step write only when every step before it landed. Steps run in queue order, so each + * step's write learns synchronously, from the journal's count of completed writes, whether the + * write handed over just ahead of it landed; a promise would say so only after it ran. */ +function createStepGate(journal: Pick): { + /** First thing step `index` does at its turn; false once an earlier step failed. */ + open: (index: number) => boolean + /** The step writes nothing, so whether its write completes says nothing about a failure. */ + wroteNothing: () => void +} { + let failed = false + let reached = 0 + let handedAt: number | null = null + return { + open: (index) => { + // A step that never reached its resolve failed before it: a refused import, a read-only journal. + if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { + failed = true + } + reached = index + 1 + handedAt = failed ? null : journal.completedWrites + return !failed + }, + wroteNothing: () => { + handedAt = null + } + } +} From 2faf837f84b0fd141d43a184d828e2c740bd8cc7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:36:28 -0700 Subject: [PATCH 15/26] 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. --- .../codex-provider-timeline-identity.test.ts | 409 ------------------ .../journal-terminal-settlement.ts | 13 + .../provider-timeline-apply-host.ts | 35 -- ...der-timeline-assembler-attribution.test.ts | 98 ++--- ...vider-timeline-assembler-held-rows.test.ts | 160 +++++++ ...ovider-timeline-assembler-recovery.test.ts | 402 ----------------- ...rovider-timeline-assembler-refusal.test.ts | 25 +- ...provider-timeline-assembler-replay.test.ts | 295 ------------- ...rovider-timeline-assembler-restart.test.ts | 153 +++++++ .../provider-timeline-assembler-rows.test.ts | 207 +++++++++ ...rovider-timeline-assembler-session.test.ts | 11 +- ...rovider-timeline-assembler-streams.test.ts | 107 +---- ...rovider-timeline-assembler-test-support.ts | 56 ++- .../provider-timeline-assembler-turns.test.ts | 20 +- .../provider-timeline-assembler.ts | 214 +++------ .../provider-timeline-budget.ts | 39 +- .../provider-timeline-context.ts | 41 +- .../provider-timeline-decision.ts | 125 +++--- .../provider-timeline-decisions.ts | 76 ---- .../provider-timeline-event.ts | 36 +- .../provider-timeline-identity.test.ts | 54 +++ .../provider-timeline-identity.ts | 106 +++++ .../provider-timeline-item-decisions.ts | 291 +++++++------ .../provider-timeline-plan.ts | 78 +--- .../provider-timeline-rows.ts | 110 +++++ .../provider-timeline-settlement.ts | 27 +- .../provider-timeline-state.ts | 204 +++------ .../provider-timeline-stream-rows.ts | 185 -------- .../provider-timeline-text-events.ts | 100 +++-- .../provider-timeline-text-streams.ts | 211 +++------ .../provider-timeline-transition-layout.ts | 127 ++---- .../provider-timeline-turn-decisions.ts | 302 +++++++------ ...gent-session-dead-generation-settlement.ts | 19 +- .../structured-agent-session-transition.ts | 7 +- 34 files changed, 1684 insertions(+), 2659 deletions(-) delete mode 100644 src/main/codex/codex-provider-timeline-identity.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-held-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.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 delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-decisions.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-rows.ts delete mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts diff --git a/src/main/codex/codex-provider-timeline-identity.test.ts b/src/main/codex/codex-provider-timeline-identity.test.ts deleted file mode 100644 index 84fc68f15bf3..000000000000 --- a/src/main/codex/codex-provider-timeline-identity.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -// The migration contract for moving Codex onto the shared timeline assembler: the assembler with -// the Codex identity scheme lands the same rows Codex's own translator lands today — the same keys -// (live messages, history restored on resume, a late item naming its own turn, interleaved -// subagent threads) and the same row contents. - -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - closeProviderTimelineRigs, - openProviderTimelineRig, - SESSION -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { ProviderTurnMessageOrdinals } from '../native-chat/agent-session-timeline/provider-turn-message-ordinals' -import { createCodexProviderTimelineIdentityScheme } from './codex-provider-timeline-identity' -import { - codexItemIdentity, - codexJournalItem, - type CodexThreadItem -} from './codex-structured-item-translation' -import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' - -afterEach(closeProviderTimelineRigs) - -const THREAD = 'thread-abc' -const CHILD = 'thread-child' - -async function codexRig() { - return openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => THREAD - }), - ownThread: () => THREAD - }) -} - -function body(item: CodexThreadItem, started?: CodexThreadItem) { - const translated = codexJournalItem(item, undefined, started).body - if ( - !translated || - translated.kind === 'turn' || - translated.kind === 'approval' || - translated.kind === 'question' - ) { - throw new Error(`no item body for ${item.type}`) - } - return translated -} - -const keys = (rows: AgentJournalRenderItem[]) => - rows.filter((row) => row.body.kind !== 'turn').map((row) => row.itemId) - -/** What a client renders from a row; sequence, revision and receipt time are the journal's own. */ -const content = (rows: AgentJournalRenderItem[]) => - JSON.stringify( - rows.map( - ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) => ({ - itemId, - body, - turnScope, - agentId, - parentAgentId, - providerParentRef, - producerKind, - attempt - }) - ) - ) - -function liveTranslator(rig: Awaited>) { - return createCodexJournalTranslator({ - sink: rig.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => 1_000, - schedule: (run) => { - run() - return () => {} - } - }) -} - -function notify(method: string, params: unknown): CodexStructuredSessionEvent { - return { type: 'notification', sessionId: SESSION, threadId: THREAD, method, params } -} - -describe('Codex identities through the shared assembler', () => { - it('keys messages by turn and message ordinal, and a message outside any turn by its item id', async () => { - const rig = await codexRig() - const message = ( - id: string, - text: string, - join?: { turn?: string } - ): ProviderTimelineEvent => ({ - type: 'item.close', - item: id, - body: body({ type: 'agentMessage', id, text }), - join: { thread: THREAD, ...join } - }) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply(message('item-0', 'one')) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - rig.assembler.apply(message('item-1', 'orphan')) - rig.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 3_000 }) - rig.assembler.apply(message('item-2', 'two')) - // A message naming another turn joins that turn, whatever is open. - rig.assembler.apply(message('item-3', 'late', { turn: 'turn-9' })) - - // The keys Codex's own translator writes for the same frames. - expect(keys(await rig.rows())).toEqual([ - 'codex:thread-abc:turn-1:0', - 'orca:codex-item%3Athread-abc%3Aitem-1', - 'codex:thread-abc:turn-2:0', - 'codex:thread-abc:turn-9:0' - ]) - }) - - it('keeps interleaved subagent threads apart while their rows join the open turn', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'root' }), - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-0', - body: body({ type: 'agentMessage', id: 'item-0', text: 'child' }), - join: { thread: CHILD, turn: 'turn-child' } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-1', - body: body({ type: 'agentMessage', id: 'item-1', text: 'still root' }), - join: { thread: THREAD } - }) - const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') - expect(rows.map((row) => row.itemId)).toEqual([ - 'codex:thread-abc:turn-1:0', - 'codex:thread-child:turn-child:0', - 'codex:thread-abc:turn-1:1' - ]) - expect(new Set(rows.map((row) => JSON.stringify(row.turnScope))).size).toBe(1) - }) - - it('counts an echoed send in its turn’s message ordinals, as resume history does', async () => { - const rig = await codexRig() - const ordinals = new ProviderTurnMessageOrdinals() - const oracle = (item: CodexThreadItem) => - agentJournalItemKey(codexItemIdentity({ threadId: THREAD, turnId: 'turn-1', item, ordinals })) - const echo: CodexThreadItem = { type: 'userMessage', id: 'item-0', content: [] } - const reasoning: CodexThreadItem = { type: 'reasoning', id: 'item-1', summary: ['why'] } - const answer: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'answer' } - const expected = [oracle(echo), oracle(reasoning), oracle(answer)] - - rig.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - rig.assembler.apply({ - type: 'input.accepted', - clientMessageId: 'send-1', - requestedAt: 900, - join: { thread: THREAD, turn: 'turn-1', item: 'item-0' } - }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'reasoning', - text: 'why', - join: { thread: THREAD } - }) - rig.assembler.apply({ - type: 'item.close', - item: 'item-2', - body: body(answer), - join: { thread: THREAD } - }) - rig.assembler.apply({ type: 'turn.end', turn: 'turn-1', at: 2_000, state: 'completed' }) - - expect(keys(await rig.rows())).toEqual(expected.slice(1)) - expect(expected[2]).toBe('codex:thread-abc:turn-1:1') - }) -}) - -describe('Codex rows through the shared assembler', () => { - it('lands the same rows as the Codex translator for a live turn', async () => { - const codex = await openProviderTimelineRig() - let clock = 1_000 - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD, - now: () => clock, - schedule: (run) => { - run() - return () => {} - } - }) - const notify = (method: string, params: unknown): CodexStructuredSessionEvent => ({ - type: 'notification', - sessionId: SESSION, - threadId: THREAD, - method, - params - }) - const started: CodexThreadItem = { - type: 'commandExecution', - id: 'exec-1', - command: 'ls', - status: 'inProgress' - } - const finished: CodexThreadItem = { - ...started, - status: 'completed', - exitCode: 0, - aggregatedOutput: 'ok' - } - const reply: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'Hello' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/started', { item: { ...reply, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello' })) - translator.handle(notify('item/completed', { item: reply })) - translator.handle(notify('item/started', { item: started })) - translator.handle(notify('item/completed', { item: finished })) - clock = 2_000 - translator.handle( - notify('turn/completed', { turn: { id: 'turn-1', status: 'completed', durationMs: 900 } }) - ) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-1' }, - channel: 'assistant', - text: 'Hello', - join - }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(reply), join }) - shared.assembler.apply({ type: 'item.open', item: 'exec-1', body: body(started), join }) - shared.assembler.apply({ - type: 'item.close', - item: 'exec-1', - body: body(finished, started), - join - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 2_000, - state: 'completed', - outcome: 'success', - durationMs: 900 - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows as the Codex translator for a turn restored from history', async () => { - const turn = { - id: 'turn-1', - status: 'completed', - startedAt: 1_700_000_000, - completedAt: 1_700_000_050, - items: [ - { type: 'userMessage', id: 'user-1', content: [{ type: 'text', text: 'ask' }] }, - { type: 'reasoning', id: 'think-1', summary: ['why'] }, - { type: 'agentMessage', id: 'interim-1', text: 'looking' }, - { type: 'commandExecution', id: 'exec-1', command: 'ls', status: 'completed', exitCode: 0 }, - { type: 'agentMessage', id: 'answer-1', text: 'answer' } - ] - } - const codex = await openProviderTimelineRig() - const translator = createCodexJournalTranslator({ - sink: codex.eventSink, - sessionId: SESSION, - primaryThreadId: () => THREAD - }) - expect(translator.restoreThread(THREAD, { turns: [turn] })).toEqual({ accepted: true }) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: turn.startedAt * 1000 }) - for (const item of turn.items) { - const translated = codexJournalItem(item).body - if ( - translated && - translated.kind !== 'turn' && - translated.kind !== 'approval' && - translated.kind !== 'question' - ) { - shared.assembler.apply({ type: 'item.close', item: item.id, body: translated, join }) - } - } - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: turn.completedAt * 1000, - state: 'completed', - outcome: 'success' - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) - - it('lands the same rows across a restart in the middle of a streamed message', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const first: CodexThreadItem = { type: 'agentMessage', id: 'item-1', text: 'First' } - const second: CodexThreadItem = { type: 'agentMessage', id: 'item-2', text: 'Second' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: first })) - translator.handle(notify('item/started', { item: { ...second, text: '' } })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'Sec' })) - translator.handle(notify('item/agentMessage/delta', { itemId: 'item-2', delta: 'ond' })) - translator.handle(notify('item/completed', { item: second })) - - const shared = await codexRig() - const join = { thread: THREAD, turn: 'turn-1' } - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ type: 'item.close', item: 'item-1', body: body(first), join }) - shared.assembler.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'Sec', - join - }) - await shared.rows() - // The host restarts while Codex is still streaming item-2. - const restarted = shared.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'item-2' }, - channel: 'assistant', - text: 'ond', - join - }) - restarted.apply({ type: 'item.close', item: 'item-2', body: body(second), join }) - - const rows = await shared.rows() - expect(content(rows)).toBe(content(await codex.rows())) - // Codex message rows carry the item id their ordinal identity cannot spell. - expect( - rows.filter((row) => row.body.kind === 'message').map((row) => row.providerItemRef) - ).toEqual([expect.stringContaining('item-1'), expect.stringContaining('item-2')]) - }) - - it('lands the same rows, and no second copy, when a late repeat outlives the join cache', async () => { - const codex = await openProviderTimelineRig() - const translator = liveTranslator(codex) - const shared = await codexRig() - const old: CodexThreadItem = { type: 'agentMessage', id: 'old-1', text: 'Old' } - translator.handle(notify('turn/started', { turn: { id: 'turn-1' } })) - translator.handle(notify('item/completed', { item: old })) - translator.handle(notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })) - translator.handle(notify('turn/started', { turn: { id: 'turn-2' } })) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-1', at: 1_000 }) - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - shared.assembler.apply({ - type: 'turn.end', - turn: 'turn-1', - at: 1_000, - state: 'completed', - outcome: 'success' - }) - shared.assembler.apply({ type: 'turn.open', turn: 'turn-2', at: 1_000 }) - for (let index = 0; index < 1_025; index += 1) { - const item: CodexThreadItem = { type: 'agentMessage', id: `item-${index}`, text: 'small' } - translator.handle(notify('item/completed', { item })) - shared.assembler.apply({ - type: 'item.close', - item: item.id, - body: body(item), - join: { thread: THREAD } - }) - if (index % 32 === 31) { - await Promise.all([codex.rows(), shared.rows()]) - } - } - // Long after its join left memory, Codex repeats the old message with no turn of its own. - shared.assembler.apply({ - type: 'item.close', - item: 'old-1', - body: body(old), - join: { thread: THREAD } - }) - - expect(content(await shared.rows())).toBe(content(await codex.rows())) - }) -}) 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 1dd1115ebd19..b09d387603ea 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-apply-host.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts deleted file mode 100644 index 12804f3150b3..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-apply-host.ts +++ /dev/null @@ -1,35 +0,0 @@ -// What the event appliers share with the assembler that owns them. - -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 type { ProviderTimelineContext } from './provider-timeline-context' -import type { ProviderTimelineDropRule } from './provider-timeline-decisions' -import type { ProviderTimelinePlan } from './provider-timeline-plan' -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 -} - -/** An admitted transition that has not landed: what it changes in the forecast until it does. */ -export type ProviderTimelineInFlight = { - executed: boolean - commit: ((state: ProviderTimelineState) => void) | undefined -} - -export type ProviderTimelineApplyHost = { - context: ProviderTimelineContext - streams: ProviderTimelineTextStreams - /** The forecast now; it is replaced as transitions land. */ - forecast(): ProviderTimelineState - journal(): StructuredAgentSessionTransitionJournal | null - admits(hold: ProviderTimelineHold): boolean - submit( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission -} 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 index 92238e697090..c49d266e3432 100644 --- 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 @@ -4,19 +4,15 @@ 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 { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' import { - assistantText, backgroundTask, backgroundTaskState, closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, - SESSION, type ProviderTimelineRig } from './provider-timeline-assembler-test-support' @@ -93,43 +89,28 @@ describe('a request key reused in another turn', () => { }) }) - it('is a replay when it names the turn that is over', async () => { - const rig = await answeredInAnEarlierTurn() - expect( - rig.assembler.apply({ - type: 'request.open', - request: 'p1', - body: pendingApproval, - join: { turn: 't1' } - }).dropped - ).toBe('request-replayed') - expect(await rig.row(second)).toBeUndefined() - }) - - it('decides at execution: queued before bind, a replay of the old turn writes nothing', async () => { - const rig = await answeredInAnEarlierTurn() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - const join = { turn: 't1' } + 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( - assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval, join }).dropped + restarted.apply({ type: 'request.open', request: '0', body: pendingApproval }).dropped ).toBeUndefined() - await bind() - expect(await rig.row(second)).toBeUndefined() - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved' } + // 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('decides at execution: queued before bind, a new prompt in the live turn opens', 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 }) - await bind() - expect((await rig.row(second))?.body).toMatchObject({ resolution: { state: 'pending' } }) - }) - - it('decides at execution: a turn another writer ended while it was queued takes no prompt', async () => { + 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' } @@ -144,7 +125,9 @@ describe('a request key reused in another turn', () => { { fence: 1, turnScope: { kind: 'thread' } } ) await bind() - expect(await rig.row(second)).toBeUndefined() + expect( + await rig.row(providerItemId('request', 'p1', { generation: 'gen-unbound' })) + ).toBeUndefined() }) }) @@ -203,23 +186,24 @@ describe('a send that names its turn', () => { }) describe('a background task', () => { - it('outlives its turn after a restart, and settles on its own update', async () => { + 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') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) + rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) expect(await backgroundTaskState(rig, 'bg')).toBe('working') expect( - restarted.apply({ type: 'item.close', item: 'bg', body: backgroundTask('bg', 'done') }) + 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( - restarted.apply({ type: 'item.update', item: 'bg', body: backgroundTask('bg', 'working') }) - .dropped + rig.assembler.apply({ + type: 'item.update', + item: 'bg', + body: backgroundTask('bg', 'working') + }).dropped ).toBe('item-settled') expect(await backgroundTaskState(rig, 'bg')).toBe('done') }) @@ -237,29 +221,3 @@ describe('a background task', () => { expect(await backgroundTaskState(rig, 'ran')).toBe('done') }) }) - -describe('message places after a restart', () => { - it('continues past the highest place the journal holds, not the first gap', async () => { - const rig = await openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - await rig.journal.appendItem( - { provider: 'codex', threadId: 'root', turnId: 't1', ordinal: 2 }, - assistantText('old'), - { fence: 1, turnScope: { kind: 'thread' }, providerItemRef: 'item:old' } - ) - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'item.close', - item: 'new', - body: assistantText('new'), - join: { thread: 'root', turn: 't1' } - }) - const placed = (await rig.rows()).find((row) => messageText(row.body) === 'new') - expect(placed?.itemId).toBe('codex:root:t1:3') - }) -}) 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 000000000000..8d01dfbbb4d7 --- /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-recovery.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts deleted file mode 100644 index b63560978336..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-recovery.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -// Memory is a cache: what the assembler forgot (a restart, an evicted entry, a write the journal -// rejected, an event the sink refused) never changes which row an event lands on or what it says. - -import { afterEach, describe, expect, it } from 'vitest' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { createCodexProviderTimelineIdentityScheme } from '../../codex/codex-provider-timeline-identity' -import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' -import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' -import { createProviderTimelineAssembler } from './provider-timeline-assembler' -import { - AGENT, - assistantText, - backgroundTask, - closeProviderTimelineRigs, - messageText, - NAMESPACE, - openProviderTimelineRig, - pendingApproval, - providerItemId, - providerTurnId, - providerTurnItemId, - refusingSink, - runningTool, - SESSION -} from './provider-timeline-assembler-test-support' -import { providerTimelineSink } from './provider-timeline-plan' - -afterEach(closeProviderTimelineRigs) - -const codexRig = () => - openProviderTimelineRig({ - scheme: createCodexProviderTimelineIdentityScheme({ - sessionId: SESSION, - primaryThreadId: () => 'root' - }), - ownThread: () => 'root' - }) - -const messages = async (rig: Awaited>) => - (await rig.rows()).filter((row) => row.body.kind === 'message') - -describe('a forgotten join finds its original row', () => { - it('finishes an in-flight message after a restart without overwriting an earlier one', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm0', body: assistantText('First'), join }) - rig.assembler.apply({ type: 'item.open', item: 'm1', body: assistantText('Partial'), join }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'item.close', item: 'm1', body: assistantText('Second'), join }) - restarted.apply({ type: 'item.close', item: 'm2', body: assistantText('Third'), join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'First'], - ['codex:root:t1:1', 'Second'], - ['codex:root:t1:2', 'Third'] - ]) - }) - - it('resumes an in-flight streamed message after a restart from the text it already holds', async () => { - const rig = await codexRig() - const join = { thread: 'root', turn: 't1' } - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'Hel', - join - }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ - type: 'text.delta', - item: { id: 'm0' }, - channel: 'assistant', - text: 'lo', - join - }) - restarted.apply({ type: 'text.close', item: { id: 'm0' }, join }) - - expect((await messages(rig)).map((row) => [row.itemId, messageText(row.body)])).toEqual([ - ['codex:root:t1:0', 'Hello'] - ]) - }) - - it('reaches a message in its own turn after its join was evicted', async () => { - const rig = await codexRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ - type: 'item.close', - item: `m-${index}`, - body: assistantText('small'), - join: { thread: 'root' } - }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'old-message', - body: assistantText('Old'), - join: { thread: 'root' } - }) - - const old = (await rig.rows()).filter((row) => messageText(row.body) === 'Old') - expect(old.map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) - - it('keeps a late background task update in the turn it opened in after its join was evicted', 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' }) - rig.assembler.apply({ type: 'turn.open', turn: 't2', at: 3_000 }) - for (let index = 0; index < 1_025; index += 1) { - rig.assembler.apply({ type: 'item.close', item: `m-${index}`, body: assistantText('small') }) - if (index % 32 === 31) { - await rig.rows() - } - } - rig.assembler.apply({ - type: 'item.close', - item: 'background-tool', - body: backgroundTask('background-tool', 'done') - }) - - expect((await rig.row(providerItemId('item', 'background-tool')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('t1') - }) - }) - - it('withdraws the request incarnation a previous run opened', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - rig.assembler.apply({ type: 'request.withdrawn', request: 'p1' }) - rig.assembler.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ type: 'request.withdrawn', request: 'p1' }) - - expect( - (await rig.row(providerItemId('request', 'p1', { incarnation: 2 })))?.body - ).toMatchObject({ resolution: { state: 'cancelled' } }) - }) -}) - -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 codexRig() - 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([ - ['codex:root:t1:0', 'Root'], - ['codex:child:child-turn:0', 'Child'] - ]) - }) - - it('never lets replayed text replace a message its settled turn completed', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - rig.assembler.apply({ type: 'item.close', item: 'm1', body: assistantText('Full final text') }) - rig.assembler.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - await rig.rows() - - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ - type: 'text.delta', - item: { id: 'm1' }, - channel: 'assistant', - text: 'Full', - join: { turn: 't1' } - }).dropped - ).toBe('item-settled') - restarted.flush() - - expect(messageText((await rig.row(providerItemId('item', 'm1')))?.body)).toBe('Full final text') - }) - - 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('a write the journal rejects changes nothing the assembler knows', () => { - it('keeps a running tool open when a replayed supersede it planned before bind lands as nothing', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - - 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-2', - namespace: NAMESPACE - }) - // Planned before bind: nothing tells the planner the journal already holds both turns. - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) - await deferred.drained() - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect(assembler.openTurnId).toBe(providerTurnId('live')) - - const closed = assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - expect(closed.dropped).toBeUndefined() - await deferred.drained() - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'completed' - }) - deferred.close() - assembler.dispose() - }) - - it('takes no message ordinal for an event the sink refused', async () => { - const rig = await codexRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - const join = { thread: 'root', turn: 't1' } - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - expect( - assembler.apply({ type: 'item.close', item: 'refused', body: assistantText('No'), join }) - .admission - ).toEqual({ accepted: false, reason: 'backpressure' }) - refusing = false - assembler.apply({ type: 'item.close', item: 'accepted', body: assistantText('Yes'), join }) - - expect((await messages(rig)).map((row) => row.itemId)).toEqual(['codex:root:t1:0']) - }) -}) - -describe('reset settles the old session whatever the assembler remembers', () => { - it('writes text a reset interrupts, though nothing else was open', async () => { - const rig = await openProviderTimelineRig({ schedule: () => () => {} }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Before reset' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - - expect((await messages(rig)).map((row) => messageText(row.body))).toEqual(['Before reset']) - }) - - it('settles the journal work of a previous run as lost', 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') }) - await rig.rows() - - rig.restart({ generation: 'gen-2' }).apply({ - type: 'session.reset', - namespace: 'provider-session-2' - }) - - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ - state: 'failed' - }) - }) -}) - -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-refusal.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-refusal.test.ts index 4551013a3c64..2580926b30a2 100644 --- 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 @@ -18,7 +18,7 @@ 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.restart({ sink: refusingSink(rig.sink, () => refusing) }) + 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 @@ -36,7 +36,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('keeps observed text when the close that would write it is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: refusingSink(rig.sink, () => refusing), schedule: () => () => {} }) @@ -59,7 +59,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { const runs: (() => void)[] = [] const rig = await openProviderTimelineRig() let refusal: 'backpressure' | 'failed' | null = null - const assembler = rig.restart({ + const assembler = rig.assemble({ sink: { ...rig.sink, tryAppendTransition: (transition) => @@ -90,7 +90,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { 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.restart({ sink: refusingSink(rig.sink, () => refusing) }) + 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 @@ -115,7 +115,7 @@ describe('a refused event changes nothing and its retry lands it once', () => { it('leaves the open turn in place when a superseding open is refused', async () => { const rig = await openProviderTimelineRig() let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) + 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 @@ -128,21 +128,6 @@ describe('a refused event changes nothing and its retry lands it once', () => { expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', outcome: 'superseded' }) expect(await rig.turn('t2')).toMatchObject({ state: 'running' }) }) - - it('keeps the old session open when the reset that would settle it is refused', async () => { - const rig = await openProviderTimelineRig() - let refusing = false - const assembler = rig.restart({ sink: refusingSink(rig.sink, () => refusing) }) - assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) - refusing = true - const reset = { type: 'session.reset', namespace: 'provider-session-2' } as const - expect(assembler.apply(reset).admission).toEqual(BACKPRESSURE) - expect(assembler.openTurnId).not.toBeNull() - refusing = false - assembler.apply(reset) - expect(assembler.openTurnId).toBeNull() - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - }) }) describe('what the assembler holds open is bounded', () => { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts deleted file mode 100644 index bb0eee27f429..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-replay.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -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('a replay never changes what the journal already holds', () => { - it('keeps an answered prompt answered when a restarted host replays its open and its turn', 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 itemId = providerItemId('request', 'p1') - const identity = parseAgentJournalItemKey(itemId) - if (!identity) { - throw new Error('request key did not parse') - } - // The answer path's compare-and-set lands first, with no word to the assembler. - await rig.journal.appendItem(identity, answered, { - fence: 1, - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('t1') } - }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed', outcome: 'success' }) - expect((await rig.row(itemId))?.body).toMatchObject({ resolution: { state: 'resolved' } }) - - const restarted = rig.restart({ generation: 'gen-2' }) - expect(restarted.apply({ type: 'turn.open', turn: 't1', at: 3_000 }).dropped).toBe( - 'turn-replayed' - ) - expect( - restarted.apply({ type: 'request.open', request: 'p1', body: pendingApproval }).dropped - ).toBe('request-replayed') - restarted.apply({ type: 'turn.end', at: 4_000, state: 'completed' }) - expect((await rig.row(itemId))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) - - it('does not let a replayed open of a settled turn supersede the live one', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - restarted.apply({ type: 'item.open', item: 'live-tool', body: runningTool('read') }) - const live = restarted.openTurnId - - expect(restarted.apply({ type: 'turn.open', turn: 'old', at: 1_000 }).dropped).toBe( - 'turn-replayed' - ) - expect(restarted.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running' }) - expect((await rig.row(providerItemId('item', 'live-tool')))?.body).toMatchObject({ - state: 'running' - }) - }) - - it('settles a turn the journal holds running even when the assembler that opened it is gone', 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') }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.end', turn: 't1', at: 2_000, state: 'completed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'completed', completedAt: 2_000 }) - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - }) - - it('takes its open turn back from the journal when a planned open found the row already there', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 1_000 }) - await rig.rows() - const live = rig.assembler.openTurnId - // The plan reads no journal yet (as before it binds); the write still asks it when it runs. - let lagging = true - const stale = rig.restart({ - sink: { ...rig.sink, journalItems: () => (lagging ? null : rig.sink.journalItems()) }, - generation: 'gen-2' - }) - expect(stale.apply({ type: 'turn.open', turn: 'live', at: 1_000 }).dropped).toBeUndefined() - lagging = false - await rig.rows() - await Promise.resolve() - expect(stale.openTurnId).toBe(live) - expect(await rig.turn('live')).toMatchObject({ state: 'running', startedAt: 1_000 }) - }) - - 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' - }) - }) - - it('does not reopen a settled tool a restarted host replays', 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') }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool', - body: { ...runningTool('read'), state: 'completed' } - }) - await rig.rows() - const restarted = rig.restart({ generation: 'gen-2' }) - expect( - restarted.apply({ type: 'item.open', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-replayed') - expect( - restarted.apply({ type: 'item.update', item: 'tool', body: runningTool('read') }).dropped - ).toBe('item-settled') - 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 it could not see when it planned the open', 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') } - }) - const blind = rig.restart({ - sink: { ...rig.sink, journalItems: () => null }, - generation: 'gen-2' - }) - blind.apply({ type: 'request.open', request: 'p1', body: pendingApproval }) - expect((await rig.row(providerItemId('request', 'p1')))?.body).toMatchObject({ - resolution: { state: 'resolved', selectedOptionId: 'allow' } - }) - }) -}) - -describe('a new provider session is a new id space', () => { - it('gives a reused provider turn id its own row after a reset', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', at: 2_000, state: 'completed' }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', turn: '1', at: 3_000 }) - - expect((await rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) - expect(await rig.turn('1', 'provider-session-2')).toMatchObject({ startedAt: 3_000 }) - }) - - it('does not overwrite an earlier session’s item when the provider reuses its id', async () => { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('first'), state: 'completed' } - }) - rig.assembler.apply({ - type: 'session.ended', - verdict: { state: 'interrupted', completedAt: 2_000 } - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ - type: 'item.close', - item: 'tool-1', - body: { ...runningTool('second'), state: 'completed' } - }) - const tools = (await rig.rows()).filter((row) => row.body.kind === 'tool-call') - expect(tools.map((row) => row.body)).toEqual([ - expect.objectContaining({ name: 'first' }), - expect.objectContaining({ name: 'second' }) - ]) - }) - - it('settles the open work of a session reset before anything ended it, as lost', 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') }) - rig.assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Half' - }) - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } }) - - expect((await rig.row(providerItemId('item', 'tool')))?.body).toMatchObject({ state: 'failed' }) - expect(await rig.turn('t1')).toMatchObject({ state: 'unverifiable' }) - expect((await rig.rows()).filter((row) => row.body.kind === 'message')).toHaveLength(1) - }) -}) 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 000000000000..5b4033e3ff6d --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-restart.test.ts @@ -0,0 +1,153 @@ +// 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 type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig, + openUnboundProviderTimelineAssembler, + pendingApproval, + providerItemId, + providerTurnItemId, + runningTool, + SESSION, + type ProviderTimelineRig +} from './provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from './provider-timeline-event' + +afterEach(closeProviderTimelineRigs) + +const userText = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] +}) + +/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ +const history: ProviderTimelineEvent[] = [ + { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, + { type: 'turn.open', turn: 'p1', at: 1_000 }, + { + type: 'text.delta', + item: { id: 'a1' }, + channel: 'assistant', + text: 'Looking', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, + { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, + { + type: 'item.close', + item: 'call-1', + body: { ...runningTool('read'), state: 'completed' }, + join: { turn: 'p1' } + }, + { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } +] + +async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { + return (await rig.rows()).map((row) => [row.itemId, row.revision]) +} + +describe('an adopted session', () => { + it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { + const rig = await openProviderTimelineRig() + history.forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + + const user = await rig.row(providerItemId('item', 'u1')) + expect(user).toMatchObject({ + body: userText('Fix it'), + turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } + }) + expect(await rig.turn('p1')).toMatchObject({ + state: 'completed', + outcome: 'success', + userItemId: user?.itemId + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'completed' + }) + + // Adoption run again by the next child (its first try was interrupted): nothing new lands. + const before = await revisions(rig) + const again = await rig.restart() + history.forEach((event) => again.apply(event)) + again.flush() + expect(await revisions(rig)).toEqual(before) + }) + + it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { + const rig = await openProviderTimelineRig() + history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + rig.assembler.flush() + await rig.rows() + // The child died with the turn and its tool running; the next acquisition sweeps them. + await rig.restart() + const swept = await rig.turn('p1') + expect(swept?.state).not.toBe('running') + const before = (await rig.rows()).length + + // The new child replays the whole history before its sink binds: only the writes can see it. + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) + assembler.flush() + await bind() + + expect(await rig.rows()).toHaveLength(before) + expect(await rig.turn('p1')).toEqual(swept) + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') + }) +}) + +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: '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' + }) + 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 000000000000..8e4de9e95ae9 --- /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 index 3bc3aeb3caa5..fff993850eb1 100644 --- 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 @@ -67,7 +67,7 @@ describe('provider timeline session end', () => { expect(turn).not.toHaveProperty('outcome') }) - it('drops everything after the session ended until a reset starts a fresh namespace', async () => { + 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 @@ -81,16 +81,9 @@ describe('provider timeline session end', () => { rig.assembler.apply({ type: 'item.open', item: 'call-a', body: runningTool('read') }).dropped ).toBe('session-ended') expect(rig.assembler.openTurnId).toBeNull() - - rig.assembler.apply({ type: 'session.reset', namespace: 'provider-session-2' }) - rig.assembler.apply({ type: 'turn.open', at: 3_000 }) - const second = rig.assembler.openTurnId - expect(second).not.toBeNull() - expect(second).not.toBe(first) const turns = (await rig.rows()).filter((row) => row.body.kind === 'turn') expect(turns.map((row) => row.body)).toEqual([ - expect.objectContaining({ turnId: first, state: 'interrupted' }), - expect.objectContaining({ turnId: second, state: 'running' }) + expect.objectContaining({ turnId: first, state: 'interrupted' }) ]) }) }) 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 index d2094d9a4399..95b28378dfb3 100644 --- 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 @@ -7,49 +7,32 @@ import { openProviderTimelineRig, openUnboundProviderTimelineAssembler, providerItemId, - providerTurnItemId, - type ProviderTimelineRig + providerTurnItemId } from './provider-timeline-assembler-test-support' afterEach(closeProviderTimelineRigs) -/** A journal that already holds a settled `old` turn and a running `live` one. */ -async function oldAndLiveTurns(): Promise { - const rig = await openProviderTimelineRig() - rig.assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - rig.assembler.apply({ type: 'turn.end', turn: 'old', at: 2_000, state: 'completed' }) - rig.assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - await rig.rows() - return rig -} - -async function messages(rig: ProviderTimelineRig): Promise<(string | undefined)[]> { - return (await rig.rows()) - .filter((row) => row.body.kind === 'message') - .map((row) => messageText(row.body)) -} - -describe('a stream follows the row it resolved to, not the one planning expected', () => { - it('stops at the end of the turn its row landed in, though planning expected another', async () => { - const rig = await oldAndLiveTurns() +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) - // Planned before bind, so planning expects `old` to be open; it is a replay of a settled turn. - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) + assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 }) assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - await bind() 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('live') + turnItemId: providerTurnItemId('t1') }) - - assembler.apply({ type: 'turn.end', turn: 'live', at: 4_000, state: 'completed' }) - await drained() - expect(await rig.turn('live')).toMatchObject({ state: 'completed' }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.flush() - await drained() expect(messageText((await rig.row(providerItemId('item', 'm')))?.body)).toBe('Hel') }) @@ -100,66 +83,6 @@ describe('a stream follows the row it resolved to, not the one planning expected }) }) -describe('a message boundary is the journal decision', () => { - it('keeps an anonymous message whole across a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'turn.open', turn: 'live', at: 3_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Hel' - }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'lo' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - }) - - it('still splits an anonymous message at a turn the journal really opened', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'One' - }) - assembler.apply({ type: 'turn.open', turn: 'next', at: 4_000 }) - assembler.apply({ - type: 'text.delta', - item: { stream: 'reply' }, - channel: 'assistant', - text: 'Two' - }) - assembler.apply({ type: 'text.close', item: { stream: 'reply' } }) - await bind() - expect(await messages(rig)).toEqual(['One', 'Two']) - }) - - it('resumes a named message after a queued open the journal held as a replay', async () => { - const rig = await oldAndLiveTurns() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'Hel' }) - assembler.apply({ type: 'turn.open', turn: 'old', at: 1_000 }) - assembler.apply({ type: 'text.delta', item: { id: 'm' }, channel: 'assistant', text: 'lo' }) - assembler.apply({ type: 'text.close', item: { id: 'm' } }) - await bind() - expect(await messages(rig)).toEqual(['Hello']) - expect((await rig.row(providerItemId('item', 'm')))?.turnScope).toEqual({ - kind: 'turn', - turnItemId: providerTurnItemId('live') - }) - }) -}) - 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: () => () => {} }) 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 index c7e66edb98bb..15b5963db5ea 100644 --- 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 @@ -31,6 +31,7 @@ import { type ProviderTimelineAssembler, type ProviderTimelineAssemblerDeps } from './provider-timeline-assembler' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { createLegacyProviderTimelineIdentityScheme, type ProviderTimelineItemFamily @@ -44,23 +45,25 @@ export const NAMESPACE = 'provider-session-1' const scheme = createLegacyProviderTimelineIdentityScheme({ agent: AGENT, sessionId: SESSION }) -/** The journal key the assembler gives a provider-keyed item. */ +/** The journal key the assembler gives a provider-keyed item, or a request of `generation`. */ export function providerItemId( - family: ProviderTimelineItemFamily, + family: ProviderTimelineItemFamily | 'request', key: string, - options: { namespace?: string; incarnation?: number } = {} + options: { namespace?: string; thread?: string; generation?: string; incarnation?: number } = {} ): string { return agentJournalItemKey( - scheme.item({ - namespace: options.namespace ?? NAMESPACE, - family, - key: { source: 'provider', value: key }, - thread: null, - turn: null, - itemClass: 'message', - messageOrdinal: null, - incarnation: options.incarnation ?? 1 - }) + 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 + }) ) } @@ -139,7 +142,7 @@ export async function closeProviderTimelineRigs(): Promise { } /** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that - * starts before its journal opens: it plans without the journal's view. */ + * starts before its journal opens (a resume): it admits events without the journal's view. */ export type UnboundProviderTimelineAssembler = { assembler: ProviderTimelineAssembler bind(): Promise @@ -187,8 +190,12 @@ export type ProviderTimelineRig = { sink: ProviderTimelineSink /** The same journal's event sink, for a lane that writes it directly. */ eventSink: StructuredAgentSessionEventSink - /** A fresh assembler on the same journal, as a restarted host builds. */ - restart(overrides?: Partial): ProviderTimelineAssembler + /** What a restarted host does: the dead-generation sweep settles what the old child left, then + * a new child gets a new assembler in a new generation. */ + 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`. */ @@ -258,12 +265,27 @@ export async function openProviderTimelineRig( const turn = readAgentJournalTurn(item.body) return turn ? [turn] : [] }) + let generations = 1 + const restart = async (more: Partial = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await deferred.drained() + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 1, + acquisitionGeneration: generation, + deathEvidence: null + }) + return build({ ...more, generation }) + } return { journal, assembler: build(), sink, eventSink: deferred.sink, - restart: build, + restart, + assemble: build, rows, row: async (itemId) => (await rows()).find((item) => item.itemId === itemId), turns, 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 index 69fa01b3f9d1..19a7750b089c 100644 --- 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 @@ -3,6 +3,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { closeProviderTimelineRigs, openProviderTimelineRig, + openUnboundProviderTimelineAssembler, providerTurnId, providerTurnItemId } from './provider-timeline-assembler-test-support' @@ -149,9 +150,9 @@ describe('provider timeline turns', () => { expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) - // A replayed open of a settled turn neither reopens nor rewrites it. + // 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-replayed' + 'turn-settled' ) expect(await rig.turn('turn-1')).toMatchObject({ state: 'completed', @@ -161,16 +162,18 @@ describe('provider timeline turns', () => { }) }) - it('never rewrites a settled turn when a restarted host replays its history', async () => { + 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() - // A new host process has no memory of the turn; only the journal knows it ended. - const restarted = rig.restart({ generation: 'gen-2' }) - restarted.apply({ type: 'turn.open', turn: 'turn-1', at: 5_000 }) - restarted.apply({ type: 'turn.end', at: 6_000, state: 'interrupted', outcome: 'cancellation' }) + // 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', @@ -179,5 +182,8 @@ describe('provider timeline turns', () => { 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 index af1918c24b1c..581c9703be00 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -4,56 +4,47 @@ // 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 plans each event as ONE sink transition. Which row each write lands on, whether -// a replay writes at all, and every change to what the assembler knows (its ledger) are decided -// in the transition's resolvers, at the event's turn in the journal's write queue, against the -// journal as it stands then. So there is one clock: a refused event changed nothing, a write the -// journal rejects changes nothing, and a restart or an evicted cache finds the same rows again. -// - Planning reads a forecast — the ledger plus what admitted events still in the queue will -// change — only to answer `apply` at once: dropped or not, over budget or not, the open turn. -// - The journal keeps what it owns: a person's Stop, a dead generation after a restart, and the -// answer compare-and-set. +// - 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 type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' import { PROVIDER_TIMELINE_OVER_BUDGET, providerTimelineBudgetAdmits, type ProviderTimelineHold } from './provider-timeline-budget' -import { providerTimelineLedger, type ProviderTimelineContext } from './provider-timeline-context' +import type { ProviderTimelineContext } from './provider-timeline-context' import { decideProviderTimelineEvent, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision -} from './provider-timeline-decisions' + type ProviderTimelineDecidedEvent +} from './provider-timeline-decision' import type { ProviderTimelineEvent } from './provider-timeline-event' -import { - createLegacyProviderTimelineIdentityScheme, - type ProviderTimelineIdentityScheme -} from './provider-timeline-identity' -import { ProviderTimelineJoins } from './provider-timeline-joins' +import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' +import { ProviderTimelineRows } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, - applyProviderTimelineTextDelta + applyProviderTimelineTextDelta, + type ProviderTimelineApplyResult, + type ProviderTimelineTextHost } from './provider-timeline-text-events' import { ProviderTimelineTextStreams } from './provider-timeline-text-streams' import { planProviderTimelineBarrier, - planProviderTimelineSlots + planProviderTimelineWrites } from './provider-timeline-transition-layout' -export type { ProviderTimelineApplyResult } from './provider-timeline-apply-host' -export type { ProviderTimelineDropRule } from './provider-timeline-decisions' +export type { ProviderTimelineDropRule } from './provider-timeline-decision' +export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' export type ProviderTimelineAssembler = { apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult @@ -68,15 +59,12 @@ export type ProviderTimelineAssemblerDeps = { sink: ProviderTimelineSink sessionId: string agent: AgentType - /** The acquisition: minted keys are unique per generation. */ + /** The acquisition: minted keys and request ids are unique per generation. */ generation: string - /** The provider session whose ids the adapter forwards; the same one again after a restart that - * re-attaches it, so its replays are recognised. */ + /** 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 - /** Defaults to the shared `legacy` identity arm. */ - scheme?: ProviderTimelineIdentityScheme coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } @@ -86,174 +74,96 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export function createProviderTimelineAssembler( deps: ProviderTimelineAssemblerDeps ): ProviderTimelineAssembler { - const scheme = - deps.scheme ?? - createLegacyProviderTimelineIdentityScheme({ agent: deps.agent, sessionId: deps.sessionId }) - let settlements = 0 const context: ProviderTimelineContext = { sessionId: deps.sessionId, agent: deps.agent, - joins: new ProviderTimelineJoins({ - scheme, + generation: deps.generation, + rows: new ProviderTimelineRows({ + scheme: createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }), generation: deps.generation, namespace: deps.namespace }), - ledger: new ProviderTimelineState(deps.namespace), - ...(deps.ownThread ? { ownThread: deps.ownThread } : {}), - settlementId: (what) => { - settlements += 1 - return `provider-timeline:${deps.sessionId}:${deps.generation}:${settlements}:${what}` - } + ...(deps.ownThread ? { ownThread: deps.ownThread } : {}) } + const state = new ProviderTimelineState() const streams = new ProviderTimelineTextStreams({ sink: deps.sink, context, - generation: deps.generation, ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}) }) - let forecast = context.ledger.clone() - const inFlight: ProviderTimelineInFlight[] = [] - - /** The forecast again: the ledger, plus what admitted events not yet run will change. */ - const resync = () => { - forecast = context.ledger.clone() - for (const entry of inFlight) { - if (!entry.executed) { - entry.commit?.(forecast) - } - } - } - - const submit = ( - plan: ProviderTimelinePlan, - entry: ProviderTimelineInFlight - ): StructuredAgentSessionSinkAdmission => { - if (!plan.writes) { - return plan.submit(deps.sink) - } - inFlight.push(entry) - const settle = () => { - const at = inFlight.indexOf(entry) - if (at !== -1) { - inFlight.splice(at, 1) - resync() - } - } - const admission = plan.submit(deps.sink, settle) - if (!admission.accepted) { - inFlight.splice(inFlight.indexOf(entry), 1) - return admission - } - entry.commit?.(forecast) - return admission - } - const admits = (hold: ProviderTimelineHold): boolean => - providerTimelineBudgetAdmits({ - hold, - forecast, - ledger: context.ledger, - streams: streams.open, - journal: deps.sink.journalItems() - }) - - const host: ProviderTimelineApplyHost = { - context, + const textHost = (journal: ProviderTimelineTextHost['journal']): ProviderTimelineTextHost => ({ + sink: deps.sink, + state, streams, - forecast: () => forecast, - journal: () => deps.sink.journalItems(), - admits, - submit - } + journal, + admits: (hold) => admits(hold, journal) + }) - const applyDecided = (event: ProviderTimelineDecidedEvent): ProviderTimelineApplyResult => { - const journal = deps.sink.journalItems() + const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => + providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + + const applyDecided = ( + event: ProviderTimelineDecidedEvent, + journal: ProviderTimelineTextHost['journal'] + ): ProviderTimelineApplyResult => { + const serials = state.serials() const decision = decideProviderTimelineEvent( - { context, state: forecast, journal, execute: false }, + { context, state, journal, serial: serials.next }, event ) if (decision.dropped) { return { admission: ADMITTED, dropped: decision.dropped } } - if (decision.hold && !admits(decision.hold)) { + if (decision.hold && !admits(decision.hold, journal)) { return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - const entry: ProviderTimelineInFlight = { executed: false, commit: decision.commit } - let executed: ProviderTimelineDecision | null = null - // A message boundary is the event's only when its own decision, at execution, wrote. - const landed = () => executed !== null && !executed.dropped && executed.write !== null - planProviderTimelineBarrier(host, plan, event, decision, landed) - // The event's decision on the ledger, taken once, by its first slot to run. - const decide = (at: StructuredAgentSessionTransitionJournal) => { - if (!executed) { - const ledger = providerTimelineLedger(context, at) - executed = decideProviderTimelineEvent( - { context, state: ledger, journal: at, execute: true }, - event - ) - entry.executed = true - executed.commit?.(ledger) - } - return executed.dropped ? null : executed - } - planProviderTimelineSlots(host, plan, event, decide) - if ( - event.type === 'turn.open' || - event.type === 'turn.end' || - event.type === 'session.ended' || - event.type === 'session.reset' - ) { + planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + planProviderTimelineWrites(context, plan, decision, serials.next) + plan.onAdmitted(() => { + decision.commit?.(state) + serials.commit() + }) + if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { plan.onAdmitted(() => deps.sink.setActivity?.(null)) } - return { admission: submit(plan, entry) } + return { admission: plan.submit(deps.sink) } } const apply = (event: ProviderTimelineEvent): ProviderTimelineApplyResult => { const journal = deps.sink.journalItems() if (journal) { - forecast.reconcile(journal) + state.reconcile(journal) } switch (event.type) { case 'text.delta': - return applyProviderTimelineTextDelta(host, event) + return applyProviderTimelineTextDelta(textHost(journal), event) case 'text.close': - return applyProviderTimelineTextClose(host, event) + return applyProviderTimelineTextClose(textHost(journal), event) case 'activity': { - const open = forecast.open - if (forecast.ended || !open) { - return { admission: ADMITTED, dropped: forecast.ended ? 'session-ended' : 'no-turn' } + 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 'turn.open': - return applyDecided( - event.turn === undefined ? { ...event, minted: context.joins.mint('t') } : event - ) - case 'provider.frame': - return applyDecided({ ...event, minted: context.joins.mint('f') }) - case 'input.accepted': - case 'turn.end': - case 'item.open': - case 'item.update': - case 'item.close': - case 'request.open': - case 'request.withdrawn': - case 'context.usage': - case 'session.ended': - case 'session.reset': - return applyDecided(event) + default: + return applyDecided(event, journal) } } return { apply, get openTurnId() { - return forecast.open?.turnId ?? null + return state.open?.turnId ?? null }, flush: () => streams.flush(), dispose: () => streams.dispose() 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 index 864a193818fa..a097bf689d69 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -1,12 +1,11 @@ // One admission budget for everything the assembler holds open: running items, pending requests -// and open text streams. It is derived, not booked: the open set is the forecast's obligations -// plus the open streams, one entry per item (an item and its stream share its join reference) and -// one per request key whatever its incarnation. Before refusing, the set is re-derived from the -// journal, so an answer a client gave or a row another writer 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. +// 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 or a row another writer 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 { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' 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' @@ -23,21 +22,21 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState - ledger: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - // The fold is never behind the ledger, so what it shows settled is settled in both. - for (const state of [input.forecast, input.ledger]) { - for (const [key, obligation] of state.obligations) { - const body = obligation.itemId ? input.journal?.itemBody(obligation.itemId) : null - if (body && !requiresTerminalSettlement(body)) { - state.obligations.delete(key) - } + const { journal, state } = 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) } } return fits(input) @@ -45,12 +44,14 @@ export function providerTimelineBudgetAdmits(input: { function fits(input: { hold: ProviderTimelineHold - forecast: ProviderTimelineState + state: ProviderTimelineState streams: readonly ProviderTimelineHold[] }): boolean { const open = new Map() - for (const [key, obligation] of input.forecast.obligations) { - open.set(key, obligation.bytes) + for (const [key, entry] of input.state.items) { + if (!entry.closed) { + open.set(key, entry.bytes) + } } for (const stream of input.streams) { open.set(stream.key, Math.max(open.get(stream.key) ?? 0, stream.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 index 16cdee07da9e..e65c134ad888 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -1,34 +1,30 @@ -// What every planner and resolver of one assembler shares, and where a joined row goes. +// 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 { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import type { ProviderTimelineJoin } from './provider-timeline-event' -import type { ProviderTimelineJoins, ProviderTimelinePlacement } from './provider-timeline-joins' +import { providerKey, type ProviderTimelineRows } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' export type ProviderTimelineContext = { sessionId: string agent: AgentType - joins: ProviderTimelineJoins - /** The truth decisions run on; resolvers only. */ - ledger: ProviderTimelineState + 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. */ - settlementId(what: string): string } -/** The ledger, with its open turn taken from the journal before its first decision runs, and - * ended when another writer of the journal ended it. */ -export function providerTimelineLedger( +/** A settlement id no other settlement of this journal shares. */ +export function providerTimelineSettlementId( context: ProviderTimelineContext, - journal: StructuredAgentSessionTransitionJournal -): ProviderTimelineState { - context.ledger.reconcile(journal) - return context.ledger + 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 (subagent work joins the open turn while @@ -37,21 +33,16 @@ export function providerTimelinePlacement( context: ProviderTimelineContext, state: ProviderTimelineState, join: ProviderTimelineJoin | undefined -): ProviderTimelinePlacement { +): AgentJournalTurnScope { const thread = join?.thread ?? null if (join?.turn === undefined) { - return { thread, turn: state.open?.address.key ?? null, scope: state.scope } + return state.scope } - const key = { source: 'provider', value: join.turn } as const const own = context.ownThread?.() ?? null - const subagent = thread !== null && own !== null && thread !== own - return { - thread, - turn: key, - scope: subagent - ? state.scope - : { kind: 'turn', turnItemId: context.joins.turn(key, state.namespace).itemId } + 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 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 index ee13faf44a36..bd1274934676 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -1,4 +1,10 @@ -// The shape of a decision on one non-text event, and the reads every rule shares. +// 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, @@ -10,92 +16,107 @@ import type { StructuredAgentSessionTransitionJournal } from '../agent-session-w import type { ProviderTimelineHold } from './provider-timeline-budget' import type { ProviderTimelineContext } from './provider-timeline-context' import type { ProviderTimelineEvent } from './provider-timeline-event' -import type { ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' import { - providerTimelineSettlement, - type ProviderTimelineTurnEnd -} from './provider-timeline-settlement' + decideFrame, + decideItem, + decideRequest, + decideWithdrawal +} from './provider-timeline-item-decisions' import type { ProviderTimelineState } from './provider-timeline-state' +import { + decideContextUsage, + decideHistory, + decideInput, + decideSessionEnd, + decideTurnEnd, + decideTurnOpen +} from './provider-timeline-turn-decisions' -/** Why an event wrote nothing. Each is a grammar rule the adapter broke or a replay it repeated. */ +/** 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-replayed' + | 'turn-settled' | 'turn-unknown' | 'no-turn' | 'item-settled' - | 'item-replayed' | 'request-duplicate' - | 'request-replayed' | 'request-unknown' | 'stream-unknown' | 'stream-mismatch' -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ - -/** A non-text event with the keys planning minted for it, so execution names the same ones. */ export type ProviderTimelineDecidedEvent = Exclude< ProviderTimelineEvent, { type: 'text.delta' | 'text.close' | 'activity' } -> & { minted?: ProviderTimelineKey } +> + +type Journal = StructuredAgentSessionTransitionJournal export type ProviderTimelineResolvedWrite = { identity: AgentJournalItemIdentity body: AgentJournalItemBody - options?: StructuredAgentSessionItemAppendOptions +} + +/** 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 would hold open, for the budget. */ + /** What the event holds open, for the budget. */ hold?: ProviderTimelineHold - /** The event's change to what is known: the forecast's at admission, the ledger's at execution. */ + /** The event's change to the state, made when the sink admits it. */ commit?: (state: ProviderTimelineState) => void - /** Execution only: the settlement step's mutations and the item step's write. */ - settle?: readonly JournalLifecycleMutationInput[] - write?: ProviderTimelineResolvedWrite | null - /** The item the event closes, whose stream it releases. */ + /** 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 } export type ProviderTimelineDecisionInput = { context: ProviderTimelineContext state: ProviderTimelineState - journal: StructuredAgentSessionTransitionJournal | null - /** Execution: place rows and compute writes. Planning never does. */ - execute: boolean -} - -export const providerKey = (value: string): ProviderTimelineKey => ({ source: 'provider', value }) - -export function settledTool(body: AgentJournalItemBody | null): boolean { - return body?.kind === 'tool-call' && body.state !== 'running' + /** The journal at admission; null before the sink binds. */ + journal: Journal | null + /** Serials this event takes, committed with it. */ + serial: () => number } -export function runningTool(body: AgentJournalItemBody): boolean { - return body.kind === 'tool-call' && body.state === 'running' -} - -export function pendingPrompt(body: AgentJournalItemBody | null): boolean { - return ( - (body?.kind === 'approval' || body?.kind === 'question') && body.resolution.state === 'pending' - ) -} - -export function turnOf(scope: { kind: string; turnItemId?: string }): string | null { - return scope.kind === 'turn' ? (scope.turnItemId ?? null) : null -} - -/** A turn's journal-derived settlement, at execution. */ -export function settlementOf( +export function decideProviderTimelineEvent( input: ProviderTimelineDecisionInput, - turn: ProviderTimelineTurnRef, - end: ProviderTimelineTurnEnd -): readonly JournalLifecycleMutationInput[] | undefined { - if (!input.execute || !input.journal) { - return undefined + event: ProviderTimelineDecidedEvent +): ProviderTimelineDecision { + if (input.state.ended) { + return { dropped: 'session-ended' } + } + switch (event.type) { + case 'input.accepted': + return decideInput(input, event) + case 'input.history': + return decideHistory(input, event) + case 'turn.open': + return decideTurnOpen(input, event) + case 'turn.end': + return decideTurnEnd(input, 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) } - return providerTimelineSettlement(input.journal, { turnItemId: turn.itemId }, [turn], end) } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts deleted file mode 100644 index 7f03e7267c00..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decisions.ts +++ /dev/null @@ -1,76 +0,0 @@ -// What each non-text event does: one rule set, run twice. -// -// Planning runs it on the forecast and the journal as it stands, to answer `apply` (dropped or -// not, and what it would hold open) without writing or allocating anything. Execution runs it -// again in the event's first resolver, on the ledger and the fold at that point in the journal's -// write queue: only then are rows placed, ordinals and incarnations taken, and the ledger changed. -// So when the two disagree — before bind, after a restart, behind a replay the journal rejects — -// the journal's answer is the one that lands, and the ledger follows it. - -import { - decideFrame, - decideItem, - decideRequest, - decideWithdrawal -} from './provider-timeline-item-decisions' -import type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { - decideContextUsage, - decideInput, - decideSessionEnd, - decideTurnEnd, - decideTurnOpen -} from './provider-timeline-turn-decisions' - -export type { - ProviderTimelineDecidedEvent, - ProviderTimelineDecision, - ProviderTimelineDropRule -} from './provider-timeline-decision' - -export function decideProviderTimelineEvent( - input: ProviderTimelineDecisionInput, - event: ProviderTimelineDecidedEvent -): ProviderTimelineDecision { - if (input.state.ended && event.type !== 'session.reset') { - 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 '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.verdict, (state) => { - state.ended = true - state.endSession() - }) - case 'session.reset': - // Nothing ended the old session's open work, so it is lost, not interrupted; the resolver - // finds nothing when an end already settled it. - return decideSessionEnd(input, { state: 'unverifiable' }, (state) => { - state.reset(event.namespace) - if (input.execute) { - input.context.joins.reset(event.namespace) - } - }) - } -} 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 index b3f01ff9f7e4..a745709210cd 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -4,30 +4,34 @@ // 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 -// resolves those joins to rows, scopes rows to turns, and owns the turn and item rules in -// `agent-session-journal-types.ts`; the journal decides, at each write, what a replay changes. +// 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; the journal refuses a second settlement -// and a replayed open, even after a restart. +// - 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 never +// runs again, an answered request stays answered. +// - Saved history the provider replays goes only into an empty journal (an adopted session), as +// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the +// same rows again. // - 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. -// - `dropped` is the verdict when the event was applied. The journal has the last word when the -// event's writes run: a replay the journal already holds writes nothing even if it was taken. -// - After `session.ended` every event is dropped until `session.reset`. Reset whenever the -// provider's ids may repeat (a new process): its ids then name new rows. +// - After `session.ended` every event is dropped. import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import type { AgentJournalApprovalItem, AgentJournalItemBody, + AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -66,13 +70,20 @@ 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.item`: the provider's id for its echo of the send, which takes that message's - * place among its turn's messages and writes no row (the send's own row is the bubble). */ + * 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 & { item?: string } + join?: ProviderTimelineJoin + } + /** A user message from the provider's saved history (an adopted session has no Orca send): its + * own row, keyed by `item`, and the message that opened `join.turn`. */ + | { + type: 'input.history' + item: string + body: AgentJournalMessageItem + join: ProviderTimelineJoin & { turn: string } } /** 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 } @@ -120,6 +131,3 @@ export type ProviderTimelineEvent = | ({ 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 } - /** A new provider session began; its ids live in `namespace`. Work still open is settled first, - * as lost (`unverifiable`), since nothing ended it. */ - | { type: 'session.reset'; namespace: string } 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 000000000000..6242af3998ff --- /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 000000000000..6dd47f03ec53 --- /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 index d5eb0ffb62ca..e3d9ff8ec461 100644 --- 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 @@ -1,22 +1,67 @@ -// What item, request and frame events do, decided on the forecast and again on the ledger. +// What item, request and frame events do: admitted on the state, written against the journal. -import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +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 { - pendingPrompt, providerKey, - runningTool, - settledTool, + providerTimelineTurnRowState, turnOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput -} from './provider-timeline-decision' -import { providerTimelineItemClass, type ProviderTimelineKey } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin } from './provider-timeline-joins' + 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 never runs again and keeps + * its first terminal body, a settled background task is never relit, and a turn that is over + * takes no work that waits on a settlement it already ran. */ +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 (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + return true + } + if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { + return true + } + return ( + requiresTerminalSettlement(body) && + turn !== null && + providerTimelineTurnRowState(journal, turn) === 'settled' + ) +} export function decideItem( input: ProviderTimelineDecisionInput, @@ -25,37 +70,19 @@ export function decideItem( const { state, journal, context } = input const change = event.type === 'item.open' ? 'open' : event.type === 'item.update' ? 'update' : 'close' - const join: ProviderTimelineItemJoin = { - family: 'item', - key: providerKey(event.item), - thread: event.join?.thread ?? null - } - const ref = context.joins.reference(join, state.namespace) - const found = context.joins.find(join, journal, state.namespace) - const held = found ? (journal?.itemBody(found.itemId) ?? null) : null - // This run closed it: an open reopens the same row, a second close is a repeat. - const closed = state.closed.has(ref) - if (change === 'open' && !closed && held) { - return { dropped: 'item-replayed' } - } - if (change === 'update' && runningTool(event.body) && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change === 'close' && (closed || settledTool(held))) { - return { dropped: 'item-settled' } - } - if (change !== 'open' && relightsProviderTimelineBackgroundTask(held, event.body)) { + 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 placement = providerTimelinePlacement(context, state, event.join) - const row = - found ?? - (input.execute && journal - ? context.joins.place(join, providerTimelineItemClass(event.body), placement, journal) - : null) - const scope = row?.scope ?? placement.scope - const turnItemId = turnOf(scope) const bytes = providerTimelineEntryBytes({ key: event.item, join: event.join, @@ -63,94 +90,81 @@ export function decideItem( producer: event.producer }) return { - ...(obligation ? { hold: { key: ref, bytes } } : {}), - write: row && { - identity: row.identity, - body: event.body, - options: { - ...event.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) + ...(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: ref } : {}), + ], + ...(change === 'close' ? { closes: key } : {}), commit: (next) => { - if (obligation) { - next.obligations.set(ref, { itemId: row?.itemId ?? null, turnItemId, bytes }) - } else { - next.obligations.delete(ref) - } if (change === 'close') { - next.closed.set(ref, turnItemId) - } else if (change === 'open') { - next.closed.delete(ref) + 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) } } } } -export function requestRef(input: ProviderTimelineDecisionInput, key: ProviderTimelineKey): string { - return input.context.joins.reference( - { family: 'request', key, thread: null }, - input.state.namespace - ) -} +const requestKey = (request: string) => `request:${request}` export function decideRequest( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { const { state, journal, context } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - const current = context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - // Pending in the journal, or opened here and not landed yet: the same request again. - if (pendingPrompt(held) || (state.obligations.has(ref) && held === null)) { + 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' } } - // The turn this open lands in decides whether it repeats its predecessor: the same key again - // in a turn that is over is that turn's replay; in another, live turn it is a new request. - const placement = providerTimelinePlacement(context, state, event.join) - const target = turnOf(placement.scope) - const previous = current ? turnOf(current.scope) : null - if ( - held && - previous !== null && - state.status({ itemId: previous }, journal) === 'settled' && - (target === null || - target === previous || - state.status({ itemId: target }, journal) === 'settled') - ) { - return { dropped: 'request-replayed' } - } - const row = - input.execute && journal - ? context.joins.nextRequest(key, event.body.kind, placement, journal) - : null + 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: ref, bytes }, - // A row already there is a client's answer or a replay; neither is overwritten. - write: - row && journal?.itemBody(row.itemId) === null - ? { - identity: row.identity, - body: event.body, - options: { ...event.producer, turnScope: row.scope, lifecycle: true } + 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 } - : null, - commit: (next) => - next.obligations.set(ref, { - itemId: row?.itemId ?? null, - turnItemId: turnOf(row?.scope ?? placement.scope), - bytes - }) + return { identity: row.identity, body: event.body } + } + } + ], + commit: (next) => next.items.set(key, opened) } } @@ -158,34 +172,34 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input - const key = providerKey(event.request) - const ref = requestRef(input, key) - // The latest incarnation the journal holds, however long ago this run (or another) opened it. - const current = input.context.joins.request(key, journal, state.namespace) - const held = current ? (journal?.itemBody(current.itemId) ?? null) : null - if (!pendingPrompt(held) && !(state.obligations.has(ref) && held === null)) { + const key = requestKey(event.request) + const opened = input.state.items.get(key) + if (opened?.kind !== 'request') { return { dropped: 'request-unknown' } } - // Only while the journal holds it pending: a client's answer that landed first stands. - const cancelled = current && held && pendingPrompt(held) ? cancelledJournalPromptBody(held) : null return { - ...(input.execute - ? { - settle: - current && cancelled - ? [ - { - kind: 'item' as const, - identity: current.identity, - body: cancelled, - turnScope: current.scope - } - ] - : [] + settle: { + what: 'request-withdrawn', + resolve: (journal) => { + // The open ran first and named its row. Only while it is pending: a client's answer that + // landed first stands. + const held = opened.row ? journal.item(opened.row.itemId) : null + const cancelled = + held && pendingPrompt(held.body) ? cancelledJournalPromptBody(held.body) : null + if (!opened.row || !held || !cancelled) { + return [] } - : {}), - commit: (next) => next.obligations.delete(ref) + return [ + { + kind: 'item', + identity: opened.row.identity, + body: cancelled, + turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + }, + commit: (next) => next.items.delete(key) } } @@ -193,16 +207,25 @@ export function decideFrame( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input + const { state, context } = input const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (!frame || !event.minted || !input.execute || !journal) { + if (!frame) { return {} } - const row = context.joins.place( - { family: 'frame', key: event.minted, thread: event.join?.thread ?? null }, + const row = context.rows.item( 'frame', - providerTimelinePlacement(context, state, event.join), - journal + context.rows.minted('f', input.serial()), + event.join?.thread ?? null ) - return { write: { identity: row.identity, body: frame.body, options: { turnScope: row.scope } } } + 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 index 44dec71dc576..f9e9e0e33e38 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-plan.ts @@ -1,21 +1,16 @@ // One grammar event's journal writes, admitted as one sink transition. // -// Planning lays out the writes the event may need and changes nothing. What each write says, and -// which row it lands on, is decided by its resolver when the transition reaches its turn in the -// journal's write queue; those resolvers are the only code that changes the assembler's ledger. -// `onAdmitted` work (the forecast, 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. +// 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 { - StructuredAgentSessionTransitionJournal, - StructuredAgentSessionTransitionStep -} from '../agent-session-wire/structured-agent-session-transition' +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< @@ -31,9 +26,7 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } export class ProviderTimelinePlan { private readonly steps: StructuredAgentSessionTransitionStep[] = [] private readonly admitted: (() => void)[] = [] - private readonly executions: ((journal: StructuredAgentSessionTransitionJournal) => void)[] = [] private lifecycle = false - private refusal: StructuredAgentSessionSinkAdmission | null = null item(step: Omit, lifecycle = false): void { this.steps.push({ kind: 'item', ...step }) @@ -50,38 +43,12 @@ export class ProviderTimelinePlan { this.admitted.push(change) } - /** Ledger work that rides the transition's place in the queue, after its last step resolved. */ - atExecution(change: (journal: StructuredAgentSessionTransitionJournal) => void): void { - this.executions.push(change) - } - - /** The event cannot be taken at all (over budget); nothing is submitted. */ - refuse(admission: StructuredAgentSessionSinkAdmission): void { - this.refusal ??= admission - } - - /** Whether submitting sends a transition (and so `landed` will be heard). */ - get writes(): boolean { - return this.steps.length > 0 || this.executions.length > 0 - } - - submit(sink: ProviderTimelineSink, landed?: () => void): StructuredAgentSessionSinkAdmission { - if (this.refusal) { - return this.refusal - } - if (this.executions.length > 0 && this.steps.length === 0) { - this.item({ - reservedBytes: 0, - resolve: () => null, - options: { turnScope: { kind: 'thread' } } - }) - } + submit(sink: ProviderTimelineSink): StructuredAgentSessionSinkAdmission { if (this.steps.length > 0) { const admission = sink.tryAppendTransition({ - steps: this.withExecutions(), + steps: this.steps, lifecycle: this.lifecycle, - publish: true, - ...(landed ? { landed: () => landed() } : {}) + publish: true }) if (!admission.accepted) { return admission @@ -92,35 +59,6 @@ export class ProviderTimelinePlan { } return ADMITTED } - - private withExecutions(): StructuredAgentSessionTransitionStep[] { - const last = this.steps.at(-1) - if (!last || this.executions.length === 0) { - return this.steps - } - const executions = this.executions - const finish = (journal: StructuredAgentSessionTransitionJournal) => - executions.forEach((change) => change(journal)) - const wrapped: StructuredAgentSessionTransitionStep = - last.kind === 'item' - ? { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - : { - ...last, - resolve: (journal) => { - const resolved = last.resolve(journal) - finish(journal) - return resolved - } - } - return [...this.steps.slice(0, -1), wrapped] - } } /** The sink as the assembler needs it; null for a sink without transitions or a journal view. */ 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 000000000000..72c892cfbe9f --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -0,0 +1,110 @@ +// 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 { + for (let incarnation = 1; ; incarnation += 1) { + const row = this.request(key, incarnation) + if (journal.itemBody(row.itemId) === null) { + return row + } + } + } + + 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 index dd4e92166551..b8fb5f532233 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -1,10 +1,10 @@ // What a turn's end or the session's end settles, read from the journal when the settlement runs. // -// The same mechanism the dead-generation settlement uses 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, a row a previous assembler opened is settled too, and -// a second settlement finds nothing left to do. +// 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 { @@ -17,8 +17,8 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { @@ -36,19 +36,6 @@ export type ProviderTimelineTurnEnd = } | { state: 'unverifiable' } -/** What a row still open when its turn or the session ends becomes; null when it already stands. */ -export function settledProviderTimelineBody( - 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 -} - /** 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( @@ -93,7 +80,7 @@ export function providerTimelineSettlement( // Background tasks outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null - : (settledProviderTimelineBody(body) ?? + : (terminalAgentJournalBody(body) ?? (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) const identity = settled ? parseAgentJournalItemKey(itemId) : null if (settled && identity) { 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 index a74fd943bcdd..d236f2536fab 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -1,28 +1,25 @@ -// What the assembler knows about the session, held twice. +// What the assembler knows about the session: one state, in admission order. // -// The ledger is the truth the decisions run on: it changes only inside transition resolvers, at -// each event's turn in the journal's write queue, and everything in it is either a cache of the -// journal (re-derived from the fold when missing) or this run's own memory (items it closed, the -// sends waiting for a turn). The forecast is a copy plus what admitted events still in the queue -// are expected to change; it only answers `apply` before those events land, and is rebuilt from -// the ledger as they do. - -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +// 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 { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { BoundedMap } from '../../../shared/bounded-map' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' +import { + providerTimelineTurnRowState, + type ProviderTimelineRowId, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' -/** Closed items remembered so a repeat close is dropped and an open reopens; past this, the - * journal decides from the row alone. */ +/** Closed items remembered so a repeat close is dropped; past this the journal decides alone. */ const MAX_CLOSED_ITEMS = 512 -/** Turns remembered as settled without asking the journal. */ -const MAX_SETTLED_TURNS = 256 /** 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 @@ -35,101 +32,77 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } +/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ export type ProviderTimelinePendingInput = { - clientMessageId: string - requestedAt: number - /** The turn row the provider said the send opens; absent: the next turn to open. */ + userItemId: string + requestedAt?: number + /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } -/** Work still waiting on a row that settles it: a running tool or a pending request. Keyed by - * the item's join reference (a request's by its key, whatever its incarnation). */ -export type ProviderTimelineObligation = { - /** The row, once a resolver placed it. */ - itemId: string | null +/** 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 type ProviderTimelineTurnStatus = 'absent' | 'running' | 'settled' - 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 inputs: ProviderTimelinePendingInput[] = [] - /** Items this run closed, with the turn they closed in. */ - closed = new BoundedMap({ - maxEntries: MAX_CLOSED_ITEMS, - maxBytes: MAX_CLOSED_ITEMS * 1024, - sizeOf: (turnItemId, ref) => bytes(ref) + bytes(turnItemId ?? '') + 16 - }) - settled = new BoundedMap({ - maxEntries: MAX_SETTLED_TURNS, - maxBytes: MAX_SETTLED_TURNS * 1024, - sizeOf: (_settled, turnItemId) => bytes(turnItemId) + 8 - }) - obligations = new Map() - /** Whether the open turn was taken from the journal yet (ledger only). */ - hydrated = false - - constructor(public namespace: string) {} + /** 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 } - clone(): ProviderTimelineState { - const copy = new ProviderTimelineState(this.namespace) - copy.ended = this.ended - copy.open = this.open && { ...this.open } - copy.latest = this.latest - copy.inputs = [...this.inputs] - for (const [key, value] of this.closed.entries()) { - copy.closed.set(key, value) - } - for (const [key, value] of this.settled.entries()) { - copy.settled.set(key, value) + /** 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 + } } - copy.obligations = new Map(this.obligations) - copy.hydrated = this.hydrated - return copy } - /** Settled once anything settled it: this run, or any writer of the journal (a person's Stop). - * Memory says only what the journal cannot yet: a turn opened by a write still queued. */ - status( - turn: { itemId: string }, - journal: StructuredAgentSessionTransitionJournal | null - ): ProviderTimelineTurnStatus { - if (this.settled.has(turn.itemId)) { - return 'settled' - } - const row = readAgentJournalTurn(journal?.itemBody(turn.itemId) ?? undefined) - if (row) { - return row.state === 'running' ? 'running' : 'settled' + /** Ends the open turn when another writer of the journal settled its row. */ + reconcile(journal: StructuredAgentSessionTransitionJournal): void { + if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { + this.endTurn(this.open) } - return this.open?.itemId === turn.itemId ? 'running' : 'absent' } - /** Ends the open turn when the journal holds it settled: another writer ended it. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (!this.hydrated) { - this.hydrate(journal) - } - const row = this.open && readAgentJournalTurn(journal.itemBody(this.open.itemId) ?? undefined) - if (this.open && row && row.state !== 'running') { - this.endTurn(this.open) + /** 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 send waiting for its turn; past the bounds the oldest is forgotten (its turn opens as the - * provider's own). */ + /** 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.clientMessageId) + bytes(input.turnItemId ?? '') + 16 + 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() @@ -140,7 +113,7 @@ export class ProviderTimelineState { } } - /** The send that opens `turnItemId`: the one that named it, else the oldest that named none. */ + /** 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) ?? @@ -148,69 +121,32 @@ export class ProviderTimelineState { ) } - /** The turn is over, and so is everything its settlement covered. */ + /** 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 - this.settled.set(turn.itemId, true) - for (const [key, obligation] of this.obligations) { - if (obligation.turnItemId === turn.itemId) { - this.obligations.delete(key) - } - } - // A settled turn's rows are judged by the turn now. - for (const [key, turnItemId] of this.closed.entries()) { - if (turnItemId === turn.itemId) { - this.closed.delete(key) + for (const [key, entry] of this.items) { + if (entry.turnItemId === turn.itemId) { + this.items.delete(key) } } } - /** The session is over: nothing it held is open any more. */ endSession(): void { + this.ended = true this.open = null - this.obligations.clear() - this.closed.clear() - } - - reset(namespace: string): void { - this.namespace = namespace - this.ended = false - this.open = null - this.latest = null - this.inputs = [] - this.obligations.clear() - this.closed.clear() - this.settled.clear() - } - - /** Takes the open turn from the journal: the newest turn row it holds running. Its key is the - * turn id the row carries, which is the provider's own turn id for a scheme that spells it. */ - hydrate(journal: StructuredAgentSessionTransitionJournal): void { - this.hydrated = true - if (this.open) { - return - } - let newest = -1 - journal.visitItems((itemId, sequence, body) => { - const running = readAgentJournalTurn(body) - const identity = running?.state === 'running' ? parseAgentJournalItemKey(itemId) : null - if (!running || !identity || sequence < newest) { - return - } - newest = sequence - this.open = { - address: { - namespace: this.namespace, - key: { source: 'provider', value: running.turnId } - }, - identity, - itemId, - turnId: running.turnId, - running - } - }) + this.items.clear() } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts deleted file mode 100644 index b362062b25ca..000000000000 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-stream-rows.ts +++ /dev/null @@ -1,185 +0,0 @@ -// The ledger of which row each text stream writes, changed only at its writes' turn in the queue. -// -// A stream's row is decided by its first write, like every other row: a provider-named message -// the journal already holds is one this run resumes (its turn still running: the stream adopts the -// row's text as its prefix) or a replay (its turn settled, or this run closed it: nothing is -// written). An anonymous stream continues the row of the one before it when the event that ended -// that one turned out a replay. Every later write checks again that the row is not over, against -// the journal, so a turn another writer ended stops the stream there too. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { BoundedMap } from '../../../shared/bounded-map' -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' -import { - providerTimelineLedger, - providerTimelinePlacement, - type ProviderTimelineContext -} from './provider-timeline-context' -import { turnOf } from './provider-timeline-decision' -import type { ProviderTimelineRow } from './provider-timeline-joins' -import type { ProviderTimelineState } from './provider-timeline-state' -import type { ProviderTimelineStream } from './provider-timeline-text-streams' - -/** The row a stream writes, and the text it held before this run streamed into it. */ -export type ProviderTimelineStreamRow = { row: ProviderTimelineRow; prefix: string } - -type Owned = ProviderTimelineStreamRow | 'replayed' - -/** Rows kept for the anonymous stream that may continue them, and streams stopped mid-flight - * whose queued writes must still write nothing. */ -const MAX_CARRIED_ROWS = 64 -const MAX_STOPPED_STREAMS = 512 - -function providerTimelineMessageText(body: AgentJournalItemBody | null): string { - return body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' -} - -export class ProviderTimelineStreamRows { - /** The streams planning still holds open. */ - private readonly owned = new Map() - private readonly carried = new BoundedMap({ - maxEntries: MAX_CARRIED_ROWS - }) - private readonly stopped = new BoundedMap({ maxEntries: MAX_STOPPED_STREAMS }) - - constructor( - private readonly context: ProviderTimelineContext, - /** Drops a stream planning still holds open, so its next delta starts another. */ - private readonly forget: (stream: ProviderTimelineStream) => void - ) {} - - /** At a write: the row it lands on, or null when it writes nothing. */ - resolve( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): ProviderTimelineStreamRow | null { - if (this.stopped.has(stream.id)) { - return null - } - const ledger = providerTimelineLedger(this.context, journal) - let owned = this.owned.get(stream.id) - if (!owned) { - owned = this.own(stream, ledger, journal) - this.owned.set(stream.id, owned) - } - if (owned === 'replayed' || ledger.ended) { - return null - } - // 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. - if (this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - return null - } - return owned - } - - /** The event that released `stream` ran: `held` when it really ended the stream's message. */ - release(stream: ProviderTimelineStream, held: boolean): void { - stream.boundary = held ? 'held' : 'void' - const owned = this.owned.get(stream.id) - this.owned.delete(stream.id) - // A named stream's next delta finds its row again through the journal; an anonymous one's row - // is kept for the stream that follows it. - if (!held && !stream.named && owned && owned !== 'replayed') { - this.carried.set(stream.id, owned) - } - } - - /** Stops every stream whose row is over now. */ - retire( - streams: readonly ProviderTimelineStream[], - journal: StructuredAgentSessionTransitionJournal - ): void { - const ledger = providerTimelineLedger(this.context, journal) - for (const stream of streams) { - const owned = this.owned.get(stream.id) - if (owned && owned !== 'replayed' && this.over(stream, owned.row, ledger, journal)) { - this.stop(stream) - } - } - } - - /** The turn the stream's row is in, once a write placed it; else the one planning expects. */ - turn(stream: ProviderTimelineStream): string | null { - const owned = this.owned.get(stream.id) - return owned && owned !== 'replayed' ? turnOf(owned.row.scope) : stream.turnItemId - } - - clear(): void { - this.owned.clear() - this.carried.clear() - this.stopped.clear() - } - - private own( - stream: ProviderTimelineStream, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): Owned { - const { joins } = this.context - if (stream.named && ledger.closed.has(stream.key)) { - return 'replayed' - } - const carried = this.continued(stream, journal) - if (carried) { - return carried - } - const found = stream.named ? joins.find(stream.join, journal) : null - if (found) { - const turnItemId = turnOf(found.scope) - if (turnItemId !== null && ledger.status({ itemId: turnItemId }, journal) === 'settled') { - return 'replayed' - } - return { row: found, prefix: providerTimelineMessageText(journal.itemBody(found.itemId)) } - } - const placement = providerTimelinePlacement(this.context, ledger, { - ...(stream.join.thread === null ? {} : { thread: stream.join.thread }), - ...(stream.turn === undefined ? {} : { turn: stream.turn }) - }) - const itemClass = stream.channel === 'assistant' ? 'message' : 'reasoning' - return { row: joins.place(stream.join, itemClass, placement, journal), prefix: '' } - } - - /** The row of the message this one continues: an earlier anonymous stream whose release the - * journal did not take (its event was a replay). */ - private continued( - stream: ProviderTimelineStream, - journal: StructuredAgentSessionTransitionJournal - ): Owned | null { - let previous = stream.follows - delete stream.follows - for (; previous?.boundary === 'void'; previous = previous.follows) { - const kept = this.carried.get(previous.id) - if (kept) { - this.carried.delete(previous.id) - return { - row: kept.row, - prefix: providerTimelineMessageText(journal.itemBody(kept.row.itemId)) - } - } - } - return null - } - - /** Whether the row is past streaming into: its item closed, or its turn settled. */ - private over( - stream: ProviderTimelineStream, - row: ProviderTimelineRow, - ledger: ProviderTimelineState, - journal: StructuredAgentSessionTransitionJournal - ): boolean { - const turn = turnOf(row.scope) - return ( - (stream.named && ledger.closed.has(stream.key)) || - (turn !== null && ledger.status({ itemId: turn }, journal) === 'settled') - ) - } - - /** The stream writes nothing more; a later delta under its key starts another. */ - private stop(stream: ProviderTimelineStream): void { - this.owned.delete(stream.id) - this.stopped.set(stream.id, true) - this.forget(stream) - } -} 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 index 8b793ef2139f..0cf3452440bb 100644 --- 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 @@ -1,30 +1,45 @@ -// Text events: planning-side streams, admitted like every other event. +// Text events: streams admitted like every other event. -import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' +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 { - ProviderTimelineApplyHost, - ProviderTimelineApplyResult, - ProviderTimelineInFlight -} from './provider-timeline-apply-host' +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 } from './provider-timeline-plan' +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: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const journal = host.journal() const plan = new ProviderTimelinePlan() - let stream = streams.get(streams.key(event.item, event.join, forecast)) + 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' } @@ -36,70 +51,59 @@ export function applyProviderTimelineTextDelta( stream = undefined } if (!stream) { - if ('id' in event.item && settledNamedItem(host, event, journal)) { + 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: forecast + 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: host.submit(plan, { executed: false, commit: undefined }) } + 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: ProviderTimelineApplyHost, - event: Extract, - journal: StructuredAgentSessionTransitionJournal | null -): boolean { - const { context } = host - const forecast = host.forecast() - if (!('id' in event.item)) { - return false - } - const join = { - family: 'item' as const, - key: { source: 'provider' as const, value: event.item.id }, - thread: event.join?.thread ?? null - } - if (forecast.closed.has(context.joins.reference(join, forecast.namespace))) { +function settledNamedItem(host: ProviderTimelineTextHost, key: string): boolean { + if (host.state.items.get(key)?.closed) { return true } - const row = context.joins.find(join, journal, forecast.namespace) - const turnItemId = row?.scope.kind === 'turn' ? row.scope.turnItemId : null - return turnItemId !== null && forecast.status({ itemId: turnItemId }, journal) === 'settled' + 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: ProviderTimelineApplyHost, + host: ProviderTimelineTextHost, event: Extract ): ProviderTimelineApplyResult { - const { streams } = host - const forecast = host.forecast() - if (forecast.ended) { + const { streams, state } = host + if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join, forecast)) + const stream = streams.get(streams.key(event.item, event.join)) if (!stream) { return { admission: ADMITTED, dropped: 'stream-unknown' } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) streams.planClose(plan, stream, event.text) - const entry: ProviderTimelineInFlight = { - executed: false, - commit: stream.named ? (state) => state.closed.set(stream.key, stream.turnItemId) : undefined + if (stream.named) { + // The close settles the item for full snapshots too. + plan.onAdmitted(() => state.close(stream.key, turnOf(stream.scope))) } - plan.atExecution(() => { - entry.executed = true - }) - return { admission: host.submit(plan, entry) } + 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 index 1557873426b6..da869954907d 100644 --- 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 @@ -6,11 +6,14 @@ // 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. -// Which row each write lands on is the journal's call, at the write (`provider-timeline-stream-rows.ts`). +// 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. -import type { - AgentJournalItemBody, - AgentJournalProducerLinkage +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalProducerLinkage, + type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { boundInlineText, @@ -21,77 +24,60 @@ import { type AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' -import type { - StructuredAgentSessionItemAppendOptions, - StructuredAgentSessionSinkAdmission -} from '../agent-session-wire/structured-agent-session-event-sink' 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 { BoundedMap } from '../../../shared/bounded-map' import { providerTimelineKeyPart } from './provider-timeline-identity' -import type { ProviderTimelineItemJoin, ProviderTimelineRow } from './provider-timeline-joins' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineStreamRows } from './provider-timeline-stream-rows' +import { + providerKey, + providerTimelineTurnRowState, + turnOf, + type ProviderTimelineRowId +} from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' -/** One open text stream, as planning knows it. */ +/** One open text stream. */ export type ProviderTimelineStream = { - /** Unique per stream: the coalescer's key, and the ledger's for the row it owns. */ + /** The coalescer's key, unique per stream. */ id: string - /** The stream's slot: a named stream's is its item's join reference, which the budget shares. */ + /** The stream's slot: a named stream's is its item's row id, which the budget shares. */ key: string - join: ProviderTimelineItemJoin - /** The turn the provider named, if any. */ - turn: string | undefined - /** The turn planning expects its row in, so a turn's end releases it. */ - turnItemId: string | null + 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 anonymous stream an event released just before this one began: this one continues its - * row unless that event's boundary landed. */ - follows?: ProviderTimelineStream - /** Execution: whether the event that released this stream ended its message. */ - boundary?: 'held' | 'void' -} - -type Resolved = { - identity: ProviderTimelineRow['identity'] - body: AgentJournalItemBody - options: StructuredAgentSessionItemAppendOptions + /** A write found its row's turn settled: it writes nothing more. */ + stopped: boolean } export class ProviderTimelineTextStreams { private readonly streams = new Map() private readonly byId = new Map() - private readonly rows: ProviderTimelineStreamRows - /** Per anonymous slot, the stream a boundary not yet decided released last. */ - private readonly released = new BoundedMap({ maxEntries: 64 }) private readonly coalescer - private serial = 0 constructor( private readonly deps: { sink: ProviderTimelineSink context: ProviderTimelineContext - generation: string coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } ) { - this.rows = new ProviderTimelineStreamRows(deps.context, (stream) => this.forget(stream)) this.coalescer = createAgentSessionDeltaCoalescer({ ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -103,47 +89,45 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()] + return [...this.streams.values()].filter((stream) => !stream.stopped) } /** The stream slot `item` names on its thread. */ - key( - item: ProviderTimelineTextItem, - join: ProviderTimelineJoin | undefined, - state: ProviderTimelineState - ): string { + key(item: ProviderTimelineTextItem, join: ProviderTimelineJoin | undefined): string { + const thread = join?.thread ?? null return 'id' in item - ? this.deps.context.joins.reference(this.itemJoin(item, join), state.namespace) - : `stream:${providerTimelineKeyPart(join?.thread ?? '')}:${providerTimelineKeyPart(item.stream)}` + ? 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, so the next delta starts another. */ get(key: string): ProviderTimelineStream | undefined { - return this.streams.get(key) + const stream = this.streams.get(key) + if (stream?.stopped) { + this.forget(stream) + return undefined + } + return stream } - /** A new stream; the caller has checked the budget for its `bytes`. */ + /** 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 { - this.serial += 1 + const { rows } = this.deps.context + const thread = input.join?.thread ?? null const named = 'id' in input.item - const join = - 'id' in input.item ? this.itemJoin(input.item, input.join) : this.anonymousJoin(input.join) - const placement = providerTimelinePlacement(this.deps.context, input.state, input.join) - const key = this.key(input.item, input.join, input.state) - const follows = named ? undefined : this.released.get(key) - this.released.delete(key) + const key = 'id' in input.item ? providerKey(input.item.id) : rows.minted('s', input.serial) return { - id: `${this.deps.generation}:s${this.serial}`, - key, - ...(follows ? { follows } : {}), - join, - turn: input.join?.turn, - turnItemId: placement.scope.kind === 'turn' ? placement.scope.turnItemId : null, + 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, @@ -152,7 +136,8 @@ export class ProviderTimelineTextStreams { key: 'id' in input.item ? input.item.id : input.item.stream, join: input.join, producer: input.producer - }) + }), + stopped: false } } @@ -186,43 +171,18 @@ export class ProviderTimelineTextStreams { } } - /** Ends the streams `which` selects; their text was flushed by the same event. Planning starts - * the next message at once; `landed`, asked at execution, says whether the event really ended - * them: when it did not (a replay the journal held), the next anonymous message continues - * this one's row, and a named stream's next delta resumes its row from the journal. */ + /** Ends the streams `which` selects once the event is admitted; their text was flushed by it. */ planRelease( plan: ProviderTimelinePlan, - which: (stream: ProviderTimelineStream) => boolean, - landed?: () => boolean + which: (stream: ProviderTimelineStream) => boolean ): void { const released = [...this.streams.values()].filter(which) - if (released.length === 0) { - return + if (released.length > 0) { + plan.onAdmitted(() => released.forEach((stream) => this.forget(stream))) } - plan.onAdmitted(() => - released.forEach((stream) => { - this.forget(stream) - if (landed && !stream.named) { - this.released.set(stream.key, stream) - } - }) - ) - plan.atExecution(() => - released.forEach((stream) => this.rows.release(stream, !landed || landed())) - ) - } - - /** At a turn's or the session's end: every stream whose row is over now stops. */ - planRetire(plan: ProviderTimelinePlan): void { - plan.atExecution((journal) => this.rows.retire(this.open, journal)) - } - - /** The turn a stream's row is in, once a write placed it; else the one planning expects. */ - rowTurn(stream: ProviderTimelineStream): string | null { - return this.rows.turn(stream) } - /** Ends one stream with the provider's final text, else what streamed; it settles the item. */ + /** 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) @@ -230,18 +190,8 @@ export class ProviderTimelineTextStreams { this.planText(plan, stream, owed.snapshot.text) } } else { - this.planText( - plan, - stream, - boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, - true - ) - } - if (stream.named) { - // The close settles the item for full snapshots too, in the turn its row is in. - plan.atExecution(() => - this.deps.context.ledger.closed.set(stream.key, this.rows.turn(stream)) - ) + const text = boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + this.planText(plan, stream, text, true) } this.planRelease(plan, (each) => each === stream) } @@ -252,8 +202,6 @@ export class ProviderTimelineTextStreams { dispose(): void { this.open.forEach((stream) => this.forget(stream)) - this.rows.clear() - this.released.clear() this.coalescer.dispose() } @@ -265,7 +213,7 @@ export class ProviderTimelineTextStreams { } const plan = new ProviderTimelinePlan() this.planText(plan, stream, text) - const admission: StructuredAgentSessionSinkAdmission = plan.submit(this.deps.sink) + 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' } @@ -281,16 +229,11 @@ export class ProviderTimelineTextStreams { plan.onAdmitted(() => this.coalescer.markFlushed(stream.id)) return } - const placeholder = this.message(stream, text) + const body = this.message(stream, text) plan.item({ - // Room for the text and the longest identity a scheme spells; a resumed prefix only paces. - reservedBytes: estimateStructuredAgentSessionItemBytes( - { provider: 'orca', clientMessageId: 'x'.repeat(1024) }, - placeholder - ), - paced: true, - resolve: (journal) => this.resolveText(stream, text, providerFinal, journal), - options: { ...stream.producer, turnScope: { kind: 'thread' } } + reservedBytes: estimateStructuredAgentSessionItemBytes(stream.row.identity, body), + resolve: (journal) => this.resolveText(stream, body, journal), + options: { ...stream.producer, turnScope: stream.scope } }) plan.onAdmitted(() => { stream.written = true @@ -298,27 +241,23 @@ export class ProviderTimelineTextStreams { }) } - /** At the write's turn in the queue: the row the stream owns, and its text so far. */ + /** 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, - text: string, - providerFinal: boolean, + body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal - ): Resolved | null { - const owned = this.rows.resolve(stream, journal) - if (!owned) { + ): ProviderTimelineResolvedWrite | null { + const held = journal.item(stream.row.itemId) + const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) + if ( + stream.stopped || + (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') + ) { + stream.stopped = true return null } - const { row, prefix } = owned - return { - identity: row.identity, - body: this.message(stream, providerFinal ? text : `${prefix}${text}`), - options: { - ...stream.producer, - turnScope: row.scope, - ...(row.ref === undefined ? {} : { providerItemRef: row.ref }) - } - } + return { identity: stream.row.identity, body } } private message(stream: ProviderTimelineStream, text: string): AgentJournalItemBody { @@ -329,18 +268,6 @@ export class ProviderTimelineTextStreams { } } - private itemJoin(item: { id: string }, join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { - family: 'item', - key: { source: 'provider', value: item.id }, - thread: join?.thread ?? null - } - } - - private anonymousJoin(join?: ProviderTimelineJoin): ProviderTimelineItemJoin { - return { family: 'item', key: this.deps.context.joins.mint('s'), thread: join?.thread ?? null } - } - private forget(stream: ProviderTimelineStream): void { this.coalescer.forget(stream.id) if (this.streams.get(stream.key) === stream) { 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 index 35fd7abc14b5..a51cc9064dac 100644 --- 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 @@ -1,130 +1,87 @@ -// The writes one non-text event may need, laid out before anything is decided: the text owed -// ahead of it, then its fixed settlement and item slots, each resolved when it runs. +// The writes one non-text event's transition carries, in order: the text owed ahead of it, its +// settlement, then its rows. -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -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 type { ProviderTimelineApplyHost } from './provider-timeline-apply-host' +import { + providerTimelineSettlementId, + type ProviderTimelineContext +} from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision -} from './provider-timeline-decisions' +} from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' +import { providerKey, turnOf } from './provider-timeline-rows' +import type { ProviderTimelineState } from './provider-timeline-state' +import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' -/** Room for a settled turn row and its context facts. */ -const TURN_ROW_RESERVED_BYTES = 64 * 1024 -/** Identities a scheme spells stay well under this; item rows reserve it for theirs. */ -const IDENTITY_PLACEHOLDER = { provider: 'orca', clientMessageId: 'x'.repeat(1024) } as const +/** 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. */ export function planProviderTimelineBarrier( - host: ProviderTimelineApplyHost, + input: { + streams: ProviderTimelineTextStreams + state: ProviderTimelineState + context: ProviderTimelineContext + }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, - decision: ProviderTimelineDecision, - /** Execution: whether the event's own decision wrote, so the boundary it draws is real. */ - landed: () => boolean + decision: ProviderTimelineDecision ): void { - const { streams, context } = host - const forecast = host.forecast() + const { streams, state, context } = 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, landed) + streams.planRelease(plan, (stream) => stream === replaced) } - if (event.type === 'session.ended' || event.type === 'session.reset') { - streams.planRelease(plan, () => true, landed) - streams.planRetire(plan) + if (event.type === 'session.ended') { + streams.planRelease(plan, () => true) return } if (event.type === 'turn.end' || event.type === 'turn.open') { const ending = event.type === 'turn.end' && event.turn !== undefined - ? context.joins.turn({ source: 'provider', value: event.turn }, forecast.namespace).itemId - : forecast.open?.itemId + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId streams.planRelease( plan, (stream) => (!stream.named && stream.producer?.agentId === undefined) || - (ending !== undefined && streams.rowTurn(stream) === ending), - landed + (ending !== undefined && turnOf(stream.scope) === ending) ) - // The turn that really ended is the journal's: its streams stop wherever planning placed them. - streams.planRetire(plan) 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, - landed - ) + streams.planRelease(plan, (stream) => !stream.named && stream.producer?.agentId === agentId) } } -export function planProviderTimelineSlots( - host: ProviderTimelineApplyHost, +/** The event's own settlement and rows, after the text it flushed. */ +export function planProviderTimelineWrites( + context: ProviderTimelineContext, plan: ProviderTimelinePlan, - event: ProviderTimelineDecidedEvent, - decide: (journal: StructuredAgentSessionTransitionJournal) => ProviderTimelineDecision | null + decision: ProviderTimelineDecision, + serial: () => number ): void { - const { context } = host - const settlement = (what: string) => + const { settle } = decision + if (settle) { plan.settlement({ - settlementId: context.settlementId(what), - reservedBytes: TURN_ROW_RESERVED_BYTES, - resolve: (journal) => decide(journal)?.settle ?? [] + settlementId: providerTimelineSettlementId(context, serial(), settle.what), + reservedBytes: SETTLEMENT_RESERVED_BYTES, + resolve: settle.resolve }) - const item = (reservedBytes: number, lifecycle: boolean) => + } + for (const write of decision.writes ?? []) { plan.item( { - reservedBytes, - resolve: (journal) => decide(journal)?.write ?? null, - options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + reservedBytes: write.reservedBytes, + resolve: write.resolve, + options: write.options }, - lifecycle + write.lifecycle ) - switch (event.type) { - case 'turn.open': - settlement('turn-superseded') - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'turn.end': - settlement('turn-end') - return - case 'request.withdrawn': - settlement('request-withdrawn') - return - case 'session.ended': - settlement('session-end') - return - case 'session.reset': - settlement('session-reset') - return - case 'input.accepted': - case 'context.usage': - item(TURN_ROW_RESERVED_BYTES, true) - return - case 'item.open': - case 'item.update': - case 'item.close': - item( - estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), - event.type !== 'item.update' - ) - return - case 'request.open': - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, event.body), true) - return - case 'provider.frame': { - const frame = unhandledProviderFrameJournalItem(context.agent, event.frameKind, event.payload) - if (frame) { - item(estimateStructuredAgentSessionItemBytes(IDENTITY_PLACEHOLDER, frame.body), false) - } - } } } 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 index a6ee99b15bf1..f242a7573e7b 100644 --- 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 @@ -1,4 +1,5 @@ -// What turn, send, context and session events do, decided on the forecast and again on the ledger. +// What turn, user-message, context and session events do: admitted on the state, written +// against the journal. import { AGENT_JOURNAL_THREAD_SCOPE, @@ -9,81 +10,100 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' +import { providerTimelinePlacement } from './provider-timeline-context' +import type { + ProviderTimelineDecidedEvent, + ProviderTimelineDecision, + ProviderTimelineDecisionInput, + ProviderTimelineItemWrite, + ProviderTimelineResolvedWrite +} from './provider-timeline-decision' import { providerKey, - settlementOf, - type ProviderTimelineDecidedEvent, - type ProviderTimelineDecision, - type ProviderTimelineDecisionInput, - type ProviderTimelineResolvedWrite -} from './provider-timeline-decision' -import { providerTimelinePlacement } from './provider-timeline-context' -import type { ProviderTimelineTurnRef } from './provider-timeline-joins' + providerTimelineTurnRowState, + type ProviderTimelineTurnRef +} from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns, - type ProviderTimelineTurnEnd + runningProviderTimelineTurns } from './provider-timeline-settlement' -import type { ProviderTimelineState } from './provider-timeline-state' +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, context } = input + const { state, journal, context } = input const open = state.open - const key = event.turn === undefined ? event.minted : providerKey(event.turn) - if (!key || (event.turn === undefined && open)) { + // 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.joins.turn(key, state.namespace) - if (open?.itemId === turn.itemId) { + 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 (state.status(turn, input.journal) !== 'absent') { - return { dropped: 'turn-replayed' } + if (held === 'settled') { + return { dropped: 'turn-settled' } } const pending = state.opener(turn.itemId) const running: AgentJournalTurnLifecycle = { turnId: turn.turnId, state: 'running', - userItemId: pending - ? agentJournalSubmissionKey(pending.clientMessageId) - : context.joins.turnOpener(turn), + userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending ? { requestedAt: pending.requestedAt } : {}) - } - // A newer turn ended this one, whoever asked for it. - const superseded = open - ? settlementOf(input, open, { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) - : [] + ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + } + const write: ProviderTimelineResolvedWrite = { + identity: turn.identity, + body: agentJournalTurnBody(running) + } return { - ...(superseded ? { settle: superseded } : {}), - write: input.execute + // A newer turn ended this one, whoever asked for it. + ...(open ? { - identity: turn.identity, - body: agentJournalTurnBody(running), - // 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 } + settle: { + what: 'turn-superseded', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { + state: 'interrupted', + completedAt: event.at, + outcome: 'superseded' + }) + } } - : null, + : {}), + 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 (next.open) { next.endTurn(next.open) } if (pending) { - next.inputs = next.inputs.filter( - (input) => input.clientMessageId !== pending.clientMessageId - ) + next.inputs = next.inputs.filter((each) => each !== pending) } next.open = { ...turn, running } } @@ -96,69 +116,103 @@ export function decideTurnEnd( ): ProviderTimelineDecision { const { state } = input const turn = - event.turn === undefined - ? state.open - : input.context.joins.turn(providerKey(event.turn), state.namespace) + event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - if (state.status(turn, input.journal) !== 'running') { + // Only the open turn ends here; any other is already over (superseded, or ended by its writer). + if (state.open?.itemId !== turn.itemId) { return { dropped: 'turn-unknown' } } - const settle = settlementOf(input, turn, { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) - return { ...(settle ? { settle } : {}), commit: (next) => next.endTurn(turn) } + return { + settle: { + what: 'turn-end', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + }) + }, + commit: (next) => next.endTurn(turn) + } } export function decideInput( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal, context } = input - const named = event.join?.turn - const placement = providerTimelinePlacement(context, state, event.join) - if (input.execute && journal && placement.turn && event.join?.item !== undefined) { - context.joins.reserveEcho( - { family: 'item', key: providerKey(event.join.item), thread: event.join.thread ?? null }, - placement, - journal - ) + return decideOpener( + input, + { + userItemId: agentJournalSubmissionKey(event.clientMessageId), + requestedAt: event.requestedAt + }, + event.join?.turn + ) +} + +/** A saved user message of an adopted session: its own row, only where none is, and the opener of + * the turn it names. */ +export function decideHistory( + input: ProviderTimelineDecisionInput, + event: Extract +): ProviderTimelineDecision { + const { context, state } = input + const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) + const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } + const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) + return { + ...opener, + writes: [ + { + reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), + lifecycle: false, + options: { turnScope: providerTimelinePlacement(context, state, event.join) }, + resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) + }, + ...(opener.writes ?? []) + ] } +} + +/** 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 namedTurn = - named === undefined ? null : context.joins.turn(providerKey(named), state.namespace) - const status = namedTurn ? state.status(namedTurn, journal) : null - // A late echo of a turn already over names nothing. - if (status === 'settled' || (status === 'running' && namedTurn?.itemId !== open?.itemId)) { - return {} - } - const pending = { - clientMessageId: event.clientMessageId, - requestedAt: event.requestedAt, - ...(namedTurn && status === 'absent' ? { turnItemId: namedTurn.itemId } : {}) - } - // No turn open, or the send names one still to open: it waits for that turn. - if (!open || pending.turnItemId !== undefined) { - return { - commit: (next) => next.wait(pending) + 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) } } - // Only a turn still naming its fallback opener takes the send; the journal's row says, once there. const opener = - readAgentJournalTurn(journal?.itemBody(open.itemId) ?? undefined)?.userItemId ?? + (journal && readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined)?.userItemId) ?? open.running.userItemId - if (opener !== context.joins.turnOpener(open)) { + if (opener !== open.itemId) { return {} } - const userItemId = agentJournalSubmissionKey(event.clientMessageId) - const running = { ...open.running, userItemId, requestedAt: event.requestedAt } + const running = { ...open.running, ...message } return { - write: - input.execute && journal ? reviseOpener(journal, open, userItemId, event.requestedAt) : null, + 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 } @@ -167,51 +221,46 @@ export function decideInput( } } -/** Only the opener fields change; the rest are the row's as the journal holds it. */ -export function reviseOpener( +/** 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: ProviderTimelineTurnRef, - userItemId: string, - requestedAt: number + open: ProviderTimelineOpenTurn, + message: Omit ): ProviderTimelineResolvedWrite | null { const row = readAgentJournalTurn(journal.itemBody(open.itemId) ?? undefined) - if (row?.state !== 'running') { + if (row?.state !== 'running' || row.userItemId !== open.itemId) { return null } const target = { identity: open.identity } const write = { - lifecycle: agentJournalTurnBody({ ...row, userItemId, requestedAt }), + lifecycle: agentJournalTurnBody({ ...row, ...message }), onlyWhileRunning: true as const } - const resolved = resolveAgentJournalTurnRowWrite( + return resolveAgentJournalTurnRowWrite( journal, target, write, agentJournalTurnRowReservedBytes(target, write) ) - return ( - resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } } - ) } export function decideSessionEnd( - input: ProviderTimelineDecisionInput, - end: ProviderTimelineTurnEnd, - commit: (state: ProviderTimelineState) => void + _input: ProviderTimelineDecisionInput, + event: Extract ): ProviderTimelineDecision { - const { journal } = input return { - ...(input.execute && journal - ? { - settle: providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - end - ) - } - : {}), - commit + settle: { + what: 'session-end', + resolve: (journal) => + providerTimelineSettlement( + journal, + 'session', + runningProviderTimelineTurns(journal), + event.verdict + ) + }, + commit: (next) => next.endSession() } } @@ -219,24 +268,23 @@ export function decideContextUsage( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state, journal } = input + const { state } = input const named = event.join?.turn - const turn = - named === undefined - ? (state.open ?? state.latest) - : input.context.joins.turn(providerKey(named), state.namespace) - if (!input.execute || !journal) { - return {} - } + 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 resolved = resolveAgentJournalTurnRowWrite( - journal, - target, - write, - agentJournalTurnRowReservedBytes(target, write) - ) - return { - write: resolved && { ...resolved, options: { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } + 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 f0372c8e58de..833cb89424e9 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 ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index a185695d5ea5..3e2f8449e0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -32,10 +32,8 @@ export type StructuredAgentSessionTransitionJournal = Pick< export type StructuredAgentSessionTransitionStep = | { kind: 'item' - /** Bounds what `resolve` may write; a larger write fails the sink, unless `paced`. */ + /** Bounds what `resolve` may write; a larger write fails the sink. */ reservedBytes: number - /** The size only paces the queue: the row grows by text the fold holds (a resumed message). */ - paced?: true /** The row and its whole body; null writes nothing. Resolved `options` replace the planned * ones, for a writer that learns the row's turn or producer only from the fold. */ resolve: (journal: StructuredAgentSessionTransitionJournal) => { @@ -105,9 +103,8 @@ function transitionAppend( return null } if ( - !step.paced && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } From 6e9d0c0ff7b1fa58ef5b8722943e91114ae0d854 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:37:30 -0700 Subject: [PATCH 16/26] Cover new running work in a turn the sweep ended --- .../provider-timeline-assembler-restart.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 5b4033e3ff6d..9772b13fc128 100644 --- 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 @@ -124,6 +124,12 @@ describe('a resumed session', () => { }) 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. @@ -141,6 +147,8 @@ describe('a resumed session', () => { 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' } }) From fc8541f5dd1f1cd1fd293e7085413d0097747ef4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:49:41 -0700 Subject: [PATCH 17/26] 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. --- .../journal-lifecycle-batch-appender.ts | 38 ++--- .../journal-row-writer.ts | 55 ++++---- .../journal-step-writer.ts | 58 ++++++++ .../journal-store-collaborators.ts | 17 ++- .../agent-session-journal/journal-store.ts | 14 +- .../journal-write-queue.ts | 16 +-- ...tructured-agent-session-transition.test.ts | 132 +++++++++++++++++- .../structured-agent-session-transition.ts | 94 ++++--------- 8 files changed, 274 insertions(+), 150 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-step-writer.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index cc300a7b7497..3cac8d03c211 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -78,30 +78,20 @@ export class JournalLifecycleBatchAppender { }) } - /** Chooses the mutations at its turn in the queue; one too large for a row becomes consecutive - * rows, committed in one transaction, in the order resolved. */ - appendResolved(input: JournalResolvedLifecycleBatchInput): Promise { - return this.deps - .enqueueRows(() => { - const mutations = input.resolve() - // Every chunk is built before any commits, so a second chunk naming the same item would - // reuse the first chunk's revision. - this.assertDistinctItems(mutations) - return partitionJournalLifecycleMutations(input.settlementId, mutations) - .filter((chunk) => !this.wasApplied(chunk.settlementId)) - .map((chunk) => - journalLifecycleBatchRowBuilder( - this.deps.state, - chunk.settlementId, - chunk.mutations, - input - ) - ) - }) - .then((rows) => { - const last = rows.at(-1) - return last ? { epoch: last.epoch, sequence: last.seq } : null - }) + /** The rows a resolved settlement writes, planned at its own turn in the queue: its mutations, + * chosen then, in as many consecutive rows as they need, minus any already applied. */ + planResolved( + input: JournalResolvedLifecycleBatchInput + ): ((seq: number, ts: number) => JournalRow)[] { + const mutations = input.resolve() + // Every chunk is built before any commits, so a second chunk naming the same item would + // reuse the first chunk's revision. + this.assertDistinctItems(mutations) + return partitionJournalLifecycleMutations(input.settlementId, mutations) + .filter((chunk) => !this.wasApplied(chunk.settlementId)) + .map((chunk) => + journalLifecycleBatchRowBuilder(this.deps.state, chunk.settlementId, chunk.mutations, input) + ) } private assertDistinctItems(mutations: readonly JournalLifecycleMutationInput[]): void { diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index dde1a66741d1..60db6e0cbd1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -76,33 +76,36 @@ export class JournalRowWriter { enqueueRows( plan: () => readonly ((seq: number, ts: number) => JournalRow)[] ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const first = this.deps.nextSequence() - const ts = this.deps.now() - const rows = plan().map((build, index) => build(first + index, ts)) - if (rows.length === 0) { - return rows - } - for (const row of rows) { - assertJournalFence(row.fence, this.deps.highestFence()) - } - try { - this.deps.database().transaction((db) => { - for (const row of rows) { - insertJournalRow(db, this.deps.sessionId, row) - this.runBookkeeping(db, row) - } - }) - } catch (error) { - this.deps.rolledBack?.() - throw error - } - for (const row of rows) { - this.deps.commit(row) - } + return this.deps.serialize(() => this.writeRows(plan)) + } + + /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ + writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { return rows - }) + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows } /** Assign the next sequence, make the row durable, and fold it through the SAME reducer diff --git a/src/main/native-chat/agent-session-journal/journal-step-writer.ts b/src/main/native-chat/agent-session-journal/journal-step-writer.ts new file mode 100644 index 000000000000..c0b62ce50c4c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-step-writer.ts @@ -0,0 +1,58 @@ +// Several writes run as ONE turn in the chat's write queue, one after another. +// +// Each step is planned from the fold with every step before it landed, and commits in its own +// transaction. The steps share one queue body, so no other write lands between them, and the +// first step that throws ends the run: the steps before it stay written and none after it runs. + +import type { + JournalItemAppendOptions, + JournalResolvedLifecycleBatchInput +} from './journal-store-contracts' +import type { JournalResolvedItem } from './journal-item-appender' +import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalReducerState } from './journal-reducer' +import { journalItemRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' +import type { JournalRowWriter } from './journal-row-writer' +import type { JournalWriteBody } from './journal-write-queue' + +export type JournalStep = + | { + kind: 'item' + /** Read at the step's turn; null writes nothing. */ + resolve: () => JournalResolvedItem | null + options: JournalItemAppendOptions + } + | { kind: 'settlement'; batch: JournalResolvedLifecycleBatchInput } + +export class JournalStepWriter { + constructor( + private readonly deps: { + serialize: (run: JournalWriteBody) => Promise + state: () => JournalReducerState + writeRows: JournalRowWriter['writeRows'] + planSettlement: JournalLifecycleBatchAppender['planResolved'] + } + ) {} + + /** Whether each step wrote; rejects with the error of the step that threw. */ + append(steps: readonly JournalStep[]): Promise { + return this.deps.serialize(() => { + const wrote: boolean[] = [] + for (const step of steps) { + wrote.push(this.deps.writeRows(() => this.plan(step)).length > 0) + } + return wrote + }) + } + + private plan(step: JournalStep): ((seq: number, ts: number) => JournalRow)[] { + if (step.kind === 'settlement') { + return this.deps.planSettlement(step.batch) + } + const resolved = step.resolve() + return resolved === null + ? [] + : [journalItemRowBuilder(this.deps.state, resolved.identity, resolved.body, step.options)] + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index ba8796964692..e5f521e49aea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -17,6 +17,7 @@ import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' +import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' @@ -57,6 +58,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + stepWriter: JournalStepWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same @@ -106,6 +108,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), rolledBack: () => queuedMessages.invalidate() }) + const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ + state: host.state, + cursor: host.cursor, + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) + }) return { epochController, queuedMessages, @@ -121,11 +129,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, enqueue: host.enqueue }), - lifecycleBatchAppender: new JournalLifecycleBatchAppender({ + lifecycleBatchAppender, + stepWriter: new JournalStepWriter({ + serialize: host.serialize, state: host.state, - cursor: host.cursor, - enqueue: host.enqueue, - enqueueRows: (plan) => rowWriter.enqueueRows(plan) + writeRows: (plan) => rowWriter.writeRows(plan), + planSettlement: (batch) => lifecycleBatchAppender.planResolved(batch) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3cabb7bd53ef..3edfbf0c1a75 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -71,6 +71,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalStepWriter } from './journal-step-writer' import type { JournalStopMarks } from './journal-stop-marks' export { AgentSessionJournalError } from './journal-write-guards' @@ -92,6 +93,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages @@ -143,6 +145,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.stepWriter = collaborators.stepWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore @@ -208,11 +211,6 @@ export class AgentSessionJournal { return this.queue.owing } - /** Write bodies that returned so far; see `JournalWriteQueue.completedWrites`. */ - get completedWrites(): number { - return this.queue.completedWrites - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -336,10 +334,8 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } - /** A lifecycle batch whose mutations are chosen from the fold at its own turn in the queue; - * null when none are. */ - appendResolvedLifecycleBatch: JournalLifecycleBatchAppender['appendResolved'] = (input) => - this.lifecycleBatchAppender.appendResolved(input) + /** Several writes as one turn in the queue; see `JournalStepWriter`. */ + appendSteps: JournalStepWriter['append'] = (steps) => this.stepWriter.append(steps) /** * Write-ahead submission row. It is durable before the caller dispatches diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index ae4f5c96d893..74ef33472bd4 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -30,7 +30,6 @@ export class JournalWriteQueue { private closed = false /** Runs before the next write, and stays owed until it succeeds. */ private owed: (() => Promise) | null = null - private completed = 0 constructor(private readonly sessionId: string) {} @@ -42,20 +41,9 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - const counted = (): JournalWriteResult => { - const result = run() - this.completed++ - return result - } return this.owed !== null || this.lineBusy - ? this.join(counted, this.owed !== null) - : this.runNow(counted) - } - - /** Write bodies that returned without throwing. Bodies run one at a time, so a body that reads - * this learns synchronously whether the write just ahead of it landed. */ - get completedWrites(): number { - return this.completed + ? this.join(run, this.owed !== null) + : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index b4232642dee0..6698b4cef5ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,6 +13,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -86,6 +87,36 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } +/** A bound sink over a chat whose copy into the host's database is still owed, so every write, + * the transition's included, waits in the journal's queue until the first one pays it. */ +async function owedRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) + roots.push(root) + const { journal } = await openJournalOwingImport({ + stateDirectory: root, + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + }, + now: () => 1_000 + }) + const failures: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + onFailed: (error) => failures.push(error) + }) + deferred.bind({ journal, fence: 1, publish: () => undefined }) + const ownKeys = () => + journal + .snapshot() + .items.map((item) => item.itemId) + .filter((itemId) => itemId.includes(SESSION)) + return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } +} + describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -354,6 +385,92 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) expect(journal.snapshot().items).toEqual([]) expect(publishes).toEqual([]) }) + + it('lands a write a step issues while it runs after the whole transition', async () => { + const { journal, deferred, sink, bind } = await rig() + bind() + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => { + // Another writer, reached from inside the step: it waits for the transition's turn to end. + sink.tryAppendItem?.(identity('nested'), tool('nested', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + return { identity: identity('first'), body: tool('first', 'running') } + }), + itemStep(() => ({ identity: identity('second'), body: tool('second', 'running') })) + ] + }) + await deferred.drained() + + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual( + ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + }) + + it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { + const { journal, deferred, sink, ownKeys, close } = await owedRig() + try { + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep((view) => + view.itemBody(agentJournalItemKey(identity('first'))) + ? { identity: identity('second'), body: tool('read', 'running') } + : null + ) + ] + }) + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(journal.importPending).toBe(true) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + + expect(journal.importPending).toBe(false) + expect(ownKeys()).toEqual( + ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + } finally { + await close() + } + }) + + it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { + const { deferred, sink, failures, ownKeys, close } = await owedRig() + try { + const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) + sink.tryAppendTransition?.({ + lifecycle: false, + publish: false, + steps: [ + itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), + itemStep( + () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), + 64 + ), + itemStep(third) + ] + }) + // Accepted before the failure is known: the sink's existing rule lets it land. + sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + + expect(ownKeys()).toEqual( + ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) + ) + expect(third).not.toHaveBeenCalled() + expect(failures).toHaveLength(1) + } finally { + await close() + } + }) }) describe('resolved lifecycle batches', () => { @@ -363,11 +480,16 @@ describe('resolved lifecycle batches', () => { const twice = identity('twice') await expect( - journal.appendResolvedLifecycleBatch({ - settlementId: 'twice', - fence: 1, - resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] - }) + journal.appendSteps([ + { + kind: 'settlement', + batch: { + settlementId: 'twice', + fence: 1, + resolve: () => [failedTool(identity('once')), failedTool(twice), failedTool(twice)] + } + } + ]) ).rejects.toThrow('journal_resolved_lifecycle_batch_names_item_twice') expect(journal.cursor().sequence).toBe(before) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index 57c0c7d0f5e8..f13b897f12bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -3,11 +3,10 @@ // A producer that keeps state about what it wrote must change that state only for writes the sink // took; when an event needs several rows, a refusal of the third after the first two were taken // would leave the producer and the journal disagreeing. A transition is admitted whole or not at -// all. At execution its steps are issued in the same tick, so they sit together in the journal's -// write queue, and each step resolves against the fold with every earlier write landed — the -// steps before it included — so what a step writes is decided by the journal, not by memory. -// Admitted whole, executed as a prefix: once a step fails, the steps after it write nothing, the -// steps before it stay written, and the sink fails, so nothing more is admitted after it. +// all. At execution its steps run as one turn in the journal's write queue, each resolved against +// the fold with every earlier write landed (the steps before it included), so what a step writes +// is decided by the journal, not by memory. Admitted whole, executed as a prefix: once a step +// fails, the steps after it never run, the steps before it stay written, and the sink fails. import type { AgentJournalItemBody, @@ -15,6 +14,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalStep } from '../agent-session-journal/journal-step-writer' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionItemAppendOptions, @@ -90,79 +90,37 @@ function transitionAppend( lifecycle: transition.lifecycle, run: async (bound) => { const { journal, fence } = bound - const gate = createStepGate(journal) - // Issued in one tick, so no write submitted after this transition lands between its steps. - const writes = transition.steps.map((step, index) => - step.kind === 'item' - ? journal - .appendResolvedItem( - () => { - const resolved = gate.open(index) ? step.resolve(journal) : null - if (!resolved) { - gate.wroteNothing() - return null - } + const wrote = await journal.appendSteps( + transition.steps.map((step): JournalStep => + step.kind === 'item' + ? { + kind: 'item', + resolve: () => { + const resolved = step.resolve(journal) if ( + resolved && estimateStructuredAgentSessionItemBytes(resolved.identity, resolved.body) > - step.reservedBytes + step.reservedBytes ) { throw new Error(STEP_OVERFLOW) } return resolved }, - structuredAgentSessionJournalAppendOptions(fence, step.options) - ) - .then((landed) => landed !== null) - : journal - .appendResolvedLifecycleBatch({ - settlementId: step.settlementId, - resolve: () => (gate.open(index) ? step.resolve(journal) : []), - fence - }) - .then((landed) => landed !== null) - ) - // Every step, the ones a failure turned into no-ops included, has run before this settles. - const outcomes = await Promise.allSettled(writes) - const failure = outcomes.find( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected' + options: structuredAgentSessionJournalAppendOptions(fence, step.options) + } + : { + kind: 'settlement', + batch: { + settlementId: step.settlementId, + fence, + resolve: () => step.resolve(journal) + } + } + ) ) - if (failure) { - throw failure.reason - } - if ( - transition.publish && - outcomes.some((outcome) => outcome.status === 'fulfilled' && outcome.value) - ) { + if (transition.publish && wrote.includes(true)) { bound.publish() } } }) } - -/** Lets a step write only when every step before it landed. Steps run in queue order, so each - * step's write learns synchronously, from the journal's count of completed writes, whether the - * write handed over just ahead of it landed; a promise would say so only after it ran. */ -function createStepGate(journal: Pick): { - /** First thing step `index` does at its turn; false once an earlier step failed. */ - open: (index: number) => boolean - /** The step writes nothing, so whether its write completes says nothing about a failure. */ - wroteNothing: () => void -} { - let failed = false - let reached = 0 - let handedAt: number | null = null - return { - open: (index) => { - // A step that never reached its resolve failed before it: a refused import, a read-only journal. - if (reached !== index || (handedAt !== null && journal.completedWrites === handedAt)) { - failed = true - } - reached = index + 1 - handedAt = failed ? null : journal.completedWrites - return !failed - }, - wroteNothing: () => { - handedAt = null - } - } -} From 1f4cab0b927d9c303f65eaccbadd01939eef1c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:07:42 -0700 Subject: [PATCH 18/26] 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. --- .../journal-subagent-liveness.ts | 15 +- .../provider-timeline-assembler-items.test.ts | 50 ++++ ...rovider-timeline-assembler-refusal.test.ts | 4 +- ...rovider-timeline-assembler-restart.test.ts | 31 +++ ...rovider-timeline-assembler-session.test.ts | 6 +- .../provider-timeline-assembler-stop.test.ts | 231 ++++++++++++++++++ ...rovider-timeline-assembler-test-support.ts | 53 ++-- .../provider-timeline-assembler-turns.test.ts | 6 +- .../provider-timeline-assembler.ts | 29 ++- .../provider-timeline-background-tasks.ts | 37 +-- .../provider-timeline-budget.ts | 12 +- .../provider-timeline-context.ts | 4 +- .../provider-timeline-decision.ts | 15 +- .../provider-timeline-event.ts | 18 +- .../provider-timeline-item-decisions.ts | 33 ++- .../provider-timeline-rows.ts | 19 +- .../provider-timeline-settlement.ts | 21 +- .../provider-timeline-state.ts | 15 +- .../provider-timeline-text-events.ts | 11 +- .../provider-timeline-text-streams.ts | 106 ++++++-- .../provider-timeline-transition-layout.ts | 21 +- .../provider-timeline-turn-decisions.ts | 72 ++++-- 22 files changed, 638 insertions(+), 171 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.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 ec81136688e3..ea4cebab1504 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-timeline/provider-timeline-assembler-items.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-items.test.ts index 7d6a12178a11..2bebb9ee5b14 100644 --- 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 @@ -101,6 +101,46 @@ describe('provider timeline items', () => { }) }) + 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 }) @@ -209,6 +249,16 @@ describe('provider timeline text streams', () => { 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 }) 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 index 2580926b30a2..f9dbff3efc54 100644 --- 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 @@ -30,7 +30,9 @@ describe('a refused event changes nothing and its retry lands it once', () => { 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 }) - expect(assembler.apply(end).dropped).toBe('turn-unknown') + // 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 () => { 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 index 9772b13fc128..a61ae858b26f 100644 --- 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 @@ -108,6 +108,37 @@ describe('an adopted session', () => { }) expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') }) + + // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead + // child's work, so the re-run cannot finish it. + it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { + const rig = await openProviderTimelineRig() + const reply: ProviderTimelineEvent[] = [ + { + type: 'text.delta', + item: { id: 'a2' }, + channel: 'assistant', + text: 'Done', + join: { turn: 'p1' } + }, + { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } + ] + const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] + full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) + await rig.restart() + const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { + generation: 'gen-3' + }) + full.forEach((event) => assembler.apply(event)) + assembler.flush() + await bind() + + expect((await rig.turn('p1'))?.state).toBe('unverifiable') + expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ + state: 'failed' + }) + expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() + }) }) describe('a resumed session', () => { 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 index fff993850eb1..cbb0441ac9f6 100644 --- 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 @@ -141,7 +141,11 @@ describe('provider timeline requests', () => { 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' }) - expect(rig.assembler.apply({ type: 'request.withdrawn', request: 'perm-1' }).dropped).toBe( + // 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({ 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 000000000000..7ce1ec3a3f76 --- /dev/null +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts @@ -0,0 +1,231 @@ +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, + 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' } } + ) +} + +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 ends as the provider end would end it', () => { + it("settles the turn's running tool and pending prompt, then takes 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('settles the work even when the next event is one that writes nothing', 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: 'activity', text: 'Thinking' }).dropped).toBe('no-turn') + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + state: 'failed' + }) + }) + + 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 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-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 15b5963db5ea..1009ab6aa532 100644 --- 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 @@ -186,12 +186,14 @@ export function openUnboundProviderTimelineAssembler( 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 dead-generation sweep settles what the old child left, then - * a new child gets a new assembler in a new generation. */ + /** What a restarted host does: the old child's assembler is gone, 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. */ @@ -241,6 +243,15 @@ export async function openProviderTimelineRig( 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, @@ -248,15 +259,15 @@ export async function openProviderTimelineRig( agent: AGENT, generation: GENERATION, namespace: NAMESPACE, - // Every delta writes at once unless a test drives the window itself. schedule: (run) => { - run() - return () => {} + windows.add(run) + return () => windows.delete(run) }, ...overrides, ...more }) const rows = async () => { + elapse() await deferred.drained() return journal.snapshot().items } @@ -266,25 +277,26 @@ export async function openProviderTimelineRig( return turn ? [turn] : [] }) let generations = 1 - const restart = async (more: Partial = {}) => { - generations += 1 - const generation = more.generation ?? `gen-${generations}` - await deferred.drained() - await settleStaleStructuredAgentSessionState({ - journal, - sessionId: SESSION, - fence: 1, - acquisitionGeneration: generation, - deathEvidence: null - }) - return build({ ...more, generation }) - } - return { + const rig: ProviderTimelineRig = { journal, assembler: build(), sink, eventSink: deferred.sink, - restart, + restart: async (more = {}) => { + generations += 1 + const generation = more.generation ?? `gen-${generations}` + await rows() + 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), @@ -297,4 +309,5 @@ export async function openProviderTimelineRig( ) } } + 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 index 19a7750b089c..cd31a2c8e1e7 100644 --- 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 @@ -130,7 +130,7 @@ describe('provider timeline turns', () => { expect(await rig.turn('turn-2')).toMatchObject({ state: 'running', startedAt: 2_000 }) }) - it('drops a repeated open, a repeated end, and an end for a turn it never opened', async () => { + 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( @@ -143,10 +143,10 @@ describe('provider timeline turns', () => { .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' }) - .dropped - ).toBe('turn-unknown') + ).toEqual({ admission: { accepted: true } }) expect(rig.assembler.apply({ type: 'turn.end', at: 3_000, state: 'completed' }).dropped).toBe( 'no-turn' ) 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 index 581c9703be00..0bc2da8b7204 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -29,7 +29,7 @@ import { import type { ProviderTimelineEvent } from './provider-timeline-event' import { createLegacyProviderTimelineIdentityScheme } from './provider-timeline-identity' import { ProviderTimelinePlan, type ProviderTimelineSink } from './provider-timeline-plan' -import { ProviderTimelineRows } from './provider-timeline-rows' +import { ProviderTimelineRows, providerTimelineTurnRowState } from './provider-timeline-rows' import { ProviderTimelineState } from './provider-timeline-state' import { applyProviderTimelineTextClose, @@ -52,6 +52,7 @@ export type ProviderTimelineAssembler = { 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 } @@ -105,7 +106,7 @@ export function createProviderTimelineAssembler( }) const admits = (hold: ProviderTimelineHold, journal: ProviderTimelineTextHost['journal']) => - providerTimelineBudgetAdmits({ hold, state, streams: streams.open, journal }) + providerTimelineBudgetAdmits({ hold, state, streams, journal }) const applyDecided = ( event: ProviderTimelineDecidedEvent, @@ -129,16 +130,34 @@ export function createProviderTimelineAssembler( decision.commit?.(state) serials.commit() }) - if (event.type === 'turn.open' || event.type === 'turn.end' || event.type === 'session.ended') { + if ( + event.type === 'turn.open' || + event.type === 'turn.end' || + event.type === 'turn.settled' || + event.type === 'session.ended' + ) { 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; + * 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() - if (journal) { - state.reconcile(journal) + const settled = journal ? endSettledTurn(journal) : null + if (settled && !settled.admission.accepted) { + return settled } switch (event.type) { case 'text.delta': 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 index 3b4d004160fd..c8522462245c 100644 --- 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 @@ -4,44 +4,9 @@ // that turn; only its own updates, or the session's end, change its state. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { - backgroundTaskFallbackText, - canReplaceBackgroundTaskState, - isSettledBackgroundTaskState -} from '../../../shared/native-chat-background-task-row' +import { canReplaceBackgroundTaskState } from '../../../shared/native-chat-background-task-row' import { isBackgroundTaskBlock } from '../../../shared/native-chat-types' -/** The row once the host lost the session that ran its tasks: every task still in flight is - * `unverifiable` (nothing says it exited), with its plain-text twin restated to match. Null when - * no task in it is in flight. */ -export function lostProviderTimelineBackgroundTasks( - body: AgentJournalItemBody -): AgentJournalItemBody | null { - if (body.kind !== 'message') { - return null - } - const restated = new Map() - const blocks = body.blocks.map((block) => { - if (!isBackgroundTaskBlock(block) || isSettledBackgroundTaskState(block.state)) { - return block - } - const lost = { ...block, state: 'unverifiable' as const } - restated.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(lost)) - return lost - }) - if (restated.size === 0) { - return null - } - return { - ...body, - blocks: blocks.map((block) => - block.type === 'text' && restated.has(block.text) - ? { ...block, text: restated.get(block.text) ?? block.text } - : block - ) - } -} - /** Whether `next` would move a task `held` already settled back to a state it may not take. */ export function relightsProviderTimelineBackgroundTask( held: AgentJournalItemBody | null, 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 index a097bf689d69..3422ad01f6e3 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-budget.ts @@ -2,13 +2,14 @@ // 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 or a row another writer settled frees its room. Past the budget an event is refused +// 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 @@ -23,13 +24,13 @@ export type ProviderTimelineHold = { key: string; bytes: number } export function providerTimelineBudgetAdmits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams journal: StructuredAgentSessionTransitionJournal | null }): boolean { if (fits(input)) { return true } - const { journal, state } = input + const { journal, state, streams } = input if (!journal) { return false } @@ -39,13 +40,14 @@ export function providerTimelineBudgetAdmits(input: { state.items.delete(key) } } + streams.stopSettled(journal) return fits(input) } function fits(input: { hold: ProviderTimelineHold state: ProviderTimelineState - streams: readonly ProviderTimelineHold[] + streams: ProviderTimelineTextStreams }): boolean { const open = new Map() for (const [key, entry] of input.state.items) { @@ -53,7 +55,7 @@ function fits(input: { open.set(key, entry.bytes) } } - for (const stream of input.streams) { + 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)) 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 index e65c134ad888..d321dfbe7d8c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-context.ts @@ -27,8 +27,8 @@ export function providerTimelineSettlementId( return `provider-timeline:${context.sessionId}:${context.generation}:${serial}:${what}` } -/** The turn a new row joins: the one the provider names (subagent work joins the open turn while - * keeping its own turn in its identity), else the open one, else none. */ +/** 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, 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 index bd1274934676..884f735b50fe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -22,6 +22,7 @@ import { decideRequest, decideWithdrawal } from './provider-timeline-item-decisions' +import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, @@ -29,7 +30,8 @@ import { decideInput, decideSessionEnd, decideTurnEnd, - decideTurnOpen + 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. */ @@ -45,10 +47,11 @@ export type ProviderTimelineDropRule = | 'stream-unknown' | 'stream-mismatch' -export type ProviderTimelineDecidedEvent = Exclude< - ProviderTimelineEvent, - { type: 'text.delta' | 'text.close' | 'activity' } -> +export type ProviderTimelineDecidedEvent = + | Exclude + /** The assembler's own: the journal shows the open turn settled by another writer (a person's + * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + | { type: 'turn.settled'; turn: ProviderTimelineTurnRef } type Journal = StructuredAgentSessionTransitionJournal @@ -104,6 +107,8 @@ export function decideProviderTimelineEvent( 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': 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 index a745709210cd..b578088d8ef2 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -16,11 +16,16 @@ // 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 never -// runs again, an answered request stays answered. +// 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) ends here as the provider's `turn.end` would +// end it: its running work settles, and text still streaming into it is dropped until the +// provider's end of that turn or the next `turn.open`. // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. +// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run +// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running +// tools `failed`), and the re-run writes nothing more into it. // - 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 @@ -87,7 +92,8 @@ export type ProviderTimelineEvent = } /** 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. */ + /** The provider ended a turn. Absent `turn` means the open turn. A turn already over (another + * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ | { type: 'turn.end' turn?: string @@ -103,7 +109,7 @@ export type ProviderTimelineEvent = * 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 never runs again. */ + /** 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) @@ -121,7 +127,7 @@ export type ProviderTimelineEvent = /** 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. */ + /** 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) 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 index e3d9ff8ec461..db7ee10131b6 100644 --- 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 @@ -1,5 +1,6 @@ // 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 @@ -38,9 +39,9 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel ) } -/** Whether the row the journal holds refuses this write: a settled tool never runs again and keeps - * its first terminal body, a settled background task is never relit, and a turn that is over - * takes no work that waits on a settlement it already ran. */ +/** 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 work that waits on a settlement it already ran. */ function refusesItemWrite( journal: Journal, row: ProviderTimelineRowId, @@ -50,7 +51,11 @@ function refusesItemWrite( ): boolean { const held = journal.item(row.itemId) const turn = held ? turnOf(held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : placed - if (settledTool(held?.body ?? null) && (change === 'close' || requiresTerminalSettlement(body))) { + if ( + held && + settledTool(held.body) && + (change === 'close' || !isDeepStrictEqual(held.body, body)) + ) { return true } if (relightsProviderTimelineBackgroundTask(held?.body ?? null, body)) { @@ -172,27 +177,31 @@ export function decideWithdrawal( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { + const { state, journal, context } = input const key = requestKey(event.request) - const opened = input.state.items.get(key) - if (opened?.kind !== '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: (journal) => { - // The open ran first and named its row. Only while it is pending: a client's answer that - // landed first stands. - const held = opened.row ? journal.item(opened.row.itemId) : null + 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 (!opened.row || !held || !cancelled) { + if (!row || !held || !cancelled) { return [] } return [ { kind: 'item', - identity: opened.row.identity, + identity: row.identity, body: cancelled, turnScope: held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } 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 index 72c892cfbe9f..8ce41039c302 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-rows.ts @@ -90,12 +90,21 @@ export class ProviderTimelineRows { /** The first incarnation under `key` the journal holds no row for: never one already written. */ nextRequest(key: string, journal: Journal): ProviderTimelineRowId { - for (let incarnation = 1; ; incarnation += 1) { - const row = this.request(key, incarnation) - if (journal.itemBody(row.itemId) === null) { - return row - } + 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 { 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 index b8fb5f532233..1baa7c54996b 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -18,9 +18,9 @@ import { 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 { lostProviderTimelineBackgroundTasks } from './provider-timeline-background-tasks' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite @@ -64,12 +64,17 @@ export function endedProviderTimelineTurn( /** Which open rows a settlement covers: one turn's, or every row when the session ends. */ export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' -/** The settled rows of `scope`, then each listed turn's end while the journal holds it running. */ +/** 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, - turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[], - end: ProviderTimelineTurnEnd + ending?: ProviderTimelineSettlementEnding ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { @@ -77,19 +82,19 @@ export function providerTimelineSettlement( const covered = scope === 'session' || (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) - // Background tasks outlive turns; only the session's end leaves them past seeing. + // Background tasks and subagents outlive turns; only the session's end leaves them past seeing. const settled = !covered ? null : (terminalAgentJournalBody(body) ?? - (scope === 'session' ? lostProviderTimelineBackgroundTasks(body) : null)) + (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 turns) { - const ended = endedTurnRow(journal, turn, end) + 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 }) } 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 index d236f2536fab..22d461b8ba87 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -12,11 +12,7 @@ import { } 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 { - providerTimelineTurnRowState, - type ProviderTimelineRowId, - type ProviderTimelineTurnRef -} from './provider-timeline-rows' +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 @@ -78,13 +74,6 @@ export class ProviderTimelineState { } } - /** Ends the open turn when another writer of the journal settled its row. */ - reconcile(journal: StructuredAgentSessionTransitionJournal): void { - if (this.open && providerTimelineTurnRowState(journal, this.open.itemId) === 'settled') { - this.endTurn(this.open) - } - } - /** 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 { @@ -135,8 +124,8 @@ export class ProviderTimelineState { endTurn(turn: ProviderTimelineTurnRef): void { if (this.open?.itemId === turn.itemId) { this.open = null + this.latest = turn } - this.latest = turn for (const [key, entry] of this.items) { if (entry.turnItemId === turn.itemId) { 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 index 0cf3452440bb..ec93367efd48 100644 --- 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 @@ -51,6 +51,9 @@ export function applyProviderTimelineTextDelta( 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' } } @@ -94,9 +97,13 @@ export function applyProviderTimelineTextClose( if (state.ended) { return { admission: ADMITTED, dropped: 'session-ended' } } - const stream = streams.get(streams.key(event.item, event.join)) + const key = streams.key(event.item, event.join) + const stream = streams.get(key) if (!stream) { - return { admission: ADMITTED, dropped: 'stream-unknown' } + return { + admission: ADMITTED, + dropped: streams.stoppedFor(key, event.join) ? 'turn-settled' : 'stream-unknown' + } } const plan = new ProviderTimelinePlan() streams.planFlush(plan, stream) 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 index da869954907d..eda78997e6d2 100644 --- 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 @@ -7,7 +7,9 @@ // 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. +// 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, @@ -61,13 +63,18 @@ export type ProviderTimelineStream = { /** Whether any text was written; a whitespace-only stream completes nothing. */ written: boolean bytes: number - /** A write found its row's turn settled: it writes nothing more. */ - stopped: boolean + /** 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( @@ -89,7 +96,7 @@ export class ProviderTimelineTextStreams { /** What the budget counts: one entry per open stream. */ get open(): readonly ProviderTimelineStream[] { - return [...this.streams.values()].filter((stream) => !stream.stopped) + return [...this.streams.values()].filter((stream) => stream.stoppedIn === null) } /** The stream slot `item` names on its thread. */ @@ -100,16 +107,27 @@ export class ProviderTimelineTextStreams { : `stream:${providerTimelineKeyPart(thread ?? '')}:${providerTimelineKeyPart(item.stream)}` } - /** The open stream in `key`; a stopped one is let go, so the next delta starts another. */ + /** 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?.stopped) { - this.forget(stream) + 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 @@ -137,7 +155,7 @@ export class ProviderTimelineTextStreams { join: input.join, producer: input.producer }), - stopped: false + stoppedIn: null } } @@ -171,6 +189,39 @@ export class ProviderTimelineTextStreams { } } + /** 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, @@ -200,8 +251,10 @@ export class ProviderTimelineTextStreams { this.coalescer.flushAll() } + /** Drops the window's unwritten text: apply `session.ended` first, which writes it. */ dispose(): void { - this.open.forEach((stream) => this.forget(stream)) + Array.from(this.byId.values()).forEach((stream) => this.forget(stream)) + this.stopped.clear() this.coalescer.dispose() } @@ -248,18 +301,26 @@ export class ProviderTimelineTextStreams { body: AgentJournalItemBody, journal: StructuredAgentSessionTransitionJournal ): ProviderTimelineResolvedWrite | null { - const held = journal.item(stream.row.itemId) - const turn = turnOf(held ? (held.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE) : stream.scope) - if ( - stream.stopped || - (turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled') - ) { - stream.stopped = true + 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', @@ -268,6 +329,19 @@ export class ProviderTimelineTextStreams { } } + 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) { 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 index a51cc9064dac..e497b5a66df1 100644 --- 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 @@ -18,7 +18,9 @@ import type { ProviderTimelineTextStreams } from './provider-timeline-text-strea 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. */ + * separates: anonymous ones of its producer, every stream of a turn it ends, all on a session end. + * A turn another writer settled stops its streams until that turn's boundary: its own end, or + * the next turn's open. */ export function planProviderTimelineBarrier( input: { streams: ProviderTimelineTextStreams @@ -40,11 +42,20 @@ export function planProviderTimelineBarrier( streams.planRelease(plan, () => true) return } - if (event.type === 'turn.end' || event.type === 'turn.open') { + if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + event.type === 'turn.settled' + ? event.turn.itemId + : event.type === 'turn.end' && event.turn !== undefined + ? context.rows.turn(providerKey(event.turn)).itemId + : state.open?.itemId + 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) => 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 index f242a7573e7b..9584ef55ad85 100644 --- 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 @@ -31,7 +31,8 @@ import { } from './provider-timeline-rows' import { providerTimelineSettlement, - runningProviderTimelineTurns + runningProviderTimelineTurns, + type ProviderTimelineSettlementEnding } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -80,11 +81,14 @@ export function decideTurnOpen( settle: { what: 'turn-superseded', resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: open.itemId }, [open], { - state: 'interrupted', - completedAt: event.at, - outcome: 'superseded' - }) + providerTimelineSettlement( + journal, + { turnItemId: open.itemId }, + { + turns: [open], + end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } + } + ) } } : {}), @@ -114,26 +118,50 @@ export function decideTurnEnd( input: ProviderTimelineDecisionInput, event: Extract ): ProviderTimelineDecision { - const { state } = input + const { state, journal } = input const turn = event.turn === undefined ? state.open : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } - // Only the open turn ends here; any other is already over (superseded, or ended by its writer). - if (state.open?.itemId !== turn.itemId) { + // 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' } } + return endedTurn(turn, { + turns: [turn], + end: { + state: event.state, + completedAt: event.at, + ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) + } + }) +} + +/** The open turn another writer settled: its work ends as the provider's end would end it, and + * its row stays as that writer left it. */ +export function decideTurnSettled( + event: Extract +): ProviderTimelineDecision { + return endedTurn(event.turn) +} + +/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ +function endedTurn( + turn: ProviderTimelineTurnRef, + ending?: ProviderTimelineSettlementEnding +): ProviderTimelineDecision { return { settle: { - what: 'turn-end', - resolve: (journal) => - providerTimelineSettlement(journal, { turnItemId: turn.itemId }, [turn], { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - }) + what: ending ? 'turn-end' : 'turn-settled', + resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) }, commit: (next) => next.endTurn(turn) } @@ -253,12 +281,10 @@ export function decideSessionEnd( settle: { what: 'session-end', resolve: (journal) => - providerTimelineSettlement( - journal, - 'session', - runningProviderTimelineTurns(journal), - event.verdict - ) + providerTimelineSettlement(journal, 'session', { + turns: runningProviderTimelineTurns(journal), + end: event.verdict + }) }, commit: (next) => next.endSession() } From 430d82053599e34f36282fb816e6ed82d44f33fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:30:57 -0700 Subject: [PATCH 19/26] 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. --- ...imeline-assembler-earlier-turn-end.test.ts | 62 +++++++++++ .../provider-timeline-assembler-stop.test.ts | 102 +++++++++++++++++- ...rovider-timeline-assembler-test-support.ts | 9 +- .../provider-timeline-assembler.ts | 13 +-- .../provider-timeline-decision.ts | 6 +- .../provider-timeline-event.ts | 14 ++- .../provider-timeline-item-decisions.ts | 4 +- .../provider-timeline-settlement.ts | 10 +- .../provider-timeline-state.ts | 33 +++++- .../provider-timeline-transition-layout.ts | 19 ++-- .../provider-timeline-turn-decisions.ts | 67 ++++++------ 11 files changed, 271 insertions(+), 68 deletions(-) create mode 100644 src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts 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 000000000000..0e79ec50d040 --- /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-stop.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-stop.test.ts index 7ce1ec3a3f76..4ee9190ad244 100644 --- 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 @@ -9,6 +9,7 @@ import { openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, + providerTurnId, providerTurnItemId, runningTool, type ProviderTimelineRig @@ -30,6 +31,8 @@ async function stop(rig: ProviderTimelineRig, turnKey: string, at = 2_000): Prom ) } +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) @@ -39,8 +42,8 @@ async function texts(rig: ProviderTimelineRig): Promise { }) } -describe('a turn another writer settled ends as the provider end would end it', () => { - it("settles the turn's running tool and pending prompt, then takes the provider's own end", async () => { +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') }) @@ -80,15 +83,92 @@ describe('a turn another writer settled ends as the provider end would end it', expect(await rig.turn('t1')).toMatchObject({ state: 'interrupted', completedAt: 2_100 }) }) - it('settles the work even when the next event is one that writes nothing', async () => { + 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'), output: 'half' } + 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({ output: 'half' }) + 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('never fills the open budget with the streams of turns a person stopped', async () => { @@ -157,6 +237,22 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) + it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", 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 }) 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 index 1009ab6aa532..b0e5eb833a22 100644 --- 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 @@ -191,9 +191,9 @@ export type ProviderTimelineRig = { 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, the dead-generation sweep - * settles what it left, then a new child gets a new assembler in a new generation, which - * becomes `assembler`. */ + /** 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. */ @@ -285,7 +285,8 @@ export async function openProviderTimelineRig( restart: async (more = {}) => { generations += 1 const generation = more.generation ?? `gen-${generations}` - await rows() + // The dead child's window never elapses: as in production, dispose drops its text. + await deferred.drained() rig.assembler.dispose() await settleStaleStructuredAgentSessionState({ journal, 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 index 0bc2da8b7204..a4bd51d4e807 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -124,25 +124,26 @@ export function createProviderTimelineAssembler( return { admission: PROVIDER_TIMELINE_OVER_BUDGET } } const plan = new ProviderTimelinePlan() - planProviderTimelineBarrier({ streams, state, context }, plan, event, decision) + 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 === 'turn.end' || - event.type === 'turn.settled' || - event.type === 'session.ended' + 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; - * refused, the event that found it is refused with it and its retry finds it again. */ + /** 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 => { 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 index 884f735b50fe..e5c985aaece1 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -50,7 +50,8 @@ export type ProviderTimelineDropRule = export type ProviderTimelineDecidedEvent = | Exclude /** The assembler's own: the journal shows the open turn settled by another writer (a person's - * Stop), so it ends here exactly as the provider's `turn.end` would end it. */ + * 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 @@ -80,6 +81,9 @@ export type ProviderTimelineDecision = { 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 = { 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 index b578088d8ef2..dd2f7d1497fb 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -18,9 +18,11 @@ // - 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) ends here as the provider's `turn.end` would -// end it: its running work settles, and text still streaming into it is dropped until the -// provider's end of that turn or the next `turn.open`. +// - 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`). // - Saved history the provider replays goes only into an empty journal (an adopted session), as // ordinary events with the provider's own ids, so re-running an interrupted adoption writes the // same rows again. Limit, until the adoption work lifts it: a crash that cut the first run @@ -92,8 +94,10 @@ export type ProviderTimelineEvent = } /** 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. A turn already over (another - * writer's Stop, a newer turn) takes it too: what it left open settles, its row stays. */ + /** 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 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 index db7ee10131b6..22e8a1c6f1e6 100644 --- 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 @@ -41,7 +41,8 @@ function pendingPrompt(body: AgentJournalItemBody | null): body is ProviderTimel /** 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 work that waits on a settlement it already ran. */ + * 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, @@ -63,6 +64,7 @@ function refusesItemWrite( } return ( requiresTerminalSettlement(body) && + !(held && requiresTerminalSettlement(held.body)) && turn !== null && providerTimelineTurnRowState(journal, turn) === 'settled' ) 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 index 1baa7c54996b..24d35559a2ed 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-settlement.ts @@ -61,8 +61,9 @@ export function endedProviderTimelineTurn( } } -/** Which open rows a settlement covers: one turn's, or every row when the session ends. */ -export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session' +/** 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 = { @@ -81,7 +82,9 @@ export function providerTimelineSettlement( const turnScope = attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE const covered = scope === 'session' || - (turnScope.kind === 'turn' && turnScope.turnItemId === scope.turnItemId) + (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 @@ -129,6 +132,7 @@ function endedTurnRow( 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( 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 index 22d461b8ba87..aa8b0a1bc82c 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -53,6 +53,9 @@ export class ProviderTimelineState { 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() @@ -126,16 +129,38 @@ export class ProviderTimelineState { this.open = null this.latest = turn } - for (const [key, entry] of this.items) { - if (entry.turnItemId === turn.itemId) { - this.items.delete(key) - } + 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-transition-layout.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-transition-layout.ts index e497b5a66df1..f2c221ed8e81 100644 --- 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 @@ -10,7 +10,7 @@ import type { ProviderTimelineDecision } from './provider-timeline-decision' import type { ProviderTimelinePlan } from './provider-timeline-plan' -import { providerKey, turnOf } from './provider-timeline-rows' +import { turnOf } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import type { ProviderTimelineTextStreams } from './provider-timeline-text-streams' @@ -19,19 +19,19 @@ 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. - * A turn another writer settled stops its streams until that turn's boundary: its own end, or - * the next turn's open. */ + * 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 - context: ProviderTimelineContext }, plan: ProviderTimelinePlan, event: ProviderTimelineDecidedEvent, decision: ProviderTimelineDecision ): void { - const { streams, state, context } = input + 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) @@ -44,11 +44,8 @@ export function planProviderTimelineBarrier( } if (event.type === 'turn.end' || event.type === 'turn.open' || event.type === 'turn.settled') { const ending = - event.type === 'turn.settled' - ? event.turn.itemId - : event.type === 'turn.end' && event.turn !== undefined - ? context.rows.turn(providerKey(event.turn)).itemId - : state.open?.itemId + 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') { @@ -59,7 +56,7 @@ export function planProviderTimelineBarrier( streams.planRelease( plan, (stream) => - (!stream.named && stream.producer?.agentId === undefined) || + (current && !stream.named && stream.producer?.agentId === undefined) || (ending !== undefined && turnOf(stream.scope) === ending) ) return 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 index 9584ef55ad85..e9c4471f6076 100644 --- 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 @@ -32,7 +32,7 @@ import { import { providerTimelineSettlement, runningProviderTimelineTurns, - type ProviderTimelineSettlementEnding + type ProviderTimelineTurnEnd } from './provider-timeline-settlement' import type { ProviderTimelineOpenTurn, @@ -48,6 +48,8 @@ export function decideTurnOpen( ): 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' } @@ -75,17 +77,16 @@ export function decideTurnOpen( body: agentJournalTurnBody(running) } return { - // A newer turn ended this one, whoever asked for it. - ...(open + ...(superseded ? { settle: { what: 'turn-superseded', resolve: (journal) => providerTimelineSettlement( journal, - { turnItemId: open.itemId }, + { turnItemId: superseded.itemId }, { - turns: [open], + turns: [superseded], end: { state: 'interrupted', completedAt: event.at, outcome: 'superseded' } } ) @@ -103,8 +104,8 @@ export function decideTurnOpen( } ], commit: (next) => { - if (next.open) { - next.endTurn(next.open) + if (superseded) { + next.endTurn(superseded) } if (pending) { next.inputs = next.inputs.filter((each) => each !== pending) @@ -119,8 +120,11 @@ export function decideTurnEnd( 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 : input.context.rows.turn(providerKey(event.turn)) + event.turn === undefined + ? (state.open ?? state.stopped) + : input.context.rows.turn(providerKey(event.turn)) if (!turn) { return { dropped: 'no-turn' } } @@ -134,36 +138,38 @@ export function decideTurnEnd( if (!known) { return { dropped: 'turn-unknown' } } - return endedTurn(turn, { - turns: [turn], - end: { - state: event.state, - completedAt: event.at, - ...(event.outcome !== undefined ? { outcome: event.outcome } : {}), - ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) - } - }) + 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: its work ends as the provider's end would end it, and - * its row stays as that writer left it. */ +/** 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 { - return endedTurn(event.turn) -} - -/** The one end of a turn: the journal-derived settlement of its open work, then the state. */ -function endedTurn( - turn: ProviderTimelineTurnRef, - ending?: ProviderTimelineSettlementEnding -): ProviderTimelineDecision { + const { turn } = event return { + ends: { turnItemId: turn.itemId, current: true }, settle: { - what: ending ? 'turn-end' : 'turn-settled', - resolve: (journal) => providerTimelineSettlement(journal, { turnItemId: turn.itemId }, ending) + what: 'turn-settled', + resolve: (journal) => + providerTimelineSettlement(journal, { turnItemId: turn.itemId, promptsOnly: true }) }, - commit: (next) => next.endTurn(turn) + commit: (next) => next.stopTurn(turn) } } @@ -263,6 +269,7 @@ function reviseOpener( 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( From d22b154908646d91f8d9b3791cab5cb0a60cb5bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:31:39 -0700 Subject: [PATCH 20/26] Pin that a stopped turn's running tools hold budget until the agent's end --- .../provider-timeline-assembler-stop.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index 4ee9190ad244..d554eee9d91d 100644 --- 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 @@ -171,6 +171,19 @@ describe('a turn another writer settled stops its text and prompts; its tools wa 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) { From 1c49877cbb4ff58c38b8cb3f9ee207bd6590862c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:33:08 -0700 Subject: [PATCH 21/26] Type the stopped turn's tool progress update as a tool body --- .../provider-timeline-assembler-stop.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index d554eee9d91d..aaaf89b814de 100644 --- 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 @@ -105,7 +105,7 @@ describe('a turn another writer settled stops its text and prompts; its tools wa 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'), output: 'half' } + const progress = { ...runningTool('read'), input: { name: 'read', path: 'a.ts' } } expect( rig.assembler.apply({ type: 'item.update', @@ -114,7 +114,9 @@ describe('a turn another writer settled stops its text and prompts; its tools wa join: { turn: 't1' } }) ).toEqual({ admission: { accepted: true } }) - expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ output: 'half' }) + expect((await rig.row(providerItemId('item', 'call')))?.body).toMatchObject({ + input: { path: 'a.ts' } + }) expect( rig.assembler.apply({ type: 'item.close', From 9fcb95406eaa1a8d327194836bc1c54d7a4fb811 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:45:09 -0700 Subject: [PATCH 22/26] 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. --- .../provider-timeline-assembler-stop.test.ts | 2 +- .../provider-timeline-assembler.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 index aaaf89b814de..e0e4541402be 100644 --- 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 @@ -252,7 +252,7 @@ describe('text after a person stopped its turn never lands outside that turn', ( ]) }) - it("keeps dropping an anonymous stream after the provider's unnamed end until the next turn", async () => { + 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 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 index a4bd51d4e807..f1b6cb0a68b0 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -175,7 +175,18 @@ export function createProviderTimelineAssembler( ) return { admission: ADMITTED } } - default: + case 'input.accepted': + case 'input.history': + 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) } } From 879f74a2c948e37632c3d99f3d4cd9d9bdaa31ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:01:11 -0700 Subject: [PATCH 23/26] 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. --- ...rovider-timeline-assembler-restart.test.ts | 126 +----------------- .../provider-timeline-assembler.ts | 1 - .../provider-timeline-decision.ts | 3 - .../provider-timeline-event.ts | 14 -- .../provider-timeline-turn-decisions.ts | 26 ---- 5 files changed, 1 insertion(+), 169 deletions(-) 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 index a61ae858b26f..536381911be4 100644 --- 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 @@ -3,144 +3,20 @@ // decides from the journal the sweep left. import { afterEach, describe, expect, it } from 'vitest' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' import { closeProviderTimelineRigs, - messageText, openProviderTimelineRig, openUnboundProviderTimelineAssembler, pendingApproval, providerItemId, providerTurnItemId, runningTool, - SESSION, - type ProviderTimelineRig + SESSION } from './provider-timeline-assembler-test-support' -import type { ProviderTimelineEvent } from './provider-timeline-event' afterEach(closeProviderTimelineRigs) -const userText = (text: string): AgentJournalMessageItem => ({ - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] -}) - -/** A provider's saved history of one prompt, as an adapter adopting the session replays it. */ -const history: ProviderTimelineEvent[] = [ - { type: 'input.history', item: 'u1', body: userText('Fix it'), join: { turn: 'p1' } }, - { type: 'turn.open', turn: 'p1', at: 1_000 }, - { - type: 'text.delta', - item: { id: 'a1' }, - channel: 'assistant', - text: 'Looking', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a1' }, join: { turn: 'p1' } }, - { type: 'item.open', item: 'call-1', body: runningTool('read'), join: { turn: 'p1' } }, - { - type: 'item.close', - item: 'call-1', - body: { ...runningTool('read'), state: 'completed' }, - join: { turn: 'p1' } - }, - { type: 'turn.end', turn: 'p1', at: 2_000, state: 'completed', outcome: 'success' } -] - -async function revisions(rig: ProviderTimelineRig): Promise<[string, number][]> { - return (await rig.rows()).map((row) => [row.itemId, row.revision]) -} - -describe('an adopted session', () => { - it('lands the provider history as rows keyed by its own ids, opened by its saved message', async () => { - const rig = await openProviderTimelineRig() - history.forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - - const user = await rig.row(providerItemId('item', 'u1')) - expect(user).toMatchObject({ - body: userText('Fix it'), - turnScope: { kind: 'turn', turnItemId: providerTurnItemId('p1') } - }) - expect(await rig.turn('p1')).toMatchObject({ - state: 'completed', - outcome: 'success', - userItemId: user?.itemId - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'completed' - }) - - // Adoption run again by the next child (its first try was interrupted): nothing new lands. - const before = await revisions(rig) - const again = await rig.restart() - history.forEach((event) => again.apply(event)) - again.flush() - expect(await revisions(rig)).toEqual(before) - }) - - it('re-runs an adoption a crash cut midway without a second copy or a regressed row', async () => { - const rig = await openProviderTimelineRig() - history.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - rig.assembler.flush() - await rig.rows() - // The child died with the turn and its tool running; the next acquisition sweeps them. - await rig.restart() - const swept = await rig.turn('p1') - expect(swept?.state).not.toBe('running') - const before = (await rig.rows()).length - - // The new child replays the whole history before its sink binds: only the writes can see it. - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - history.forEach((event) => expect(assembler.apply(event).dropped).toBeUndefined()) - assembler.flush() - await bind() - - expect(await rig.rows()).toHaveLength(before) - expect(await rig.turn('p1')).toEqual(swept) - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(messageText((await rig.row(providerItemId('item', 'a1')))?.body)).toBe('Looking') - }) - - // Temporary limit, for the adoption work to lift: the sweep settled the cut turn as the dead - // child's work, so the re-run cannot finish it. - it('leaves the turn a crash cut as the sweep settled it when the adoption re-runs', async () => { - const rig = await openProviderTimelineRig() - const reply: ProviderTimelineEvent[] = [ - { - type: 'text.delta', - item: { id: 'a2' }, - channel: 'assistant', - text: 'Done', - join: { turn: 'p1' } - }, - { type: 'text.close', item: { id: 'a2' }, join: { turn: 'p1' } } - ] - const full = [...history.slice(0, -1), ...reply, ...history.slice(-1)] - full.slice(0, 5).forEach((event) => rig.assembler.apply(event)) - await rig.restart() - const { assembler, bind } = openUnboundProviderTimelineAssembler(rig.journal, { - generation: 'gen-3' - }) - full.forEach((event) => assembler.apply(event)) - assembler.flush() - await bind() - - expect((await rig.turn('p1'))?.state).toBe('unverifiable') - expect((await rig.row(providerItemId('item', 'call-1')))?.body).toMatchObject({ - state: 'failed' - }) - expect(await rig.row(providerItemId('item', 'a2'))).toBeUndefined() - }) -}) - describe('a resumed session', () => { it('decides events admitted before bind against the journal the sweep left', async () => { const rig = await openProviderTimelineRig() 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 index f1b6cb0a68b0..fdd10ab61041 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -176,7 +176,6 @@ export function createProviderTimelineAssembler( return { admission: ADMITTED } } case 'input.accepted': - case 'input.history': case 'turn.open': case 'turn.end': case 'item.open': 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 index e5c985aaece1..61fdfe4cd496 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-decision.ts @@ -26,7 +26,6 @@ import type { ProviderTimelineTurnRef } from './provider-timeline-rows' import type { ProviderTimelineState } from './provider-timeline-state' import { decideContextUsage, - decideHistory, decideInput, decideSessionEnd, decideTurnEnd, @@ -105,8 +104,6 @@ export function decideProviderTimelineEvent( switch (event.type) { case 'input.accepted': return decideInput(input, event) - case 'input.history': - return decideHistory(input, event) case 'turn.open': return decideTurnOpen(input, event) case 'turn.end': 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 index dd2f7d1497fb..4cd8b8650ffe 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-event.ts @@ -23,11 +23,6 @@ // 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`). -// - Saved history the provider replays goes only into an empty journal (an adopted session), as -// ordinary events with the provider's own ids, so re-running an interrupted adoption writes the -// same rows again. Limit, until the adoption work lifts it: a crash that cut the first run -// inside a turn leaves that turn as the restart's sweep settled it (`unverifiable`, its running -// tools `failed`), and the re-run writes nothing more into it. // - 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 @@ -38,7 +33,6 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import type { AgentJournalApprovalItem, AgentJournalItemBody, - AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalQuestionItem, AgentJournalTurnOutcome @@ -84,14 +78,6 @@ export type ProviderTimelineEvent = requestedAt: number join?: ProviderTimelineJoin } - /** A user message from the provider's saved history (an adopted session has no Orca send): its - * own row, keyed by `item`, and the message that opened `join.turn`. */ - | { - type: 'input.history' - item: string - body: AgentJournalMessageItem - join: ProviderTimelineJoin & { turn: string } - } /** 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 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 index e9c4471f6076..fd1399c4afc8 100644 --- 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 @@ -10,13 +10,11 @@ import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' import type { StructuredAgentSessionTransitionJournal } from '../agent-session-wire/structured-agent-session-transition' import { agentJournalTurnRowReservedBytes, resolveAgentJournalTurnRowWrite } from './agent-journal-turn-row-revision' -import { providerTimelinePlacement } from './provider-timeline-context' import type { ProviderTimelineDecidedEvent, ProviderTimelineDecision, @@ -187,30 +185,6 @@ export function decideInput( ) } -/** A saved user message of an adopted session: its own row, only where none is, and the opener of - * the turn it names. */ -export function decideHistory( - input: ProviderTimelineDecisionInput, - event: Extract -): ProviderTimelineDecision { - const { context, state } = input - const row = context.rows.item('item', providerKey(event.item), event.join.thread ?? null) - const write: ProviderTimelineResolvedWrite = { identity: row.identity, body: event.body } - const opener = decideOpener(input, { userItemId: row.itemId }, event.join.turn) - return { - ...opener, - writes: [ - { - reservedBytes: estimateStructuredAgentSessionItemBytes(row.identity, event.body), - lifecycle: false, - options: { turnScope: providerTimelinePlacement(context, state, event.join) }, - resolve: (at) => (at.itemBody(row.itemId) === null ? write : null) - }, - ...(opener.writes ?? []) - ] - } -} - /** 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( From 58c3dd548167f287371e4cef3d686419f32247e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:10:00 -0700 Subject: [PATCH 24/26] 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. --- .../agent-session-timeline/provider-timeline-state.ts | 4 ++-- .../provider-timeline-turn-decisions.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index aa8b0a1bc82c..991dea1ce264 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-state.ts @@ -28,10 +28,10 @@ export type ProviderTimelineOpenTurn = ProviderTimelineTurnRef & { running: AgentJournalTurnLifecycle } -/** A user message waiting for the turn it opens: Orca's send, or the provider's saved one. */ +/** Orca's send, waiting for the turn it opens. */ export type ProviderTimelinePendingInput = { userItemId: string - requestedAt?: number + requestedAt: number /** The turn row the provider said it opens; absent: the next turn to open. */ turnItemId?: string } 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 index fd1399c4afc8..7b1a5e4756e1 100644 --- 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 @@ -68,7 +68,7 @@ export function decideTurnOpen( state: 'running', userItemId: pending?.userItemId ?? turn.itemId, startedAt: event.at, - ...(pending?.requestedAt === undefined ? {} : { requestedAt: pending.requestedAt }) + ...(pending ? { requestedAt: pending.requestedAt } : {}) } const write: ProviderTimelineResolvedWrite = { identity: turn.identity, From eba27a3c784ea7e4c0a4eb3d6d6020d7962faf47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:26:46 -0700 Subject: [PATCH 25/26] Use current provider handles in transition tests --- .../structured-agent-session-transition.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index 6698b4cef5ec..d9a152d0e6ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -73,7 +73,7 @@ async function rig(watermarks: Partial = { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000 @@ -99,7 +99,7 @@ async function owedRig() { workspaceId: 'workspace-1', hostId: 'local', agent: 'grok', - providerHandle: { kind: 'opaque', agent: 'grok', value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } }, now: () => 1_000 }) From a7d8bcbb64fb6e60cd0cb1effe121cb1fadc67fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:27:40 -0700 Subject: [PATCH 26/26] Use current provider handles in timeline fixtures --- .../provider-timeline-assembler-test-support.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b0e5eb833a22..6946d455314b 100644 --- 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 @@ -228,7 +228,7 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { kind: 'opaque', agent: AGENT, value: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } }, stateDirectory: root, now: () => 1_000