From d438377157f23baf773f7aae4cdebf42a1a2b225 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:06:35 -0700 Subject: [PATCH 01/13] feat(native-chat): record which agent run wrote each journal item Each journal item row, subagent roster entry and background-task entry now records ownerFence: the lease fence of the agent run whose output produced it. Optional, persisted, no row version bump and no migration. The journal's itemFence() returns it, so the turn-verdict rules and per-run provider activity read the producing run rather than the writer's fence. --- ...p-timeline-grok-subagent-retention.test.ts | 11 +- .../acp/acp-timeline-grok-subagents.test.ts | 1 + .../journal-damage.test.ts | 4 +- .../journal-epoch-replacement.test.ts | 4 +- .../journal-epoch-replacement.ts | 4 + .../journal-item-fold.ts | 10 +- .../journal-item-provenance.test.ts | 186 ++++++++++++++++++ .../journal-item-provenance.ts | 129 ++++++++++++ .../journal-legacy-import.test.ts | 4 + .../journal-legacy-import.ts | 4 +- .../journal-lifecycle-batch-appender.ts | 15 +- ...-lifecycle-batch-partition.test-fixture.ts | 10 +- .../journal-lifecycle-batch-partition.test.ts | 1 + .../journal-lifecycle-batch-partition.ts | 2 + .../journal-lifecycle-mutation.ts | 7 +- .../journal-provider-activity.ts | 11 +- .../agent-session-journal/journal-reducer.ts | 16 +- .../journal-row-builders.ts | 35 +++- .../journal-row-schema.ts | 33 ++-- .../journal-row-unusable-annotations.ts | 10 + .../journal-store-contracts.ts | 6 + .../journal-submission-fold.ts | 3 +- ...der-timeline-assembler-attribution.test.ts | 11 +- ...tructured-agent-session-event-sink.test.ts | 9 +- .../structured-agent-session-event-sink.ts | 4 +- ...ed-agent-session-journal-append-options.ts | 4 + ...ent-session-prompt-answer-receipts.test.ts | 8 +- .../structured-agent-session-rewind.ts | 20 +- ...d-agent-session-stop-event-binding.test.ts | 3 +- .../structured-agent-session-transition.ts | 1 + .../structured-rewind-retained-host-rows.ts | 24 ++- src/shared/agent-session-journal-schemas.ts | 6 +- src/shared/agent-session-rewind.ts | 3 + src/shared/native-chat-types.ts | 5 + 34 files changed, 534 insertions(+), 70 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-item-provenance.ts diff --git a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts index edacc93ab735..44065c2843b5 100644 --- a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts +++ b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts @@ -153,12 +153,20 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { { id: 'old', label: 'Original label', + ownerFence: 1, startedAt: 2, settledAt: 301, state: 'completed', tokens: 12 }, - { id: 'sibling', label: 'Original label 2', startedAt: 3, settledAt: 4, state: 'completed' } + { + id: 'sibling', + label: 'Original label 2', + ownerFence: 1, + startedAt: 3, + settledAt: 4, + state: 'completed' + } ]) expect( (await roster(fixture)).flatMap(({ group }) => @@ -192,6 +200,7 @@ describe('Grok roster ownership after more than 32 spawning groups', () => { { id: 'old', label: 'Original label', + ownerFence: 1, startedAt: 2, settledAt: 301, state: kind === 'output' ? 'completed' : 'stopped' diff --git a/src/main/acp/acp-timeline-grok-subagents.test.ts b/src/main/acp/acp-timeline-grok-subagents.test.ts index 2acd725cf284..f3d7adadb158 100644 --- a/src/main/acp/acp-timeline-grok-subagents.test.ts +++ b/src/main/acp/acp-timeline-grok-subagents.test.ts @@ -62,6 +62,7 @@ describe('Grok subagents through the shared timeline', () => { { id: 'subagent-1', label: 'Count note lines', + ownerFence: 1, state: 'completed', tokens: 13778, startedAt: expect.any(Number), diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts index 8b59940a6a19..168e74484b69 100644 --- a/src/main/native-chat/agent-session-journal/journal-damage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts @@ -206,8 +206,8 @@ describe('a row the reader would reject', () => { await expect( journal.replaceEpochItems('legacy_import', 1, [ - { identity: item(5), body: body('replacement') }, - { identity: item(6), body: blankCallId } + { identity: item(5), body: body('replacement'), ownerFence: 1 }, + { identity: item(6), body: blankCallId, ownerFence: 1 } ]) ).rejects.toMatchObject({ code: 'journal_row_rejected' }) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 36247e2571ff..c64027b94555 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -96,7 +96,7 @@ describe('journal epoch replacement', () => { const published: JournalLoad[] = [] replace({ - items: [{ identity: item(1), body: { kind: 'status', text: 'republished' } }], + items: [{ identity: item(1), body: { kind: 'status', text: 'republished' }, ownerFence: 1 }], onPublished: (loaded) => published.push(loaded) }) @@ -112,7 +112,7 @@ describe('journal epoch replacement', () => { 'a replace', (journal: AgentSessionJournal) => journal.replaceEpochItems('legacy_import', 1, [ - { identity: item(9), body: { kind: 'status', text: 'republished' } } + { identity: item(9), body: { kind: 'status', text: 'republished' }, ownerFence: 1 } ]) ], ['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)] diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 67927b401588..17b9f5e3b956 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -38,6 +38,9 @@ export type JournalReplacementItem = AgentJournalProducerLinkage & { /** Absent for history rebuilt from a source that never stated one: derived from position, as * a legacy row's is, and then written down. */ turnScope?: AgentJournalTurnScope + /** Carried from the epoch it came from (`JournalItemRow.ownerFence`), never the replacing + * writer's fence: replacing history does not make it the current generation's work. */ + ownerFence: number } export function replaceJournalEpoch(input: { @@ -71,6 +74,7 @@ export function replaceJournalEpoch(input: { body: item.body, seq: state.lastSequence + 1, fence: input.fence, + ownerFence: item.ownerFence, ts: item.observedAt ?? input.now(), linkage: item, turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body) 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..6085e6ab88d7 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 @@ -27,8 +27,8 @@ export function upsertJournalItem( itemId: string, revision: number, next: AgentJournalRenderItem, - /** The creating write's fence: the generation a running turn belongs to. */ - fence: number + /** The write's fence, and the provenance it states (`JournalItemRow.ownerFence`). */ + fences: { fence: number; ownerFence?: number } ): void { const tombstoned = state.tombstones.get(itemId) if (tombstoned !== undefined && revision <= tombstoned) { @@ -41,10 +41,14 @@ export function upsertJournalItem( state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) if (!existing) { state.items.set(itemId, next) - state.itemFences.set(itemId, fence) + state.itemFences.set(itemId, fences.ownerFence ?? fences.fence) state.tombstones.delete(itemId) return } + // A revision keeps the provenance it found unless it states one: an owner's observation does. + if (fences.ownerFence !== undefined) { + state.itemFences.set(itemId, fences.ownerFence) + } // Creation sequence is the ordering key; a revision refreshes content only. // `observedAt` is pinned with it: clients sort the timeline by that timestamp, // so letting a revision advance it makes the row jump past everything that diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts new file mode 100644 index 000000000000..e275e8102eda --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -0,0 +1,186 @@ +// Execution provenance (`JournalItemRow.ownerFence`): the generation whose execution produced an +// item, kept apart from each row's writer fence. Every writer states it; the fold prefers it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows' +import type { JournalHostDatabase } from './journal-host-database' +import { + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} +const SCOPE = AGENT_JOURNAL_THREAD_SCOPE +const TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} +const TURN_ID = agentJournalItemKey(TURN) +const RUNNING: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } +const SETTLED: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'interrupted' } + +let root: string +let database: JournalHostDatabase +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +type StoredItemWrite = { kind?: string; ownerFence?: number; mutations?: StoredItemWrite[] } + +/** Each stored item write's stated provenance, in write order, a lifecycle batch's included. */ +function storedOwnerFences(): (number | undefined)[] { + return liveTestJournalRows(database.db, IDENTITY.sessionId).flatMap((row) => { + const parsed: StoredItemWrite = JSON.parse(row.rowJson) + const writes = parsed.kind === 'lifecycle-batch' ? (parsed.mutations ?? []) : [parsed] + return writes.flatMap((write) => (write.kind === 'item' ? [write.ownerFence] : [])) + }) +} + +async function open(): Promise { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-provenance-')) + database = openTestJournalHostDatabase(root) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('execution provenance', () => { + it("records a provider observation's owner, and a host revision keeps it", async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) + // Host bookkeeping under a later writer fence states no owner of its own. + await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE }) + + expect(journal.itemFence(TURN_ID)).toBe(3) + // Every row states it: none leaves the reader to guess from the writer fence. + expect(storedOwnerFences()).toEqual([3, 3]) + }) + + it('a first write with no stated owner is its writer generation’s', async () => { + const journal = await open() + await journal.appendItem( + item(2), + { kind: 'status', text: 'note' }, + { fence: 6, turnScope: SCOPE } + ) + expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6) + expect(storedOwnerFences()).toEqual([6]) + }) + + it('a lifecycle batch states it on each item: the batch owner, else the kept value', async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) + // A provider batch at the owner's fence creates one item. + await journal.appendLifecycleBatch({ + settlementId: 'provider', + fence: 3, + ownerFence: 3, + mutations: [{ kind: 'item', identity: item(2), body: RUNNING, turnScope: SCOPE }] + }) + // A settlement at a later fence revises the first: it keeps that item's owner. + await journal.appendLifecycleBatch({ + settlementId: 'settlement', + fence: 4, + recovered: true, + mutations: [{ kind: 'item', identity: TURN, body: SETTLED, turnScope: SCOPE }] + }) + + expect(journal.itemFence(TURN_ID)).toBe(2) + expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(3) + expect(storedOwnerFences()).toEqual([2, 3, 2]) + }) + + it('survives a reopen from storage', async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) + await journals.closeAll() + + expect((await open()).itemFence(TURN_ID)).toBe(3) + }) + + it('epoch replacement carries each item’s provenance', async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) + const before = journal.epoch + + await journal.replaceEpochItems('legacy_import', 5, [ + { identity: TURN, body: RUNNING, ownerFence: journal.itemFence(TURN_ID) ?? 0 }, + { identity: item(2), body: { kind: 'status', text: 'new' }, ownerFence: 5 } + ]) + + expect(journal.epoch).not.toBe(before) + // Written at fence 5, the dead generation's turn is still fence 2's work. + expect(journal.itemFence(TURN_ID)).toBe(2) + expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(5) + }) + + it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => { + const row = { + itemId: TURN_ID, + body: RUNNING, + observedAt: 1, + ownerFence: 2 + } + expect(retainedRowReplacement(row).ownerFence).toBe(2) + const { ownerFence: _stated, ...older } = row + expect(retainedRowReplacement(older).ownerFence).toBe(0) + }) + + it('a stored provenance that is not a fence is dropped, and the row reads as its writer’s', async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) + const rows = liveTestJournalRows(database.db, IDENTITY.sessionId) + const stored = rows.find((row) => row.rowJson.includes('"kind":"item"')) + const last = rows.at(-1)?.seq + if (!stored || last === undefined) { + throw new Error('expected the item row') + } + await journals.closeAll() + database = openTestJournalHostDatabase(root) + const parsed: Record = JSON.parse(stored.rowJson) + insertTestJournalRowJson( + database.db, + IDENTITY.sessionId, + last + 1, + JSON.stringify({ + ...parsed, + seq: last + 1, + revision: Number(parsed.revision) + 1, + fence: 7, + ownerFence: -1 + }) + ) + + expect((await open()).itemFence(TURN_ID)).toBe(2) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts new file mode 100644 index 000000000000..06f37469f5b7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts @@ -0,0 +1,129 @@ +// Execution provenance: the generation whose execution produced an item (`JournalItemRow.ownerFence`) +// and each entry of a container row. +// +// Each entry of a container row: every subagent roster entry and every +// background task. One roster row is revised in place across generations (a resumed provider keeps +// the group's identity), so the row's own provenance cannot say which generation each child +// belongs to. A provider's observation stamps the entries it changed or added with its own +// generation; an entry it carries unchanged keeps the one it had (or, never stamped, the row's +// earlier provenance). Host bookkeeping states no provenance and stamps nothing. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { JournalReducerState } from './journal-reducer' +import { + isBackgroundTaskBlock, + isSubagentGroupBlock, + type NativeChatBlock +} from '../../../shared/native-chat-types' + +type Entry = { ownerFence?: number } + +/** An entry's observed fields, in a fixed order: its provenance is not part of what was seen. */ +function observationKey(entry: Entry): string { + const { ownerFence: _ownerFence, ...fields } = entry + return JSON.stringify(Object.entries(fields).sort(([left], [right]) => left.localeCompare(right))) +} + +function stamp( + entry: T, + previous: T | undefined, + previousItemFence: number | undefined, + ownerFence: number +): T { + if (previous && observationKey(previous) === observationKey(entry)) { + const kept = previous.ownerFence ?? previousItemFence + return kept === undefined || kept === entry.ownerFence ? entry : { ...entry, ownerFence: kept } + } + // No earlier entry to compare: one that already names its generation (history carried into a new + // epoch) keeps it. + if (!previous && entry.ownerFence !== undefined) { + return entry + } + return entry.ownerFence === ownerFence ? entry : { ...entry, ownerFence } +} + +function hasEntries(body: AgentJournalItemBody): boolean { + return ( + body.kind === 'message' && + body.blocks.some((block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block)) + ) +} + +/** The body as written by an observation of generation `ownerFence`, each entry stamped. */ +export function stampJournalEntryProvenance( + body: AgentJournalItemBody, + previous: { body: AgentJournalItemBody; ownerFence?: number } | undefined, + ownerFence: number +): AgentJournalItemBody { + if (body.kind !== 'message' || !hasEntries(body)) { + return body + } + const earlier: readonly NativeChatBlock[] = + previous?.body.kind === 'message' ? previous.body.blocks : [] + const blocks = body.blocks.map((block): NativeChatBlock => { + if (isSubagentGroupBlock(block)) { + const group = earlier.find( + (candidate) => isSubagentGroupBlock(candidate) && candidate.groupId === block.groupId + ) + const agents = group && isSubagentGroupBlock(group) ? group.agents : [] + return { + ...block, + agents: block.agents.map((agent) => + stamp( + agent, + agents.find((candidate) => candidate.id === agent.id), + previous?.ownerFence, + ownerFence + ) + ) + } + } + if (isBackgroundTaskBlock(block)) { + const task = earlier.find( + (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === block.taskId + ) + return stamp( + block, + task && isBackgroundTaskBlock(task) ? task : undefined, + previous?.ownerFence, + ownerFence + ) + } + return block + }) + return { ...body, blocks } +} + +/** The provenance a row states: the writer's own claim when it has one — a provider's observation, + * or history carried into a new epoch — else host bookkeeping's, which keeps the item's, and for + * an item it creates is the fence it writes at. Every item row states it. */ +export function journalItemOwnerFence( + state: Pick, + resolvedItemId: string, + write: { fence: number; ownerFence?: number } +): number { + if (write.ownerFence !== undefined) { + return write.ownerFence + } + const kept = state.items.has(resolvedItemId) ? state.itemFences.get(resolvedItemId) : undefined + return kept ?? write.fence +} + +/** A provider's observation stamps each roster entry and background task it saw + * (`stampJournalEntryProvenance`); host bookkeeping states no provenance and stamps nothing. */ +export function journalObservedBody( + state: Pick, + resolvedItemId: string, + body: AgentJournalItemBody, + ownerFence: number | undefined +): AgentJournalItemBody { + if (ownerFence === undefined) { + return body + } + const previous = state.items.get(resolvedItemId) + return stampJournalEntryProvenance( + body, + previous && { body: previous.body, ownerFence: state.itemFences.get(resolvedItemId) }, + ownerFence + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index f4cad1f9edc0..9da96daf490c 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -245,6 +245,10 @@ describe('claude import', () => { uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31' }) ]) + // Imported at fence 1, yet no generation's work: provenance 0, so nothing in it is current. + expect(journal.snapshot().items.map((entry) => journal.itemFence(entry.itemId))).toEqual([ + 0, 0, 0, 0 + ]) }) it('stays aligned when the decoder drops lines the tracker still walks', async () => { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 1e6171e94aa9..7c8b67e440a2 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -138,7 +138,9 @@ export async function prepareLegacyTranscriptImport(input: { replacement.push({ identity, body: legacyItemBody(message, limits), - observedAt: message.timestamp ?? undefined + observedAt: message.timestamp ?? undefined, + // Imported history is no generation's work: never current, whatever it says still runs. + ownerFence: 0 }) } return { ok: true, items: replacement } 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 f942f0185d05..9ae46af77acc 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 @@ -75,12 +75,22 @@ export class JournalLifecycleBatchAppender { } private planMutations( - input: Pick, - mutations: readonly JournalLifecycleMutationInput[] + input: Pick, + stated: readonly JournalLifecycleMutationInput[] ): ((seq: number, ts: number) => JournalRow)[] { if (this.wasApplied(input.settlementId)) { return [] } + // A provider's batch states its generation on every item it writes. + const { ownerFence } = input + const mutations = + ownerFence === undefined + ? stated + : stated.map((mutation) => + mutation.kind === 'item' && mutation.ownerFence === undefined + ? { ...mutation, ownerFence } + : mutation + ) const current = this.deps.state() const options = { ...input, epoch: current.epoch } // Every row in this transaction must advance past the rows planned before it. @@ -141,6 +151,7 @@ export class JournalLifecycleBatchAppender { ...only.linkage, turnScope: only.turnScope, fence: input.fence, + ownerFence: only.ownerFence, recovered: input.recovered }, revisions diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test-fixture.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test-fixture.ts index 509850c78841..cc94120fe538 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test-fixture.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test-fixture.ts @@ -28,8 +28,8 @@ export const LEGACY_MIXED_PARTITIONS = [ [['', 30]], [['', 96]], [ - [':1/2', 117], - [':2/2', 5] + [':1/2', 116], + [':2/2', 6] ], [['', 70]], [ @@ -37,9 +37,9 @@ export const LEGACY_MIXED_PARTITIONS = [ [':2/2', 34] ], [ - [':1/3', 66], - [':2/3', 66], - [':3/3', 32] + [':1/3', 65], + [':2/3', 65], + [':3/3', 34] ], [ [':1/4', 62], diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts index 25b6f5b96531..1943f30dd673 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.test.ts @@ -57,6 +57,7 @@ function previousProbe(settlementId: string, mutations: readonly JournalLifecycl ? { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } } : mutation, journalLifecycleMutationItemId(mutation), + Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER ) ) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 4e01628c6d58..525605b9ec3b 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -136,6 +136,8 @@ function toLifecycleMutationRow(mutation: JournalLifecycleMutationInput): Journa return journalLifecycleMutationRow( sizedAsStopped(mutation), journalLifecycleMutationItemId(mutation), + Number.MAX_SAFE_INTEGER, + // The widest provenance any row can state, so the chunk built from it still fits. Number.MAX_SAFE_INTEGER ) } diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-mutation.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-mutation.ts index 1340b99f3106..9b939c267436 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-mutation.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-mutation.ts @@ -26,6 +26,8 @@ export type JournalLifecycleMutationInput = JournalItemAddress & linkage?: AgentJournalProducerLinkage /** Which turn the row belongs to. Kept from the write that creates the row. */ turnScope: AgentJournalTurnScope + /** The writer's provenance claim (`journalItemOwnerFence`); absent for host bookkeeping. */ + ownerFence?: number } | { kind: 'tombstone' } ) @@ -61,7 +63,9 @@ export function journalLifecycleItemMutation( export function journalLifecycleMutationRow( mutation: JournalLifecycleMutationInput, itemId: string, - revision: number + revision: number, + /** What an item mutation states (`journalItemOwnerFence`). */ + ownerFence: number ): JournalLifecycleMutation { return mutation.kind === 'item' ? { @@ -70,6 +74,7 @@ export function journalLifecycleMutationRow( revision, body: mutation.body, turnScope: mutation.turnScope, + ownerFence, ...agentJournalLinkageFields(mutation.linkage) } : { kind: 'tombstone', itemId, revision } diff --git a/src/main/native-chat/agent-session-journal/journal-provider-activity.ts b/src/main/native-chat/agent-session-journal/journal-provider-activity.ts index 09a965b624ef..5a7c29ef361b 100644 --- a/src/main/native-chat/agent-session-journal/journal-provider-activity.ts +++ b/src/main/native-chat/agent-session-journal/journal-provider-activity.ts @@ -4,13 +4,15 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -/** Saved output extends a turn; client actions and recovery writes do not. */ +/** Saved output extends a turn; client actions and recovery writes do not. Keyed by the fence of + * the generation that produced the item (`AgentSessionJournal.itemFence`), as a verdict reads it. */ export function observeJournalProviderActivity( state: Pick, row: JournalRow, itemId: string, body: AgentJournalItemBody, - savedAt: number + savedAt: number, + fence: number ): void { if (row.recovered || !isProviderActivity(body)) { return @@ -25,10 +27,7 @@ export function observeJournalProviderActivity( ) { return } - state.providerActivityAt.set( - row.fence, - Math.max(state.providerActivityAt.get(row.fence) ?? 0, savedAt) - ) + state.providerActivityAt.set(fence, Math.max(state.providerActivityAt.get(fence) ?? 0, savedAt)) } function isProviderActivity(body: AgentJournalItemBody): boolean { 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 41da4e5f459b..df5ccb9a976b 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -51,7 +51,8 @@ export type JournalReducerState = { oldestSequence: number highestFence: number items: Map - /** Fence of the writer that created each item: the generation a running turn belongs to. */ + /** Each item's execution provenance (`JournalItemRow.ownerFence`): the generation whose work it + * is, which decides whether it is current. */ itemFences: Map /** Latest saved output per owner, rebuilt during the existing row fold. */ providerActivityAt: Map @@ -109,15 +110,16 @@ export function applyJournalRow( return } const itemId = resolveJournalItemId(state, row.itemId, row.body) - observeJournalProviderActivity(state, row, row.itemId, row.body, savedAt) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) upsertJournalItem( state, itemId, row.revision, journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)), - row.fence + { fence: row.fence, ownerFence: row.ownerFence } ) + const fence = state.itemFences.get(itemId) ?? row.fence + observeJournalProviderActivity(state, row, row.itemId, row.body, savedAt, fence) return } if (isJournalStopOrResumeRow(row)) { @@ -141,13 +143,17 @@ export function applyJournalRow( continue } const { revision, body } = mutation - observeJournalProviderActivity(state, row, mutation.itemId, body, savedAt) const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) const producer = journalBatchMutationProducer(row, mutation) const scope = statedOrDerivedTurnScope(state, mutation) const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex) - upsertJournalItem(state, itemId, revision, item, row.fence) + upsertJournalItem(state, itemId, revision, item, { + fence: row.fence, + ownerFence: mutation.ownerFence + }) + const fence = state.itemFences.get(itemId) ?? row.fence + observeJournalProviderActivity(state, row, mutation.itemId, body, savedAt, fence) } else { removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } 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 a6e3aa03d189..229e1656ec5f 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 @@ -13,6 +13,7 @@ import { agentSessionJournalProviderHandle } from '../../../shared/agent-session import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { journalItemOwnerFence, journalObservedBody } from './journal-item-provenance' import type { JournalReducerState } from './journal-reducer' import type { JournalDispatchRow, @@ -51,7 +52,12 @@ export function journalItemRowBuilder( state: () => JournalReducerState, address: AgentJournalItemIdentity | string, body: AgentJournalItemBody, - options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true }, + options: AgentJournalRowAttribution & { + fence: number + ownerFence?: number + observedAt?: number + recovered?: true + }, revisions?: Map ): RowBuilder { return (seq, ts) => @@ -61,6 +67,7 @@ export function journalItemRowBuilder( body, seq, fence: options.fence, + ownerFence: options.ownerFence, ts: options.observedAt ?? ts, recovered: options.recovered, linkage: options, @@ -183,10 +190,22 @@ export function journalLifecycleBatchRowBuilder( const revision = nextJournalItemRevision(current, itemId, runningRevisions) return journalLifecycleMutationRow( mutation.kind === 'item' - ? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) } + ? { + ...mutation, + body: journalObservedBody( + current, + resolved, + turnEndAfterStop(current, resolved, mutation.body), + mutation.ownerFence + ) + } : mutation, itemId, - revision + revision, + journalItemOwnerFence(current, resolved, { + fence: options.fence, + ownerFence: mutation.kind === 'item' ? mutation.ownerFence : undefined + }) ) }) const row: JournalLifecycleBatchRow = { @@ -225,6 +244,8 @@ export function buildJournalItemRow( body: AgentJournalItemBody seq: number fence: number + /** The writer's provenance claim (`journalItemOwnerFence`); absent for host bookkeeping. */ + ownerFence?: number ts: number recovered?: true linkage?: AgentJournalProducerLinkage @@ -237,13 +258,19 @@ export function buildJournalItemRow( // A tombstoned row keeps its revision in `tombstones`, and the reducer drops // any item at or below it — so a re-add has to outrank the tombstone too. const revision = nextJournalItemRevision(input.state, itemId, input.revisions) - const body = turnEndAfterStop(input.state, resolved, input.body) + const body = journalObservedBody( + input.state, + resolved, + turnEndAfterStop(input.state, resolved, input.body), + input.ownerFence + ) return { kind: 'item', itemId, revision, body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), + ownerFence: journalItemOwnerFence(input.state, resolved, input), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, ...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 f018f6307f94..494495248ca2 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 @@ -80,12 +80,18 @@ export const AGENT_JOURNAL_EPOCH_REASONS = [ ] as const export type AgentJournalEpochReason = (typeof AGENT_JOURNAL_EPOCH_REASONS)[number] -export type JournalItemRow = JournalRowBase & { - kind: 'item' - itemId: string - revision: number - body: AgentJournalItemBody -} +/** The generation whose execution produced an item: execution provenance, distinct from the + * row's `fence`, which is writer authority. Rides the row like linkage, with no `v` bump: an older + * host ignores it. Absent on rows from before it: the item's creating fence stands in. */ +type JournalItemProvenance = { ownerFence?: number } + +export type JournalItemRow = JournalRowBase & + JournalItemProvenance & { + kind: 'item' + itemId: string + revision: number + body: AgentJournalItemBody + } export type JournalTombstoneRow = JournalRowBase & { kind: 'tombstone' @@ -196,13 +202,14 @@ export type JournalDispatchRow = JournalRowBase & { * Inline like the row base, and for the same reason no `v` bump: an older host * ignores the unknown keys and reads the mutation as root, as it always did. */ export type JournalLifecycleMutation = - | (AgentJournalProducerLinkage & { - kind: 'item' - itemId: string - revision: number - body: AgentJournalItemBody - turnScope?: AgentJournalTurnScope - }) + | (AgentJournalProducerLinkage & + JournalItemProvenance & { + kind: 'item' + itemId: string + revision: number + body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope + }) | { kind: 'tombstone'; itemId: string; revision: number } /** One durable append whose nested mutations share the outer ordering facts. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts index 6c8811eb4a5b..e6d25b21df9e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -6,11 +6,13 @@ import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-sess export function dropUnusableRowAnnotations(record: Record): void { dropUnusableProducerLinkage(record) dropUnusableTurnScope(record) + dropUnusableOwnerFence(record) if (record.kind === 'lifecycle-batch' && Array.isArray(record.mutations)) { for (const mutation of record.mutations) { if (isPlainObject(mutation)) { dropUnusableProducerLinkage(mutation) dropUnusableTurnScope(mutation) + dropUnusableOwnerFence(mutation) } } } @@ -56,6 +58,14 @@ function dropUnusableTurnScope(record: Record): void { } } +/** Provenance this build cannot read, removed like linkage: the item's creating fence stands in. */ +function dropUnusableOwnerFence(record: Record): void { + const value = record.ownerFence + if (value !== undefined && !(Number.isSafeInteger(value) && Number(value) >= 0)) { + delete record.ownerFence + } +} + /** Context facts this build cannot read, removed from the turn row that carries * them. Same reasoning as linkage: they are an annotation on the turn, and * rejecting the row for them would truncate the journal from that row on. */ 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 f9f7502ea8b8..72c387e50908 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 @@ -59,6 +59,9 @@ export type JournalAppendResult = { export type JournalItemAppendOptions = AgentJournalRowAttribution & { fence: number + /** The generation whose execution produced the item: set by a provider's observation, and by + * nothing else. Host bookkeeping leaves it out and keeps the item's (`journalItemOwnerFence`). */ + ownerFence?: number observedAt?: number recovered?: true } @@ -76,6 +79,9 @@ export type JournalLifecycleBatchInput = { settlementId: string mutations: readonly JournalLifecycleMutationInput[] fence: number + /** A provider's own batch: the generation that produced every item it writes + * (`JournalItemAppendOptions.ownerFence`). */ + ownerFence?: number recovered?: true /** Submission verdicts belonging to this settlement, committed before its item rows. */ dispatches?: readonly ResolveDispatchInput[] diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 7b03b4f457f8..1369f57892ee 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -46,7 +46,8 @@ export function applyJournalSubmission( itemId, 0, journalRenderItem(itemId, 0, row.body, row, turnScope), - row.fence + // A person's message is no generation's work: its provenance is the fence that accepted it. + { fence: row.fence } ) } 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 c49d266e3432..82703eaf0d70 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 @@ -215,9 +215,14 @@ describe('a background task', () => { 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') - ) + // The entry keeps the generation that ran it (`ownerFence`), stamped when it was observed. + const unverifiable = backgroundTask('bg', 'unverifiable') + expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual({ + ...unverifiable, + blocks: unverifiable.blocks.map((block) => + block.type === 'background-task' ? { ...block, ownerFence: 1 } : block + ) + }) expect(await backgroundTaskState(rig, 'ran')).toBe('done') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 476089292a93..3a443559e2e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -577,7 +577,7 @@ describe('producer linkage reaches the journal through every append path', () => await deferred.drained() expect(journalAppendOptions).toEqual([ - { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + { fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } ]) deferred.close() } @@ -601,7 +601,7 @@ describe('producer linkage reaches the journal through every append path', () => await deferred.drained() expect(journalAppendOptions).toEqual([ - { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + { fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } ]) deferred.close() } @@ -637,7 +637,10 @@ describe('producer linkage reaches the journal through every append path', () => // A control, not a pin. Absence is the claim, so the keys must be missing // rather than present-and-undefined: a reader holding this options object // would read `agentId: undefined` as a key that exists. - expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }]) + // A provider observation names the generation that produced it: the bound fence. + expect(journalAppendOptions).toEqual([ + { fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ]) deferred.close() }) }) 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 1e2d2c64d70a..d375bc9cddd7 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 @@ -246,7 +246,9 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { settlementId, mutations, // No row-level linkage: each mutation names its own (see the batch row builder). - fence: bound.fence + fence: bound.fence, + // The provider's own observation: its child's generation produced every item. + ownerFence: bound.fence }) }, { ...options, lifecycle: true } 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..4aa4d0f646da 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 @@ -3,6 +3,9 @@ // Every such path calls this, so a row-level field added to the sink's options // reaches the durable row through one edit rather than through whichever spread // the next change remembers. Lifecycle batches carry producers per mutation. +// +// The sink carries only the provider's own observations, bound at its child's fence: that fence is +// the generation whose execution produced each item (`JournalItemRow.ownerFence`). import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' @@ -14,6 +17,7 @@ export function structuredAgentSessionJournalAppendOptions( ): JournalItemAppendOptions { return { fence, + ownerFence: fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), turnScope: options.turnScope, ...agentJournalLinkageFields(options) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index 87ad1fc95a11..8cefd4c0d9ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -115,7 +115,13 @@ async function reviseFromProvider(itemId: string): Promise { async function rewindKeepingPrompt(itemId: string): Promise { const fence = store.getRecord(SESSION)!.lease.runtimeFence await journal().replaceEpochItems('handle_forked', fence, [ - { identity: PROMPT, body: promptBody(itemId), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + // The prompt keeps the generation that raised it (this one), as a rewind carries it. + { + identity: PROMPT, + body: promptBody(itemId), + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + ownerFence: fence + } ]) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index bd819d09f2ca..5c1f9df0ce41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -2,7 +2,6 @@ import { agentSessionProviderHandleChainHead } from '../../../shared/agent-sessi import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { - agentJournalItemKey, agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -23,7 +22,10 @@ import { conversationCommandBlocked } from './structured-conversation-command-ad import { rewindRefusal } from './structured-rewind-refusal' import { renameRewindTurnOpener } from './structured-rewind-journal-body' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' -import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { + mergeRetainedHostLifecycleRows, + rewindProviderRows +} from './structured-rewind-retained-host-rows' export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, @@ -165,11 +167,13 @@ export async function rewindStructuredAgentSession( if (!body) { throw new Error('agent_session_rewind:missing-retained-item') } + const ownerFence = ctx.journal.itemFence(itemId) return { itemId: providerKey(itemId), body: renameRewindTurnOpener(body, providerKey), observedAt, ...(turnScope ? { turnScope } : {}), + ...(ownerFence === undefined ? {} : { ownerFence }), ...agentJournalLinkageFields(linkage) } }) @@ -204,11 +208,7 @@ export async function rewindStructuredAgentSession( onPrepared: async (items) => { const retained = mergeRetainedHostLifecycleRows( prepared.retained, - items.map(({ identity, body }) => ({ - itemId: agentJournalItemKey(identity), - body, - observedAt: ctx.now() - })) + rewindProviderRows(items, ctx.now(), ctx.fence) ) if ( retained.length > 10_000 || @@ -241,11 +241,7 @@ export async function rewindStructuredAgentSession( const confirmed = provider.items ? mergeRetainedHostLifecycleRows( prepared.retained, - provider.items.map(({ identity, body }) => ({ - itemId: agentJournalItemKey(identity), - body, - observedAt: ctx.now() - })) + rewindProviderRows(provider.items, ctx.now(), ctx.fence) ) : prepared.retained if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index c09d2eba456c..250c00bd1f58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -443,7 +443,8 @@ describe('a rewind that restates a turnless Stop', () => { await journal().replaceEpochItems('handle_forked', 1, [ { identity: stoppedTurn, - body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } + body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' }, + ownerFence: 1 } ]) const mail = rig.send('mail after the rewind') 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 d1cfdec43356..a6b5b9b30be9 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 @@ -119,6 +119,7 @@ function transitionAppend( batch: { settlementId: step.settlementId, fence, + ownerFence: fence, recovered: step.recovered, resolve: () => step.resolve(journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index f41e83522d2c..5fc0f7f184a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -5,7 +5,10 @@ // each keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_ITEM_BODY_KINDS, isAdmissibleAgentJournalMessageBody @@ -64,12 +67,28 @@ export function mergeRetainedHostLifecycleRows( ) } +/** The live agent's rewound history as retained rows: its own observation, at the fence it + * answered at. A row the old epoch held keeps that row's provenance when merged. */ +export function rewindProviderRows( + items: readonly { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[], + observedAt: number, + ownerFence: number +): RetainedRow[] { + return items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt, + ownerFence + })) +} + /** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & { identity: AgentJournalItemIdentity body: AgentJournalItemBody observedAt: number turnScope?: AgentJournalTurnScope + ownerFence: number } { const identity = parseAgentJournalItemKey(row.itemId) if (!identity) { @@ -87,6 +106,9 @@ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLi body: restoreRewindJournalBody(row.body), observedAt: row.observedAt, ...(scope ? { turnScope: scope } : {}), + // The old fold's provenance, so a rewind never makes a dead generation's work current. A record + // from before rows carried it states none: 0, which is never current. + ownerFence: row.ownerFence ?? 0, ...(agentId === undefined ? {} : { agentId }), ...(parentAgentId === undefined ? {} : { parentAgentId }), ...(providerParentRef === undefined ? {} : { providerParentRef }), diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 9e5262693514..6c63c6e1d397 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -70,7 +70,8 @@ const SubagentEntry = z.object({ state: z.string().min(1), tokens: z.number().optional(), startedAt: z.number().optional(), - settledAt: z.number().optional() + settledAt: z.number().optional(), + ownerFence: z.number().int().nonnegative().optional() }) /** Renderers select blocks by `type` equality and skip what they cannot draw, @@ -125,7 +126,8 @@ const Block = openDiscriminatedUnion( outputFile: z.string().optional(), tokens: z.number().optional(), startedAt: z.number().optional(), - settledAt: z.number().optional() + settledAt: z.number().optional(), + ownerFence: z.number().int().nonnegative().optional() }) ]) ) diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts index e7d864390cca..36e30f9b79a4 100644 --- a/src/shared/agent-session-rewind.ts +++ b/src/shared/agent-session-rewind.ts @@ -55,6 +55,9 @@ export const AgentSessionRewindRecordSchema = z.object({ observedAt: z.number().finite(), // Absent on records written before rows stated them: the rebuild then places the row by position. turnScope: AgentJournalTurnScopeSchema.optional(), + // The row's execution provenance, carried into the rebuilt epoch. Absent on records written + // before rows carried it. + ownerFence: z.number().int().nonnegative().optional(), ...AgentJournalProducerLinkageFields }) ) diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 28e9f86f9f3d..c43191e3d171 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -151,6 +151,9 @@ export type NativeChatSubagentEntry = { startedAt?: number /** Epoch ms the entry latched terminal. */ settledAt?: number + /** The agent generation (lease fence) whose execution this entry last observed: stamped by the + * journal on a fresh observation, kept when carried unchanged. Absent: the row's own. */ + ownerFence?: number } /** One spawn group's roster, revised in place as its children report activity. @@ -195,6 +198,8 @@ export type NativeChatBackgroundTaskBlock = { startedAt?: number /** Epoch ms the row latched terminal. */ settledAt?: number + /** As `NativeChatSubagentEntry.ownerFence`. */ + ownerFence?: number } export type NativeChatBlock = From db574ae83acd9b17d348f69d0fd371ffa9c603ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:56:55 -0700 Subject: [PATCH 02/13] feat(native-chat): one current-work projection for every host reader; record death first The host decides what work is current in one place (structured-agent-session-current-work.ts) from the lease, the exit this host saw, the runtimes it replaced, its live child and each item's provenance. Every host reader (queue, Stop, prompt answers, commands, status feed, idle sweep, teardown snapshot, history pages and frames, orchestration mailbox) reads it, so a dead agent's leftovers stop reading Working or holding the queue even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope() become the raw runningTurn(). On exit the host records the child's end first and signals generationEnded, which republishes frames, the status feed and settlement waits and wakes the queue drain with no journal row. Pages and frames carry optional working and actionablePromptIds fields. --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + .../first-work-branch-rename.test.ts | 14 +- .../claude-background-stop-records.test.ts | 9 +- .../claude-background-wake-retention.test.ts | 7 +- ...claude-subagent-permission-request.test.ts | 6 +- ...tructured-journal-exit-convergence.test.ts | 22 +- ...journal-atomic-terminal-settlement.test.ts | 8 +- .../journal-store.test.ts | 2 +- .../agent-session-journal/journal-store.ts | 24 +- ...nal-terminal-settlement-validation.test.ts | 6 +- .../agent-session-history-page.ts | 82 ++++-- .../agent-session-stop-note-refresh.test.ts | 97 +++++++ .../agent-session-stop-note-refresh.ts | 12 +- .../agent-session-subscriber-catch-up.ts | 43 ++- ...structured-agent-session-attach-context.ts | 1 + ...structured-agent-session-attach-failure.ts | 2 + .../structured-agent-session-attach-flow.ts | 8 +- ...d-agent-session-background-task-channel.ts | 17 +- .../structured-agent-session-chat-stop.ts | 2 + .../structured-agent-session-child-exit.ts | 52 ++-- ...red-agent-session-claude-root-exit.test.ts | 2 +- ...tructured-agent-session-client-delivery.ts | 62 +++- .../structured-agent-session-command-turn.ts | 14 +- ...red-agent-session-conversation-lifetime.ts | 4 + ...uctured-agent-session-current-work.test.ts | 274 ++++++++++++++++++ .../structured-agent-session-current-work.ts | 268 +++++++++++++++++ .../structured-agent-session-delivery-loop.ts | 6 +- ...structured-agent-session-event-recovery.ts | 5 +- ...structured-agent-session-history-result.ts | 13 +- ...uctured-agent-session-host-current-work.ts | 57 ++++ .../structured-agent-session-host-delivery.ts | 9 +- .../structured-agent-session-host-lifetime.ts | 2 + ...ructured-agent-session-host-status-feed.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 39 ++- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.ts | 22 +- ...tructured-agent-session-idle-sweep.test.ts | 3 +- .../structured-agent-session-idle-sweep.ts | 50 ++-- ...ession-leftover-settlement.test-fixture.ts | 145 +++++++++ ...red-agent-session-live-turn-window.test.ts | 2 +- ...ctured-agent-session-mutation-admission.ts | 3 + ...ructured-agent-session-mutation-context.ts | 6 + ...red-agent-session-mutation-turn-context.ts | 6 +- .../structured-agent-session-prompt-cancel.ts | 7 +- ...structured-agent-session-provider-child.ts | 16 +- ...ed-agent-session-queued-card-holds.test.ts | 36 ++- ...ructured-agent-session-queued-edit.test.ts | 8 +- ...ructured-agent-session-queued-gate.test.ts | 10 +- ...session-queued-message-rig.test-fixture.ts | 11 +- ...tructured-agent-session-queued-messages.ts | 101 +++---- ...ructured-agent-session-queued-mutations.ts | 6 +- ...ctured-agent-session-queued-publication.ts | 30 +- .../structured-agent-session-queued-stop.ts | 11 +- .../structured-agent-session-queued-wiring.ts | 10 +- ...ent-session-restart-resume-test-harness.ts | 7 +- ...ed-agent-session-rewind-provenance.test.ts | 154 ++++++++++ .../structured-agent-session-rewind.ts | 3 + ...ured-agent-session-send-settlement.test.ts | 21 +- ...tructured-agent-session-send-settlement.ts | 8 +- .../structured-agent-session-shown-work.ts | 13 +- ...red-agent-session-signed-out-child.test.ts | 7 +- ...ructured-agent-session-signed-out-child.ts | 7 +- ...agent-session-startup-failure-exit.test.ts | 2 +- ...ructured-agent-session-status-feed.test.ts | 8 +- .../structured-agent-session-status-feed.ts | 22 +- ...agent-session-status-journal-projection.ts | 73 ++++- ...d-agent-session-stop-event-binding.test.ts | 8 +- ...d-agent-session-stop-event-entries.test.ts | 2 +- ...d-agent-session-stop-event-restart.test.ts | 2 +- ...tructured-agent-session-stop-event.test.ts | 2 +- ...red-agent-session-stop-never-waits.test.ts | 2 +- ...-agent-session-stop-settle-binding.test.ts | 8 +- ...structured-agent-session-stop-wind-down.ts | 12 +- .../structured-agent-session-stopping.test.ts | 8 +- .../structured-agent-session-stopping.ts | 16 +- .../structured-agent-session-subscribers.ts | 41 ++- .../structured-agent-session-thread-goal.ts | 3 +- ...tructured-agent-session-turn-stop-notes.ts | 7 +- .../structured-agent-session-turns-cancel.ts | 39 +-- .../structured-agent-session-turns-prompt.ts | 8 +- .../structured-agent-session-turns.ts | 12 +- ...ured-agent-session-unexpected-exit.test.ts | 17 +- ...tured-agent-session-working-at-teardown.ts | 37 ++- ...red-conversation-command-admission.test.ts | 13 +- ...ructured-conversation-command-admission.ts | 27 +- .../structured-conversation-command.ts | 3 + ...structured-conversation-compaction.test.ts | 9 +- .../structured-mailbox-pointer-host.test.ts | 5 +- .../structured-mailbox-pointer-host.ts | 7 +- .../structured-session-pointer-delivery.ts | 13 +- ...structured-worker-group-addressing.test.ts | 3 +- ...ructured-agent-session-rpc.test-fixture.ts | 12 +- .../structured-worker-cleared-session.test.ts | 2 + src/shared/agent-session-wire.ts | 22 +- .../structured-agent-session-live-turn.ts | 33 ++- ...ctured-agent-session-main-agent-working.ts | 22 +- .../structured-agent-session-owed-work.ts | 16 +- .../structured-agent-session-projection.ts | 34 ++- 98 files changed, 1990 insertions(+), 442 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3c9d738d8ba9..22cc03d9be32 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -100,6 +100,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts', @@ -153,6 +154,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 1a761fda810d..767092e491c6 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -102,12 +102,15 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), the provenance reads and runningTurn() of a journal. const journal = { snapshot: () => ({ items, submissions: [] }), stopMarks: { latest: () => null, revision: () => 0 }, lastActivityAt: () => 1, - cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) + cursor: () => ({ epoch: 1, sequence: (sequence += 1) }), + itemFence: () => undefined, + wroteBeforeOpen: () => false, + runningTurn: () => null } as unknown as AgentSessionJournal const pending: Promise[] = [] const observe = vi.fn((summary, options) => { @@ -191,7 +194,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), the provenance reads and runningTurn() of a journal. const journal = { lastActivityAt: () => 1, stopMarks: { latest: () => null, revision: () => 0 }, @@ -208,7 +211,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { } ], submissions: [] - }) + }), + itemFence: () => undefined, + wroteBeforeOpen: () => false, + runningTurn: () => null } as unknown as AgentSessionJournal const location = { executionHostId: 'local' as const, diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 0092d9e2f29d..59da29795077 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -33,7 +33,14 @@ async function droppedFromRoster() { { sessionId: 'session-1', fence: 7, - journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + journal: { + snapshot: () => ({ items: [] }), + submissions: () => [], + runningTurn: () => null, + itemFence: () => undefined, + visitItems: () => undefined, + wroteBeforeOpen: () => false + }, adapter: run.adapter }, RECORD, diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 427e24e89609..d65d4a9a4451 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -40,6 +40,11 @@ async function wiredSession() { }, // No Stop was ever pressed here. stopMarks: { latest: () => null, revision: () => 0 }, + // One generation produced everything here, and every send was accepted by this process. + itemFence: () => undefined, + wroteBeforeOpen: () => false, + // The live generation's own turns are never a died turn, so this is never consulted for one. + runningTurn: () => null, snapshot: () => ({ items: [...run.journalItems.values()] .sort((a, b) => a.sequence - b.sequence) @@ -53,7 +58,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the feed's cursor, clock, Stop marks, snapshot, submissions and item reads. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the feed's cursor, clock, Stop marks, snapshot, submissions, item and provenance reads. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index c3db6a4011b6..95bfb84cb2b8 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -24,6 +24,7 @@ import { AgentHookServer } from '../agent-hooks/server' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { settleStructuredAgentSessionDeadGeneration } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' +import { StructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-current-work' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -243,7 +244,10 @@ async function pipeline() { await journal.appendItem( identity, { ...card.body, resolution: { ...resolution, resolvedBy: 'client-1', resolvedAt: now() } }, - { fence: FENCE, turnScope: journal.liveTurnScope() } + { + fence: FENCE, + turnScope: new StructuredAgentSessionCurrentWork(journal, FENCE).turnScope() + } ) } const cardId = (): string => { diff --git a/src/main/codex/codex-structured-journal-exit-convergence.test.ts b/src/main/codex/codex-structured-journal-exit-convergence.test.ts index c9f56af83048..187d130e464d 100644 --- a/src/main/codex/codex-structured-journal-exit-convergence.test.ts +++ b/src/main/codex/codex-structured-journal-exit-convergence.test.ts @@ -5,7 +5,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { isStructuredAgentSessionMainAgentWorking } from '../../shared/structured-agent-session-main-agent-working' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseFixture, @@ -23,7 +22,7 @@ import { type StructuredAgentSessionChildExitSession } from '../native-chat/agent-session-wire/structured-agent-session-child-exit' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { pendingPromptExists } from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' +import { StructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-current-work' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { handleCodexSessionExit } from './codex-structured-session-close' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' @@ -46,15 +45,14 @@ afterEach(async () => { } }) -function userVisible(journal: AgentSessionJournal) { +/** What the chat shows with the live generation at `liveFence`: 7 while the child runs, none once + * its exit released it (`structuredAgentSessionCurrentWork`). */ +function userVisible(journal: AgentSessionJournal, liveFence: number | null) { const items = journal.snapshot().items + const work = new StructuredAgentSessionCurrentWork(journal, liveFence) return { - working: isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - 8 - ), - prompt: pendingPromptExists(journal), + working: work.working(), + prompt: work.hasActionablePrompt(), turnStates: items.flatMap((item) => readAgentJournalTurn(item.body)?.state ?? []), runningCalls: items.filter( (item) => item.body.kind === 'tool-call' && item.body.state === 'running' @@ -149,7 +147,7 @@ it('settles a refused Codex exit through the host in one commit, then permits a ).toEqual({ accepted: true }) await expect(deferred.drained()).resolves.toEqual({ ok: true }) expect(deferred.state().queuedBytes).toBe(0) - expect(userVisible(journal)).toEqual({ + expect(userVisible(journal, 7)).toEqual({ working: true, prompt: true, turnStates: ['running'], @@ -234,13 +232,13 @@ it('settles a refused Codex exit through the host in one commit, then permits a }, sessions: new Map([[SESSION, hostSession]]), flushLifecycle: () => deferred.lifecycleBarrier(), - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId, task) => task(), now: () => 5_000 }, event ) - expect(userVisible(journal)).toEqual({ + expect(userVisible(journal, null)).toEqual({ working: false, prompt: false, turnStates: ['interrupted'], diff --git a/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts index ca622c5b2f71..7fa7e873d4b6 100644 --- a/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts @@ -191,7 +191,7 @@ describe('atomic terminal settlement', () => { return result }) ) - expect(await settle()).toEqual({ ok: true }) + expect(await settle()).toMatchObject({ ok: true }) vi.restoreAllMocks() const settled = journal.snapshot() expect(published).toEqual([settled]) @@ -209,7 +209,7 @@ describe('atomic terminal settlement', () => { expect(frames.at(-1)).toMatchObject({ type: 'batch', batch: { cursor: settled.cursor } }) const frameCount = frames.length expect(settled.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true }) - expect(journal.activeTurnId()).toBeNull() + expect(journal.runningTurn()?.turnId ?? null).toBeNull() const calls = settled.items.filter((item) => item.body.kind === 'tool-call') expect(calls).toHaveLength(201) expect( @@ -230,7 +230,7 @@ describe('atomic terminal settlement', () => { 'lifecycle-batch' ]) expect(committed.every((row) => row.recovered)).toBe(true) - expect(await settle()).toEqual({ ok: true }) + expect(await settle()).toMatchObject({ ok: true }) expect(journal.snapshot()).toEqual(settled) expect(rows().slice(diskBefore.length)).toEqual(committed) expect(published).toEqual([settled]) @@ -238,7 +238,7 @@ describe('atomic terminal settlement', () => { await journal.close() journal = await open() expect(journal.snapshot()).toEqual(settled) - expect(await settle()).toEqual({ ok: true }) + expect(await settle()).toMatchObject({ ok: true }) expect(journal.snapshot()).toEqual(settled) } ) diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 5c5637de2dfa..6b2488c259a6 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -141,7 +141,7 @@ describe('sequences', () => { const rendered = (): string | null => activeStructuredAgentSessionTurnId(journal.snapshot().items) const bothAgreeOn = async (turnId: string | null): Promise => { - expect(journal.activeTurnId()).toBe(turnId) + expect(journal.runningTurn()?.turnId ?? null).toBe(turnId) expect(rendered()).toBe(turnId) } 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 8fe8c970b0a7..67f26ee31c40 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -13,7 +13,6 @@ import type { AgentJournalSubmission, AgentJournalThreadGoal, AgentJournalTurnLifecycle, - AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -21,9 +20,8 @@ import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-s import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import { - activeStructuredAgentSessionTurnIdBySequence, - liveStructuredAgentSessionTurnScope, - newestStructuredAgentSessionTurnBySequence + newestStructuredAgentSessionTurnBySequence, + runningStructuredAgentSessionTurnItemBySequence } from '../../../shared/structured-agent-session-live-turn' import type { JournalReplacementItem } from './journal-epoch-replacement' import { readJournalSince } from './journal-cursor' @@ -223,14 +221,11 @@ export class AgentSessionJournal { } } - /** The turn this journal has published as running — the same read a client's snapshot gives, - * without materialising one. */ - activeTurnId = (): string | null => - activeStructuredAgentSessionTurnIdBySequence(this.state.items.values()) - - /** Where a row written now belongs: the running turn, or the conversation. */ - liveTurnScope = (): AgentJournalTurnScope => - liveStructuredAgentSessionTurnScope(this.state.items.values()) + /** The turn this journal records as running, with its record's item, whichever generation + * opened it — the same read a client's snapshot gives, without materialising one. Whether it is + * current work is the host projection's to say (`structuredAgentSessionCurrentWork`). */ + runningTurn = (): { item: AgentJournalRenderItem; turnId: string } | null => + runningStructuredAgentSessionTurnItemBySequence(this.state.items.values()) /** The newest turn record whatever state it settled in, for readers that need the outcome. */ newestTurn = (): AgentJournalTurnLifecycle | null => @@ -247,12 +242,11 @@ export class AgentSessionJournal { /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ lastActivityAt = (): number => this.state.lastActivityAt - /** Fence of the writer that created the item, while it is in the timeline. */ + /** The item's execution provenance (`JournalItemRow.ownerFence`), while it is in the timeline. */ itemFence = (itemId: string): number | undefined => this.state.itemFences.get(itemId) /** Latest saved output at this fence, excluding client actions and recovery bookkeeping. */ - lastProviderActivityAt = (fence: number): number | undefined => - this.state.providerActivityAt.get(fence) + lastProviderActivityAt = (fence: number) => this.state.providerActivityAt.get(fence) submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts index dba12dc49d3f..3d9f3b0ce85b 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts @@ -117,7 +117,7 @@ it.each([ turnId: 'old', state: 'interrupted' }) - expect(journal.activeTurnId()).toBeNull() + expect(journal.runningTurn()?.turnId ?? null).toBeNull() const ended = journal.snapshot() await settle(observedAt, lastProvenAliveAt) expect(journal.snapshot()).toEqual(ended) @@ -145,7 +145,7 @@ it.each([{ kind: 'tool-call' }, { providerTurnId: '' }])( state: 'interrupted', completedAt: 1_000 }) - expect(journal.activeTurnId()).toBeNull() + expect(journal.runningTurn()?.turnId ?? null).toBeNull() await expectSendStillWritable() } ) @@ -329,7 +329,7 @@ it.each([ } expect(journal.itemBody('orca:extension-7')).toHaveProperty('laterField', { kind: 'tool-call' }) expect(journal.itemBody('orca:extension-6')).toHaveProperty('contextUsage', laterContextUsage) - expect(journal.activeTurnId()).toBeNull() + expect(journal.runningTurn()?.turnId ?? null).toBeNull() expect(journal.snapshot().items).toHaveLength(inputs.length + 1) const ended = journal.snapshot() await settle(observedAt, lastProvenAliveAt) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index b1667daa512c..d2714ff3cbf5 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -36,6 +36,15 @@ import { submissionBytesByItemId } from './agent-session-history-page-bounds' import { offPageSubagentRoster } from './agent-session-history-subagent-roster' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' + +/** What a page publishes of the host's current-work projection (`structuredAgentSessionCurrentWork`): + * the newest turn record as clients are told it, and the prompts still waiting on the person. + * Absent, a page states the raw record and no prompt ids, as an older host did. */ +export type AgentSessionPublishedWork = Pick< + StructuredAgentSessionCurrentWork, + 'publishedLatestTurn' | 'actionablePromptIds' | 'working' +> export { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' @@ -80,19 +89,20 @@ export function readAgentSessionHistory( /** Reduced state to read against. A synchronous multi-page catch-up passes one * snapshot for the whole run so each page costs its own rows, not the timeline. */ snapshot: AgentJournalSnapshot = journal.snapshot(), - scope: AgentSessionHistoryScope = 'every-agent' + scope: AgentSessionHistoryScope = 'every-agent', + work?: AgentSessionPublishedWork ): AgentSessionHistoryResult { const limit = resolveHistoryLimit(request.limit) if (request.direction === 'after') { - return readForward(journal, snapshot, request.cursor, limit) + return readForward(journal, snapshot, request.cursor, limit, work) } const cursor = request.direction === 'before' ? request.cursor : undefined if (cursor) { if (cursor.epoch !== snapshot.cursor.epoch) { - return historyReset(snapshot, 'epoch_changed') + return historyReset(snapshot, 'epoch_changed', work) } if (cursor.sequence > snapshot.cursor.sequence) { - return historyReset(snapshot, 'cursor_ahead') + return historyReset(snapshot, 'cursor_ahead', work) } } const scoped = @@ -117,7 +127,8 @@ export function readAgentSessionHistory( nextCursor: items[0] ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } : undefined, - subagentRoster: offPageSubagentRoster(scoped, items) + subagentRoster: offPageSubagentRoster(scoped, items), + work }) } } @@ -128,7 +139,8 @@ export function readAgentSessionHistory( * check re-reduces if anything did advance the journal between pages. */ export function createAgentSessionCatchUpReader( - journal: AgentSessionJournal + journal: AgentSessionJournal, + work?: AgentSessionPublishedWork ): (request: AgentSessionHistoryRequest) => AgentSessionHistoryResult { let snapshot = journal.snapshot() return (request) => { @@ -136,20 +148,34 @@ export function createAgentSessionCatchUpReader( if (live.epoch !== snapshot.cursor.epoch || live.sequence !== snapshot.cursor.sequence) { snapshot = journal.snapshot() } - return readAgentSessionHistory(journal, request, snapshot) + return readAgentSessionHistory(journal, request, snapshot, 'every-agent', work) } } export function readAgentSessionHydrationPage( journal: AgentSessionJournal, - fence?: number + fence?: number, + work?: AgentSessionPublishedWork ): AgentSessionHistoryPage { - return buildHydrationPage(journal.snapshot(), fence) + return buildHydrationPage(journal.snapshot(), fence, work) +} + +/** The current-work fields a page carries, for a frame that carries no page. */ +export function agentSessionPublishedWorkFields( + journal: AgentSessionJournal, + work: AgentSessionPublishedWork +): Pick { + return { + latestTurn: work.publishedLatestTurn(snapshotLatestTurn(journal.snapshot())), + actionablePromptIds: work.actionablePromptIds(), + working: work.working() + } } function buildHydrationPage( snapshot: AgentJournalSnapshot, - fence?: number + fence?: number, + work?: AgentSessionPublishedWork ): AgentSessionHistoryPage { const items = conversationWindow(snapshot.items, AGENT_SESSION_HISTORY_MAX_LIMIT) const bounded = boundHistoryItemsByBytes( @@ -169,18 +195,20 @@ function buildHydrationPage( ? { epoch: snapshot.cursor.epoch, sequence: bounded.items[0].sequence } : undefined, fence, - subagentRoster: offPageSubagentRoster(snapshot.items, bounded.items) + subagentRoster: offPageSubagentRoster(snapshot.items, bounded.items), + work }) } function historyReset( snapshot: AgentJournalSnapshot, - reset: Extract['reset'] + reset: Extract['reset'], + work: AgentSessionPublishedWork | undefined ): AgentSessionHistoryResult { return { ok: false, reset, - page: buildHydrationPage(snapshot) + page: buildHydrationPage(snapshot, undefined, work) } } @@ -188,18 +216,19 @@ function readForward( journal: AgentSessionJournal, snapshot: AgentJournalSnapshot, cursor: AgentJournalCursor | undefined, - limit: number + limit: number, + work: AgentSessionPublishedWork | undefined ): AgentSessionHistoryResult { if (!cursor) { // Why: forward paging replays rows after a position; without one there is // nothing to be after, and silently serving the tail would hand the client // a page it cannot place. - return historyReset(snapshot, 'cursor_ahead') + return historyReset(snapshot, 'cursor_ahead', work) } // One lookahead preserves hasNewer without rereading the entire remaining journal per page. const since = journal.readSince(cursor, limit + 1) if (!since.ok) { - return historyReset(snapshot, since.reset) + return historyReset(snapshot, since.reset, work) } const submissionBytes = submissionBytesByItemId(snapshot.submissions) // The page cost is EVERYTHING variable it carries: items with their @@ -225,7 +254,7 @@ function readForward( canonicalItemId: (itemId) => journal.canonicalItemId(itemId) }) if (!projected.ok) { - return historyReset(snapshot, projected.reset) + return historyReset(snapshot, projected.reset, work) } let contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds) while (rows.length > 1 && contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { @@ -237,7 +266,7 @@ function readForward( canonicalItemId: (itemId) => journal.canonicalItemId(itemId) }) if (!shrunk.ok) { - return historyReset(snapshot, shrunk.reset) + return historyReset(snapshot, shrunk.reset, work) } projected = shrunk contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds) @@ -257,7 +286,7 @@ function readForward( // break the client's keying. A bounded tail replaces the client's state // wholesale, which applies the removal without carrying the id, and the // client resumes from the live cursor past this row. - return historyReset(snapshot, 'cursor_compacted') + return historyReset(snapshot, 'cursor_compacted', work) } const lastSequence = rows.at(-1)?.seq ?? cursor.sequence return { @@ -271,7 +300,8 @@ function readForward( hasOlder: cursor.sequence > 0, hasNewer: since.rows.length > rows.length, fallbackCursor: cursor, - nextCursor: { epoch: cursor.epoch, sequence: lastSequence } + nextCursor: { epoch: cursor.epoch, sequence: lastSequence }, + work }) } } @@ -287,6 +317,7 @@ function buildPage(input: { nextCursor: AgentJournalCursor | undefined fence?: number subagentRoster?: AgentSessionSubagentRosterEntry[] + work?: AgentSessionPublishedWork }): AgentSessionHistoryPage { const epoch = input.snapshot.cursor.epoch const pageItemIds = new Set(input.items.map((item) => item.itemId)) @@ -311,8 +342,15 @@ function buildPage(input: { hasOlder: input.hasOlder, hasNewer: input.hasNewer, ...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster }), - // From the whole timeline, never the page: a long turn's record sits below any window. - latestTurn: snapshotLatestTurn(input.snapshot) + // From the whole timeline, never the page: a long turn's record sits below any window. Scoped + // by the host's projection where the reader has one: an ended generation's turn runs nowhere. + ...(input.work + ? { + latestTurn: input.work.publishedLatestTurn(snapshotLatestTurn(input.snapshot)), + actionablePromptIds: input.work.actionablePromptIds(), + working: input.work.working() + } + : { latestTurn: snapshotLatestTurn(input.snapshot) }) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts new file mode 100644 index 000000000000..7a3e6864096c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts @@ -0,0 +1,97 @@ +// The stop-note refresh frame carries rows, so it restates the host's current work as every rows +// frame does: a client reads a rows frame without it as an older host's. + +import { 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 { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import type { Subscriber } from './structured-agent-session-subscribers' + +const staleTurn: AgentJournalRenderItem = { + itemId: 'turn-old', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 't-old', state: 'running', startedAt: 1 } +} +const stalePrompt: AgentJournalRenderItem = { + itemId: 'prompt-old', + revision: 1, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Run ls', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } +} +const noteId = agentJournalItemKey(structuredAgentSessionStopNoteIdentity('t-old')) +const unconfirmed = { + kind: 'status' as const, + text: 'x', + failure: { kind: 'cancelUnconfirmed' as const } +} +const projectedNote: AgentJournalRenderItem = { + itemId: noteId, + revision: 2, + sequence: 3, + observedAt: 3, + body: { kind: 'status', text: 'Stopped.' } +} +const items = [staleTurn, stalePrompt, projectedNote] + +describe('the stop-note refresh frame', () => { + it("restates the host's current work", () => { + const emitted: AgentSessionSubscribeEvent[] = [] + const journal = { + visitItems: (visit: (itemId: string, sequence: number, body: unknown) => void) => + visit(noteId, 3, unconfirmed), + snapshot: () => ({ + sessionId: 's', + cursor: { epoch: 'e', sequence: 3 }, + items, + submissions: [] + }), + itemBody: (itemId: string) => (itemId === noteId ? unconfirmed : null) + } + // The generation live now is fence 2; both stale items came from fence 1. + const work = new StructuredAgentSessionCurrentWork( + { + runningTurn: () => ({ item: staleTurn, turnId: 't-old' }), + itemFence: () => 1, + visitItems: (visit) => { + for (const item of items) { + visit(item.itemId, item.sequence, item.body) + } + }, + submissions: () => [], + wroteBeforeOpen: () => false + }, + 2 + ) + const subscriber = { sessionId: 's', cursor: { epoch: 'e', sequence: 3 }, fence: 2 } + refreshDerivedStopNotes( + { emit: (_subscriber, event) => emitted.push(event), isActive: () => true }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh reads only the subscriber's session, cursor and fence. + subscriber as unknown as Subscriber, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh reads only visitItems, snapshot and itemBody, all supplied. + journal as unknown as AgentSessionJournal, + 10, + work + ) + + expect(emitted).toHaveLength(1) + const frame = emitted[0] + if (frame?.type !== 'batch') { + throw new Error('expected a batch') + } + expect(frame.batch.items.length).toBeGreaterThan(0) + expect(frame).toMatchObject({ working: false, actionablePromptIds: [], latestTurn: null }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts index 5f86048af0c1..83ce92fc7cf4 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts @@ -6,16 +6,23 @@ import { oversizedHistoryItem } from './agent-session-history-page-bounds' import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import { + agentSessionPublishedWorkFields, + type AgentSessionPublishedWork +} from './agent-session-history-page' import type { SubscriberDeliveryPort } from './agent-session-subscriber-catch-up' import type { Subscriber } from './structured-agent-session-subscribers' import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' -/** A stale in-flight history page can land after its note's live correction was out of window. */ +/** A stale in-flight history page can land after its note's live correction was out of window. + * `work`: the host's current work, which a frame carrying rows restates, as every such frame does + * (a client reads a rows frame without it as an older host's). */ export function refreshDerivedStopNotes( port: Pick, subscriber: Subscriber, journal: AgentSessionJournal, - hostNow: number + hostNow: number, + work?: AgentSessionPublishedWork ): void { let hasUnconfirmedStop = false journal.visitItems((itemId, _sequence, body) => { @@ -39,6 +46,7 @@ export function refreshDerivedStopNotes( sessionId: subscriber.sessionId, batch: { ...emptyAgentSessionBatch(subscriber.cursor), items: pending }, fence: subscriber.fence, + ...(work ? agentSessionPublishedWorkFields(journal, work) : {}), hostNow }) pending = [] diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index df98d8ba902b..668c433de9e0 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -12,7 +12,11 @@ import { import { sameJournalCursor } from '../agent-session-journal/journal-cursor' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' -import { createAgentSessionCatchUpReader } from './agent-session-history-page' +import { + agentSessionPublishedWorkFields, + createAgentSessionCatchUpReader +} from './agent-session-history-page' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { subscriberQueuedMessagesChanged, type SubscriberFieldHooks @@ -22,6 +26,11 @@ import type { Subscriber } from './structured-agent-session-subscribers' export type SubscriberDeliveryPort = { hooks: SubscriberFieldHooks & { readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + /** The host's projection of current work, which every frame's turn and prompt fields state. */ + readCurrentWork?: ( + sessionId: string, + journal: AgentSessionJournal + ) => StructuredAgentSessionCurrentWork | undefined } emit: ( subscriber: Subscriber, @@ -49,12 +58,13 @@ export function deliverToSubscriber( hostNow, ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) } + const work = port.hooks.readCurrentWork?.(subscriber.sessionId, journal) // Caught up, so there are no rows to read: every publish behind a commit's own delivery. if (sameJournalCursor(subscriber.cursor, journal.cursor())) { - emitCaughtUp(port, subscriber, emitCheckpoint, shared) + emitCaughtUp(port, subscriber, emitCheckpoint, shared, { journal, work }) return } - const readPage = createAgentSessionCatchUpReader(journal) + const readPage = createAgentSessionCatchUpReader(journal, work) while (true) { const result = readPage({ sessionId: subscriber.sessionId, @@ -73,12 +83,13 @@ export function deliverToSubscriber( ...shared }) subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.workView = work?.viewKey() return } const page = result.page const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence if (!advanced) { - emitCaughtUp(port, subscriber, emitCheckpoint, shared) + emitCaughtUp(port, subscriber, emitCheckpoint, shared, { journal, work }) return } port.emit( @@ -94,6 +105,10 @@ export function deliverToSubscriber( }, fence: subscriber.fence, ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), + ...(page.actionablePromptIds !== undefined + ? { actionablePromptIds: page.actionablePromptIds } + : {}), + ...(page.working !== undefined ? { working: page.working } : {}), ...shared }, // On a multi-page catch-up the draft list rides only the final page, or a @@ -101,6 +116,7 @@ export function deliverToSubscriber( { withholdQueued: page.hasNewer } ) subscriber.cursor = page.window.nextCursor + subscriber.workView = work?.viewKey() if (!page.hasNewer || !port.isActive(subscriber)) { return } @@ -114,19 +130,34 @@ function emitCaughtUp( shared: { hostNow: number activity?: AgentSessionTurnActivity | null - } + }, + current: { journal: AgentSessionJournal; work: StructuredAgentSessionCurrentWork | undefined } ): void { const commandsChanged = port.hooks.readCommands !== undefined && (port.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber) - if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) { + // A generation that ended (or began) with no row, or an exit this host saw with no lease write, + // changes what is current: the caught-up frame restates the view. + const { work } = current + const workChanged = work !== undefined && work.viewKey() !== subscriber.workView + if ( + emitCheckpoint || + shared.activity !== undefined || + commandsChanged || + queuedChanged || + workChanged + ) { port.emit(subscriber, { type: 'batch', sessionId: subscriber.sessionId, batch: emptyAgentSessionBatch(subscriber.cursor), fence: subscriber.fence, + ...(workChanged ? agentSessionPublishedWorkFields(current.journal, work) : {}), ...shared }) + if (workChanged) { + subscriber.workView = work.viewKey() + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 12c2f54a1793..96681b63f3b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -34,6 +34,7 @@ export type StructuredAgentSessionAttachContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void + generationEnded?: StructuredAgentSessionLifetimeContext['generationEnded'] /** Joining a stop's close ends the child's record through the one exit handler. */ endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery'] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 06e325fefd37..a8368d067893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -84,4 +84,6 @@ export function endStructuredAgentSessionReleasedChild( context.runtimeState.currentEventSink(sessionId)?.close() context.runtimeState.discardEventSink(sessionId) context.publishStatus?.(sessionId) + // The released acquisition was the live generation: readers and the queued-card drain re-derive. + context.generationEnded?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c3271ff3d477..a437a1db496a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -36,6 +36,7 @@ import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { acquireOwner } from './structured-agent-session-acquisition' import { importAdoptedTranscript, @@ -284,7 +285,12 @@ export async function performAttach( value: { sessionId, fence, - page: readAgentSessionHydrationPage(attached.journal, fence), + // The lease this attach just took is the live generation. + page: readAgentSessionHydrationPage( + attached.journal, + fence, + heldStructuredAgentSessionCurrentWork(store, sessionId, attached.journal, undefined) + ), unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds, ...(tabId ? { tabId } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index cf44fc3d66b5..0f87c4164497 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -7,10 +7,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' -import { - structuredQueueSendGate, - tryReadQueuePublication -} from './structured-agent-session-queued-publication' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import type { AgentSessionHistoryScope } from './agent-session-history-page' import type { AgentSessionSubscribers, @@ -45,17 +42,19 @@ export class StructuredAgentSessionBackgroundTaskChannel { scope?: AgentSessionHistoryScope ): Promise { const journal = (await this.conversation(request.sessionId)).journal + const work = hostStructuredAgentSessionCurrentWork( + { store: this.deps.store, sessions: this.sessions }, + request.sessionId + ) const result = readStructuredAgentSessionHistoryResult({ journal, record: this.deps.store.getRecord(request.sessionId), request, - scope + scope, + ...(work ? { work } : {}) }) const backgroundTasks = this.read(request.sessionId) - const queue = tryReadQueuePublication( - journal, - structuredQueueSendGate(this.deps.store, request.sessionId) - ) + const queue = this.subscribers.readQueuePublication(request.sessionId) const hostNow = this.deps.now?.() ?? Date.now() return { ...result, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b6cd948fa4dc..1e508636ebf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -156,6 +156,8 @@ export function mutateWithChatStop( windDown = { owed, ctx } }, withdrewQueued: withdrew, + noteFence: () => + context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? ctx.fence, ...(ownsLatestStop ? { opensSettle: true as const } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 694268a1f8bf..e9fcdf063094 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -15,7 +15,7 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' -import { endProviderChild } from './structured-agent-session-provider-child' +import { recordProviderChildEnd } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwnerAfterExit, type StructuredAgentSessionLeaseStore @@ -49,14 +49,23 @@ export type StructuredAgentSessionChildExitSession = Pick< 'child' | 'lastEndedChild' > & { journal: DeadGenerationJournal & Pick } +/** The one edge every way a generation ends goes through + * (`StructuredAgentSessionClientDelivery.publishGenerationEnded`). */ +export type StructuredAgentSessionGenerationEnded = ( + sessionId: string, + options?: { restate?: boolean } +) => void + export type StructuredAgentSessionChildExitContext< TSession extends StructuredAgentSessionChildExitSession = StructuredAgentSessionHostSession > = { store: StructuredAgentSessionLeaseStore sessions: Map flushLifecycle: (sessionId: string) => Promise - publishFence: (sessionId: string, session: TSession) => void publishStatus?: (sessionId: string) => void + /** A generation ended: every reader re-derives current work and the queued-card drain is + * scheduled, with no journal write needed. `restate`: each chat re-baselines at the moved fence. */ + generationEnded: StructuredAgentSessionGenerationEnded /** The delivery loop hands over whatever is queued once the child is off the record. */ wakeDelivery?: (sessionId: string) => void /** An ended child's start, if still open, has nothing left to time. */ @@ -145,20 +154,27 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< ? ('hostRestarted' as const) : ('chatClosed' as const) : undefined + // Death first: before the tail, the settlement or the release writes anything, every reader + // projects this generation as ended, so no frame they publish reads it Working and no prompt it + // raised is answerable. The child stays on record (its sink drains the tail) until `endChild`. + recordProviderChildEnd(session, { + generation: child.generation, + fence: child.fence, + // A close keeps the cause of the stop that asked for it, and ends where it was asked. + cause: expected ? (close?.cause ?? 'evict') : 'exit', + reason: expected ? (close?.reason ?? null) : exit.reason, + ...(!expected && exit.failure ? { failure: exit.failure } : {}), + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true, + ...(expected && close ? { endedAt: close.requestedAt } : {}) + }) + context.generationEnded(sessionId) const endChild = (): void => { context.startupAttempts?.childEnded(sessionId, child) - endProviderChild(session, { - generation: child.generation, - fence: child.fence, - // A close keeps the cause of the stop that asked for it, and ends where it was asked. - cause: expected ? (close?.cause ?? 'evict') : 'exit', - reason: expected ? (close?.reason ?? null) : exit.reason, - ...(!expected && exit.failure ? { failure: exit.failure } : {}), - duringStartup: exitedDuringStartup, - // The adapter publishes an exit only once it saw the root go, first-hand or proven. - rootGone: true, - ...(expected && close ? { endedAt: close.requestedAt } : {}) - }) + if (session.child === child) { + session.child = null + } context.publishStatus?.(sessionId) } const record = context.store.getRecord(sessionId) @@ -256,10 +272,10 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } endChild() // A reader re-baselines on a death of the child's own; a close Orca asked for moves no fence a - // reader holds, as a client resends a message when its fence moves. - if (released && !expected) { - context.publishFence(sessionId, session) - } + // reader holds, as a client resends a message when its fence moves, and discards a reply (the + // Stop's own) issued at the fence before. Either way every reader learns the generation ended + // and the queued-card drain runs, whether or not the release or the settlement was written. + context.generationEnded(sessionId, { restate: released && !expected }) context.wakeDelivery?.(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 2ea3a16024be..35763ea56a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -145,7 +145,7 @@ describe('Claude root-exit stop', () => { store, sessions, flushLifecycle: (id) => runtimeState.lifecycleBarrier(id), - publishFence: () => undefined, + generationEnded: () => undefined, publishStatus, serialize: (_sessionId, task) => task(), now: () => NOW + 30 * 60_000, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index c369ce1a5602..634b501be00e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -15,6 +15,8 @@ import type { } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' +import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed' import { @@ -35,7 +37,11 @@ export class StructuredAgentSessionClientDelivery { private readonly sessions: Map, now: () => number, private readonly deps: () => StructuredAgentSessionHostDeps, - private readonly onJournalActivity: (sessionId: string) => void, + /** The queued-card drain: every publish wakes it (`activity`: a journal write, which renews the + * idle clock; a generation's end is not one). */ + private readonly queue: { + onJournalActivity: (sessionId: string, activity?: boolean) => void + }, onAgentStarted: (sessionId: string) => void, /** A session's child records changed; the chat strip republishes from them. */ onChildWorkChanged: (sessionId: string) => void, @@ -56,7 +62,7 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.journalProjection(sessionId, journal)?.state ?? null }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => - this.requireJournal(sessionId) + this.settlementReading(sessionId, this.requireJournal(sessionId)) ) this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ @@ -64,10 +70,11 @@ export class StructuredAgentSessionClientDelivery { readQueuePublication: (sessionId) => tryReadQueuePublication( sessions.get(sessionId)?.journal, - structuredQueueSendGate(this.deps().store, sessionId) + structuredQueueSendGate({ store: this.deps().store, sessions }, sessionId) ), readBackgroundTasks, - onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) + onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal), + readCurrentWork: (sessionId, journal) => this.currentWork(sessionId, journal) }) } @@ -110,10 +117,35 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.publish(sessionId) const journal = this.sessions.get(sessionId)?.journal if (journal) { - this.sendSettlement.publish(sessionId, journal) + this.sendSettlement.publish(sessionId, this.settlementReading(sessionId, journal)) } } + /** A generation ended — an exit, a release — which can change what is current with no journal + * row. The one edge for every way a generation ends: every reader re-derives current work (the + * status feed, settlement waits, each chat's frames) and the queued-card drain is scheduled, + * whatever became of the cleanup's own write. An end is no activity: it renews no idle clock. + * `restate`: each chat re-baselines at the fence, as a death of the child's own does. */ + publishGenerationEnded = (sessionId: string, options: { restate?: boolean } = {}): void => { + const journal = this.sessions.get(sessionId)?.journal + if (!journal) { + return + } + if (options.restate) { + const fence = structuredAgentSessionConversationFence(this.deps().store, sessionId) + this.subscribers.snapshot(sessionId, journal, fence) + return + } + this.subscribers.deliverFrames(sessionId, journal) + this.publishJournal(sessionId, journal, false) + } + + /** A held chat's current work (`structuredAgentSessionCurrentWork`); null when none is held. */ + readCurrentWork = (sessionId: string) => { + const journal = this.sessions.get(sessionId)?.journal + return journal ? this.currentWork(sessionId, journal) : null + } + publishRestored = (sessionId: string): void => { this.statusFeed.publish(sessionId, undefined, { replay: true }) this.turnCompletionFeed.observe(sessionId, undefined, { historical: true }) @@ -148,14 +180,28 @@ export class StructuredAgentSessionClientDelivery { this.sendSettlement.closeAll() } - private publishJournal(sessionId: string, journal: AgentSessionJournal): void { + private publishJournal(sessionId: string, journal: AgentSessionJournal, activity = true): void { this.statusFeed.publish(sessionId, journal) - this.sendSettlement.publish(sessionId, journal) + this.sendSettlement.publish(sessionId, this.settlementReading(sessionId, journal)) // Derived here rather than per-subscriber: this edge runs whether or not anyone is // subscribed, which is the whole reason a backgrounded chat can complete at all. After the // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) - this.onJournalActivity(sessionId) + this.queue.onJournalActivity(sessionId, activity) + } + + /** The current-work projection over this journal (`structuredAgentSessionCurrentWork`). */ + private currentWork(sessionId: string, journal: AgentSessionJournal) { + const session = this.sessions.get(sessionId) + return heldStructuredAgentSessionCurrentWork(this.deps().store, sessionId, journal, session) + } + + private settlementReading(sessionId: string, journal: AgentSessionJournal) { + return { + submissions: () => journal.submissions(), + cursor: () => journal.cursor(), + activeTurnId: () => this.currentWork(sessionId, journal).activeTurnId() + } } private requireJournal(sessionId: string): AgentSessionJournal { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 30810b9b9f3e..302bc99646af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -44,6 +44,10 @@ import type { StructuredAgentRegistry } from './structured-agent-registry' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity' import { refuseQueuedCommand } from './structured-agent-session-queued-command-refusal' +import { + contextStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWork +} from './structured-agent-session-current-work' export { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity' @@ -90,11 +94,12 @@ export function structuredAgentSessionAwaitedCommand( : undefined } -/** Whether the journal's running turn is a command's, which takes no input while it runs. */ +/** Whether the live generation's running turn is a command's, which takes no input while it runs + * (`structuredAgentSessionCurrentWork`). */ export function structuredAgentSessionCommandRunning( - journal: Pick + work: Pick ): boolean { - const turnId = journal.activeTurnId() + const turnId = work.activeTurnId() return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) } @@ -220,7 +225,8 @@ async function openCommandTurn( clientMessageId, state: 'pending', fence: ctx.fence, - turnScope: ctx.journal.liveTurnScope() + // The child it goes to is live at `fence`: an ended generation's turn is not joined. + turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() }) const startedAt = ctx.now() const running = agentJournalTurnBody({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 431ca9e761de..bba4aeec2ca2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -84,6 +84,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { isDisposed: () => disposed, deliveryActive: host.deliveryActive, childWork: host.readChildWork, + store: { + getRecord: (sessionId) => deps().store.getRecord(sessionId), + replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId) + }, hasOpenDispatch: (sessionId) => { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts new file mode 100644 index 000000000000..bdd580d7dc96 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts @@ -0,0 +1,274 @@ +// The host's one projection of current work (`structuredAgentSessionCurrentWork`), end to end +// against the real host, store and SQLite journal: what an ended generation left holds nothing and +// reads as nothing to every host reader (the projection, the session list, the frames and pages a +// chat client is sent, Stop, the queue), whether or not its cleanup landed, while a live owner's +// work holds exactly as before. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + APPROVAL, + currentJournal, + currentWork, + exitWhileSettlementFails, + leaveUnfinishedWork, + REJECT_RECOVERED_ROWS, + sendText, + turnState +} from './structured-agent-session-leftover-settlement.test-fixture' +import { structuredAgentSessionCurrentWork } from './structured-agent-session-current-work' + +let rig: QueuedMessageTestRig | undefined + +afterEach(async () => { + await rig?.dispose() + rig = undefined + vi.restoreAllMocks() +}) + +const APPROVAL_ID = agentJournalItemKey(APPROVAL) +const FOLDER: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' +} + +type HostWork = Pick< + Extract, + 'latestTurn' | 'actionablePromptIds' | 'working' +> + +/** A chat subscriber and the session list, each fed by the host as a real one is: the subscriber + * keeps the current-work fields of the newest frame or page that stated them. */ +function watch(current: QueuedMessageTestRig) { + let work: HostWork = {} + let listed: AgentSessionStatusSummary | undefined + current.host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + const fields = + event.type === 'snapshot' ? event.page : event.type === 'batch' ? event : undefined + if (fields?.working !== undefined) { + const { latestTurn, actionablePromptIds, working } = structuredClone(fields) + work = { latestTurn, actionablePromptIds, working } + } + } + }) + current.host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + listed = structuredClone(event.session) + } else if (event.type === 'snapshot') { + listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed + } + } + }) + return { work: () => work, listed: () => listed } +} + +/** The tail page a client hydrates from. */ +async function hydration(current: QueuedMessageTestRig) { + const page = await current.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page +} + +async function deadGenerationWithFailedSettlement(location?: typeof FOLDER) { + rig = await createQueuedMessageTestRig({ + restartable: true, + ...(location ? { location } : {}) + }) + const current = rig + await current.workingSend() + await leaveUnfinishedWork(current, { prompt: true }) + const view = watch(current) + const exited = await exitWhileSettlementFails(current) + return { current, view, ...exited } +} + +describe("a dead generation's leftovers, its settlement refused and its lease released", () => { + it.each([ + ['a git worktree', undefined], + ['a folder workspace', FOLDER] + ])('in %s, read as nothing by every reader, and hold nothing', async (_name, location) => { + const { current, view } = await deadGenerationWithFailedSettlement(location) + // Still saved as running and pending: only a settlement would end them. + expect(turnState(current)).toBe('running') + const work = currentWork(current) + + expect(work.liveFence).toBeNull() + expect(work.working()).toBe(false) + expect(work.actionablePromptIds()).toEqual([]) + expect(view.listed()?.status).not.toBe('working') + expect(current.host.readStatusSummary(SESSION)?.status).not.toBe('working') + // A client hydrating now, and the one already watching, read the same. + const page = await hydration(current) + // Read interrupted, as its settlement will write it: never running, never finished. + expect(page.latestTurn?.turn.state).toBe('interrupted') + expect(page.actionablePromptIds).toEqual([]) + expect(page.working).toBe(false) + await vi.waitFor(() => + expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) + ) + expect(view.work().latestTurn?.turn.state).not.toBe('running') + }) + + it('gives Stop nothing to stop: the dead turn is not touched', async () => { + const { current } = await deadGenerationWithFailedSettlement() + + await current.stop() + + expect(current.cancelTurn).not.toHaveBeenCalled() + expect(turnState(current)).toBe('running') + }) +}) + +describe('a close Orca asked for, its settlement refused', () => { + it('tells a watching client the turn and prompt are over, with no row to carry it', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + const current = rig + await current.workingSend() + await leaveUnfinishedWork(current, { prompt: true }) + const view = watch(current) + await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) + const database = openTestJournalHostDatabase(current.root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + database.db.exec(REJECT_RECOVERED_ROWS) + const before = currentJournal(current).cursor().sequence + const child = current.host.collaboratorsForTests().sessions.get(SESSION)?.child + if (!child?.generation) { + throw new Error('expected the live child') + } + + await current.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + cause: 'requested-close', + reason: 'closed by Orca', + fence: child.fence, + acquisitionGeneration: child.generation + }) + await current.host.collaboratorsForTests().serialize(SESSION, async () => {}) + + expect(currentJournal(current).cursor().sequence).toBe(before) + expect(turnState(current)).toBe('running') + await vi.waitFor(() => + expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) + ) + expect(view.work().latestTurn?.turn.state).not.toBe('running') + expect(view.listed()?.status).not.toBe('working') + }) +}) + +describe('a waiting card when the generation ahead of it ends', () => { + it.each([ + ['an exit of its own, its settlement refused', 'unexpected-exit' as const], + ['a close Orca asked for', 'requested-close' as const] + ])('drains after %s, with no journal write in between', async (_name, cause) => { + rig = await createQueuedMessageTestRig({ restartable: true }) + const current = rig + await current.workingSend() + await leaveUnfinishedWork(current) + const queued = await sendText(current, 'behind the turn').result + expect(queued).toMatchObject({ ok: true, value: { queued: expect.anything() } }) + const journal = currentJournal(current) + const [card] = journal.queuedMessages.list() + expect(card?.state).toBe('waiting') + const database = openTestJournalHostDatabase(current.root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + database.db.exec(REJECT_RECOVERED_ROWS) + const before = journal.cursor().sequence + const child = current.host.collaboratorsForTests().sessions.get(SESSION)?.child + if (!child?.generation) { + throw new Error('expected the live child') + } + + await current.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + cause, + reason: 'ended', + fence: child.fence, + acquisitionGeneration: child.generation + }) + + await vi.waitFor(() => + expect(journal.queuedMessages.get(card?.messageId ?? '')?.state).toBe('dispatched') + ) + // The first row after the end is the drain's own hand-off: nothing was written to wake it. + const first = liveTestJournalRows(database.db, SESSION).find((row) => row.seq > before) + expect(JSON.parse(first?.rowJson ?? '{}')).toMatchObject({ + kind: 'submission', + queuedMessageId: card?.messageId + }) + }) +}) + +describe('a live owner', () => { + it('publishes its running turn and its prompt exactly as before', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + const current = rig + await current.workingSend() + await leaveUnfinishedWork(current, { prompt: true }) + const view = watch(current) + + const work = currentWork(current) + expect(work.liveFence).toBe(current.store.getRecord(SESSION)?.lease.runtimeFence) + expect(work.activeTurnId()).toBe('unfinished') + expect(work.actionablePromptIds()).toEqual([APPROVAL_ID]) + const page = await hydration(current) + expect(page.latestTurn?.turn).toMatchObject({ turnId: 'unfinished', state: 'running' }) + expect(page.actionablePromptIds).toEqual([APPROVAL_ID]) + expect(page.working).toBe(true) + await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) + expect(view.work()).toMatchObject({ + working: true, + latestTurn: { turn: { turnId: 'unfinished', state: 'running' } } + }) + expect(view.listed()?.status).not.toBe('idle') + }) +}) + +describe('an owner whose adjudication concludes with no row written', () => { + it('changes the published answer when it goes from unverifiable to released', () => { + const journal = { + runningTurn: () => null, + itemFence: () => undefined, + visitItems: () => undefined, + submissions: () => [], + wroteBeforeOpen: () => false + } + const record = agentSessionRecordFixture() + const unverifiable = structuredAgentSessionCurrentWork(journal, { + record: { lease: { ...record.lease, unreconciled: true } }, + child: null + }) + const released = structuredAgentSessionCurrentWork(journal, { + record: { lease: { ...record.lease, claimStatus: 'released' } }, + child: null + }) + + expect(unverifiable.liveFence).toBe(released.liveFence) + expect(unverifiable.viewKey()).not.toBe(released.viewKey()) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts new file mode 100644 index 000000000000..2cee82ca63f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -0,0 +1,268 @@ +// The host's one operational projection of a chat's current work: the live generation's running +// turn, the prompts it raised that still wait on the person, the sends it owes, and the queued +// sends this process may hand over. Every host reader asks here; none keeps a rule of its own. +// +// Derived on every read, never stored, from what the host already holds: +// - the lease: the live or reserved generation's fence; none once it is released; +// - this host's own sight of that generation's root exit, which ends it even when the release +// write failed (`lastEndedChild`); +// - the Orca runtimes this one replaced (`AgentSessionRecordStore.replacedRuntime`): a generation +// one of them held lost its pipes with it, so it is over before any startup write says so; +// - the journal's fold, where every item carries the generation that produced it (`ownerFence`). +// So work an ended generation left holds nothing and is not Working whether or not its cleanup +// landed: cleanup is bookkeeping and never gates what the person does next. Loss of contact is +// never an end: an unreconciled or conflicted lease keeps its fence, and its work stays current, unless +// a runtime this one replaced held it, which is this host's own proof. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalSubmission, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionLatestTurn } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' +import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredAgentSessionCurrentWorkJournal = Pick< + AgentSessionJournal, + 'runningTurn' | 'itemFence' | 'visitItems' | 'submissions' | 'wroteBeforeOpen' +> + +/** What the host knows of who runs the chat now. */ +export type StructuredAgentSessionWorkEvidence = { + record: Pick | null + /** The last child this host saw end (`StructuredAgentSessionHostSession.lastEndedChild`). */ + ended?: StructuredAgentSessionSeenEnd + /** `StructuredAgentSessionHostSession.operationalRevision`. */ + revision?: number + /** The runtimes this one replaced for the chat (`AgentSessionRecordStore.replacedRuntime`). */ + replaced?: StructuredAgentSessionReplacedEnd | undefined + /** This host's live child (`StructuredAgentSessionHostSession.child`); absent: not known. */ + child?: { fence: number } | null +} + +/** Every fence at or below `fence` was granted by a runtime this one replaced. */ +type StructuredAgentSessionReplacedEnd = { fence: number } + +type StructuredAgentSessionSeenEnd = { fence: number; rootGone?: boolean; cause?: string } + +/** The live or reserved generation's fence, or null when no generation is live. With no record + * nothing has ended: fence 0, under which every item is current, as before records scoped work. */ +export function structuredAgentSessionLiveFence( + evidence: StructuredAgentSessionWorkEvidence +): number | null { + const lease = evidence.record?.lease + if (!lease) { + return 0 + } + if (lease.claimStatus === 'released') { + return null + } + if ( + structuredAgentSessionReplacedRuntimeEnded(lease, evidence.replaced) || + structuredAgentSessionOwnerUnverifiable(evidence) + ) { + return null + } + const { ended } = evidence + return ended?.rootGone === true && ended.fence === lease.runtimeFence ? null : lease.runtimeFence +} + +/** An owner not yet reconciled, or latched in recovery, that this process holds no child for: it + * may still be running, so its turn is unverifiable, and nothing it left is current work. */ +function structuredAgentSessionOwnerUnverifiable(evidence: StructuredAgentSessionWorkEvidence) { + const lease = evidence.record?.lease + return ( + (lease?.unreconciled === true || lease?.handoffStage === 'recovering') && + evidence.child !== undefined && + evidence.child?.fence !== lease.runtimeFence + ) +} + +/** Whether a runtime this one replaced granted the lease's generation, which is then over. A + * conflicted claim's agent is a terminal's, which no runtime's pipes held. */ +export function structuredAgentSessionReplacedRuntimeEnded( + lease: Pick, + replaced: StructuredAgentSessionReplacedEnd | undefined +): boolean { + return ( + replaced !== undefined && + lease.runtimeFence <= replaced.fence && + lease.claimStatus !== 'conflicted' + ) +} + +/** `StructuredAgentSessionCurrentWork.handsOver`, for a reader that needs no other answer. */ +export function structuredAgentSessionHandsOver( + journal: Pick, + submission: Pick< + AgentJournalSubmission, + 'handoverRecorded' | 'dispatchState' | 'handedOverAt' | 'acceptedSequence' + > +): boolean { + return ( + isQueuedAgentJournalSubmission(submission) && + !journal.wroteBeforeOpen(submission.acceptedSequence) + ) +} + +/** One read of a chat's current work; build a fresh one per decision, since nothing is cached. */ +export class StructuredAgentSessionCurrentWork { + /** The shared projections' form of this answer (`StructuredAgentSessionWorkScope`). */ + readonly scope: StructuredAgentSessionWorkScope = { + isCurrentItem: (itemId) => this.isCurrentItem(itemId), + owesSend: (submission) => this.owesSend(submission) + } + + constructor( + private readonly journal: StructuredAgentSessionCurrentWorkJournal, + /** `structuredAgentSessionLiveFence`. */ + readonly liveFence: number | null, + /** Moves with every generation end this host saw, lease write or not: a reader that caches + * what it derived from this answer keys it here and on the journal's cursor. */ + readonly revision = 0, + private readonly ended?: StructuredAgentSessionSeenEnd, + private readonly replaced?: StructuredAgentSessionReplacedEnd, + /** `structuredAgentSessionOwnerUnverifiable`. */ + private readonly ownerUnverifiable = false + ) {} + + /** What a reader that published this answer compares to learn it changed with no row: the live + * generation, since every answer here is the journal read through it. Not the revision, which + * moves on every generation-end signal: an end that changed nothing is published once. */ + viewKey(): string { + return `${this.liveFence}${this.ownerUnverifiable ? ':unverifiable' : ''}` + } + + /** Whether the live generation's execution produced this item. */ + isCurrentItem(itemId: string): boolean { + if (this.liveFence === null) { + return false + } + const provenance = this.journal.itemFence(itemId) + return provenance === undefined || provenance >= this.liveFence + } + + /** Whether this process may hand this send over: accepted here, never by an earlier host process. + * Derived from where the row sits, so nothing has to be written first; keeping an earlier + * process's send as a card is the reconciliation pass's bookkeeping (`holdUnsentSends`). */ + handsOver(submission: AgentJournalSubmission): boolean { + return structuredAgentSessionHandsOver(this.journal, submission) + } + + /** A send still owed: one this process may hand over, or one handed to the live generation that + * it has neither answered nor refused (`isUnansweredStructuredAgentSessionDispatch`). */ + owesSend(submission: AgentJournalSubmission): boolean { + if (isQueuedAgentJournalSubmission(submission)) { + return this.handsOver(submission) + } + return ( + this.liveFence !== null && + isUnansweredStructuredAgentSessionDispatch(submission, this.liveFence) + ) + } + + /** The live generation's running turn, with its record's item. */ + activeTurn(): { item: AgentJournalRenderItem; turnId: string } | null { + const running = this.journal.runningTurn() + return running && this.isCurrentItem(running.item.itemId) ? running : null + } + + activeTurnId(): string | null { + return this.activeTurn()?.turnId ?? null + } + + /** Where a row written now joins: the live generation's running turn, else the conversation. */ + turnScope(): AgentJournalTurnScope { + const running = this.activeTurn() + return running ? { kind: 'turn', turnItemId: running.item.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } + + /** The approvals and questions the live generation raised that wait on the person, a + * subagent's included. */ + actionablePromptIds(): string[] { + const ids: string[] = [] + this.journal.visitItems((itemId, _sequence, body) => { + if ( + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' && + this.isCurrentItem(itemId) + ) { + ids.push(itemId) + } + }) + return ids + } + + hasActionablePrompt(): boolean { + return this.actionablePromptIds().length > 0 + } + + owesAnySend(): boolean { + return this.journal.submissions().some((submission) => this.owesSend(submission)) + } + + /** The session's own agent is working: its running turn, or a send it owes. */ + working(): boolean { + return this.activeTurnId() !== null || this.owesAnySend() + } + + /** The running turn of a generation this host saw die on its own (its root gone, nobody asked), + * or that a runtime this one replaced held: its settlement writes it interrupted, so it reads so + * before that lands. */ + diedTurn(): { item: AgentJournalRenderItem; turnId: string } | null { + const running = this.journal.runningTurn() + if (!running || this.isCurrentItem(running.item.itemId)) { + return null + } + const fence = this.journal.itemFence(running.item.itemId) + const { ended, replaced } = this + const died = ended?.rootGone === true && ended.cause === 'exit' && fence === ended.fence + return died || (replaced !== undefined && fence !== undefined && fence <= replaced.fence) + ? running + : null + } + + /** The newest turn record as a client is told it: a running one an ended generation opened is + * none, interrupted when it died on its own (`diedTurn`), or unverifiable while its owner is, so + * no client reads it as working, steers into it or reads it finished. */ + publishedLatestTurn(latest: AgentSessionLatestTurn | null): AgentSessionLatestTurn | null { + if (latest?.turn.state !== 'running' || this.isCurrentItem(latest.itemId)) { + return latest + } + if (this.diedTurn()?.item.itemId === latest.itemId) { + return { ...latest, turn: { ...latest.turn, state: 'interrupted' } } + } + return this.ownerUnverifiable + ? { ...latest, turn: { ...latest.turn, state: 'unverifiable' } } + : null + } +} + +export function structuredAgentSessionCurrentWork( + journal: StructuredAgentSessionCurrentWorkJournal, + evidence: StructuredAgentSessionWorkEvidence +): StructuredAgentSessionCurrentWork { + return new StructuredAgentSessionCurrentWork( + journal, + structuredAgentSessionLiveFence(evidence), + evidence.revision, + evidence.ended, + evidence.replaced, + structuredAgentSessionOwnerUnverifiable(evidence) + ) +} + +/** An operation context's current work: the host projection it was built with, or, for one built + * without (a test's), the generation at its fence read as live. */ +export function contextStructuredAgentSessionCurrentWork(ctx: { + journal: StructuredAgentSessionCurrentWorkJournal + fence: number + currentWork?: () => StructuredAgentSessionCurrentWork | null +}): StructuredAgentSessionCurrentWork { + return ctx.currentWork?.() ?? new StructuredAgentSessionCurrentWork(ctx.journal, ctx.fence) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 1858eaeddf8d..68abf140f20c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -38,6 +38,7 @@ import { structuredAgentSessionNextHandover } from './structured-agent-session-o import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -64,6 +65,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null + /** The host's projection of the chat's current work (`hostStructuredAgentSessionCurrentWork`). */ + currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined /** A person's Stop is still ending the session's work: the status feed's own reading. */ stopping: (sessionId: string) => boolean @@ -189,7 +192,8 @@ export class StructuredAgentSessionDeliveryLoop { const oldest = oldestQueuedSubmission(session) // A running command takes no input while its child carries it; its end is a commit, which // wakes the loop again. With no child it is a gone generation's, which the start below settles. - if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { + const work = this.deps.currentWork(sessionId) + if (!oldest || (session.child && work && structuredAgentSessionCommandRunning(work))) { return this.stop(sessionId) } const failedStart = startThatFailedWhileQueued(session, oldest) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 51894a9ff8d7..a2b30d57c301 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -18,7 +18,8 @@ import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, type StructuredAgentSessionChildExit, - type StructuredAgentSessionChildExitContext + type StructuredAgentSessionChildExitContext, + type StructuredAgentSessionGenerationEnded } from './structured-agent-session-child-exit' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' @@ -31,8 +32,8 @@ export class StructuredAgentSessionEventRecovery { store: StructuredAgentSessionHostDeps['store'] sessions: Map flushLifecycle: (sessionId: string) => Promise - publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void publishStatus?: (sessionId: string) => void + generationEnded: StructuredAgentSessionGenerationEnded serialize: (sessionId: string, task: () => Promise) => Promise now: () => number runtimeState: StructuredAgentSessionHostRuntimeState diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index 49fb06e83bad..882e7f940629 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -8,7 +8,8 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory, - type AgentSessionHistoryScope + type AgentSessionHistoryScope, + type AgentSessionPublishedWork } from './agent-session-history-page' export function structuredAgentSessionProviderSessionMetadata( @@ -23,8 +24,16 @@ export function readStructuredAgentSessionHistoryResult(input: { record: AgentSessionRecord | null request: AgentSessionHistoryRequest scope?: AgentSessionHistoryScope + /** The host's current-work projection, which the page's turn and prompt fields state. */ + work?: AgentSessionPublishedWork }): AgentSessionHistoryResult { - const result = readAgentSessionHistory(input.journal, input.request, undefined, input.scope) + const result = readAgentSessionHistory( + input.journal, + input.request, + undefined, + input.scope, + input.work + ) const fence = input.record?.lease.runtimeFence const providerSession = structuredAgentSessionProviderSessionMetadata(input.record) if (fence === undefined) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts new file mode 100644 index 000000000000..5c5ddaccea16 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-current-work.ts @@ -0,0 +1,57 @@ +// The host's current-work projection (`structured-agent-session-current-work.ts`) for a chat it +// holds, read from its sessions and lease store. Apart from the projection itself, which stays free +// of host types so shared and client code can import it. + +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + structuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWorkJournal +} from './structured-agent-session-current-work' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** The host's sessions and lease store, as the projection reads them. */ +export type StructuredAgentSessionCurrentWorkHost = { + store: Pick + sessions: { + get( + sessionId: string + ): + | Pick< + StructuredAgentSessionHostSession, + 'journal' | 'lastEndedChild' | 'operationalRevision' | 'child' + > + | undefined + } +} + +/** A held chat's current work; null when this host holds no conversation for it. */ +export function hostStructuredAgentSessionCurrentWork( + host: StructuredAgentSessionCurrentWorkHost, + sessionId: string +): StructuredAgentSessionCurrentWork | null { + const session = host.sessions.get(sessionId) + return session + ? heldStructuredAgentSessionCurrentWork(host.store, sessionId, session.journal, session) + : null +} + +/** The one current work every host reader decides and displays by: this host's child is always + * evidence (none held reads as none), so an action never reads work the display does not show. */ +export function heldStructuredAgentSessionCurrentWork( + store: StructuredAgentSessionCurrentWorkHost['store'], + sessionId: string, + journal: StructuredAgentSessionCurrentWorkJournal, + session: + | Pick + | undefined +): StructuredAgentSessionCurrentWork { + const ended = session?.lastEndedChild + return structuredAgentSessionCurrentWork(journal, { + record: store.getRecord(sessionId), + replaced: store.replacedRuntime(sessionId), + child: session?.child ?? null, + ...(ended ? { ended } : {}), + revision: session?.operationalRevision ?? 0 + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9ff8543d514e..6e75c09ead3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,6 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { holdClosedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -78,6 +79,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { : sessions.get(sessionId) await retireSignedOutStructuredAgentSessionChild(sessionId, session, { work: { + currentWork: () => + hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), childWork: () => input.clientDelivery.readChildWork(sessionId), hasOpenDispatch: () => { const record = deps.store.getRecord(sessionId) @@ -109,6 +112,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { ), logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), + currentWork: (sessionId) => + hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), readChildWork: input.clientDelivery.readChildWork, stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now() @@ -127,9 +132,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { } const wakesQueued = new Set() const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + const work = hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId) if ( wakesQueued.has(sessionId) || - structuredAgentSessionCommandRunning(journal) || + !work || + structuredAgentSessionCommandRunning(work) || !journal.submissions().some(isQueuedAgentJournalSubmission) ) { return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 8e072e714b6d..abfcd7076a03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -49,6 +49,8 @@ export type StructuredAgentSessionLifetimeContext = { now: () => number /** Re-projects the session's status after its agent stopped and the chat stays. */ publishStatus?: (sessionId: string) => void + /** A generation ended (`StructuredAgentSessionClientDelivery.publishGenerationEnded`). */ + generationEnded?: (sessionId: string) => void /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ wakeDelivery?: (sessionId: string) => void /** The one exit handler (`structured-agent-session-child-exit`), for a caller inside the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts index 25d475abb460..3ad17339753f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-status-feed.ts @@ -1,4 +1,4 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { deferredStructuredAgentSessionLogger, type StructuredAgentSessionLogger @@ -16,7 +16,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { sessions: StructuredAgentSessionStatusFeedDeps['sessions'] now: () => number deps: () => { - store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + store: Pick logger: StructuredAgentSessionLogger onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink @@ -27,6 +27,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId), now: args.now, logger: deferredStructuredAgentSessionLogger(() => args.deps().logger), onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index d9b8e068c37e..308aa76035e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -1,7 +1,8 @@ // The Stop event a host stop writes (`JournalStopEvent`): whether it ends work its event must // record, and the write itself, issued before the kill. -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -62,22 +63,27 @@ export async function stopEndsWork( STOP_EVENT_DRAIN_TIMEOUT_MS, 'slow' as const ) + // The child is live: the work its stop ends is its generation's. + const work = heldStructuredAgentSessionCurrentWork( + context.deps.store, + sessionId, + journal, + session + ) const working = - (drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) || - isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - child.fence - ) - return working && (ending.cause === 'user-close' || !defersToPersonsStop(session)) + (drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) || work.working() + return working && (ending.cause === 'user-close' || !defersToPersonsStop(session, work)) } /** A host stop of work a person's Stop is already ending must not supersede that Stop's reason: * the turn it decides, or, with no turn running, the work under the queue pause it still holds, * with nothing sent since, which a host's event would lift. */ -function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean { +function defersToPersonsStop( + session: StructuredAgentSessionHostSession, + work: StructuredAgentSessionCurrentWork +): boolean { const { journal } = session - const live = journal.activeTurnId() + const live = work.activeTurnId() if (live !== null) { return journal.stopMarks.personStopDecides(live) } @@ -101,12 +107,15 @@ export function recordStopEvent( if ('recorded' in ending) { return Promise.resolve(null) } - const turnId = session.journal.activeTurnId() + const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) + const turnId = heldStructuredAgentSessionCurrentWork( + context.deps.store, + sessionId, + session.journal, + session + ).activeTurnId() return session.journal - .appendStopEvent( - { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, - structuredAgentSessionConversationFence(context.deps.store, sessionId) - ) + .appendStopEvent({ reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, fence) .then( () => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null), (error: unknown) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index d6372878b2bd..35fa639691df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -110,6 +110,9 @@ export type StructuredAgentSessionHostSession = { * observed exit. */ child: StructuredAgentSessionProviderChild | null lastEndedChild?: StructuredAgentSessionEndedChild + /** Bumped by every signal that a generation ended, which can change what is current with no + * journal row or lease write: readers key what they cache of current work on it. */ + operationalRevision?: number /** Owned by one live restart action, so another caller cannot replace its progress. */ restartResume?: NonNullable & { operationId: string } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8f9a97671a07..bce265452396 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -47,7 +47,6 @@ import { } from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import * as conversation from './structured-agent-session-host-delivery' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -75,7 +74,7 @@ export class StructuredAgentSessionHost { this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.queued.onJournalActivity(sessionId), + this.queued, (sessionId) => this.restartResume.onAgentStarted(sessionId), (sessionId) => this.backgroundTasks.publish(sessionId), (sessionId) => this.backgroundTasks.read(sessionId) @@ -142,13 +141,8 @@ export class StructuredAgentSessionHost { store: deps.store, sessions: this.sessions, flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), - publishFence: (sessionId, session) => - this.subscribers.snapshot( - sessionId, - session.journal, - structuredAgentSessionConversationFence(deps.store, sessionId) - ), publishStatus: this.clientDelivery.publishStatusAndSettlement, + generationEnded: this.generationEnded, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), runtimeState: this.runtimeState, @@ -173,6 +167,14 @@ export class StructuredAgentSessionHost { } private now = (): number => this.deps.now?.() ?? Date.now() + /** A generation ended: what readers cache of current work is keyed on the bump, then all re-derive. */ + private generationEnded = (sessionId: string, options?: { restate?: boolean }): void => { + const session = this.sessions.get(sessionId) + if (session) { + session.operationalRevision = (session.operationalRevision ?? 0) + 1 + } + this.clientDelivery.publishGenerationEnded(sessionId, options) + } hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) hasLoadedProviders = (): boolean => @@ -190,6 +192,7 @@ export class StructuredAgentSessionHost { sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus, + generationEnded: this.generationEnded, wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), endExitedChild: this.eventRecovery.endExitedChildUnderSerialize } satisfies StructuredAgentSessionLifetimeContext @@ -330,6 +333,9 @@ export class StructuredAgentSessionHost { journalSnapshot = async (sessionId: string): Promise => (await this.lifetime.conversation(sessionId)).journal.snapshot() + /** The host's projection of a held chat's current work (`structuredAgentSessionCurrentWork`). */ + currentWork = this.clientDelivery.readCurrentWork + subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 7de8ad28581f..f3adb18b2a2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -225,7 +225,7 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) - it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { + it('never stops the retry whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { await rig.dispose() let open = true const hasOpenDispatch = vi.fn(() => open) @@ -320,6 +320,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => false, + store: { getRecord: () => null, replacedRuntime: () => undefined }, childWork: () => undefined, hasOpenDispatch: () => false, providerHoldsDispatch: () => false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1fd7fc937568..ee010cc4413d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -13,7 +13,11 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import { + heldStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWorkHost +} from './structured-agent-session-host-current-work' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -33,6 +37,8 @@ export type StructuredAgentSessionIdleSweepDeps = { hasOpenDispatch: (sessionId: string) => boolean /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ providerHoldsDispatch: (sessionId: string) => boolean + /** The lease store the current-work projection reads (`heldStructuredAgentSessionCurrentWork`). */ + store: StructuredAgentSessionCurrentWorkHost['store'] /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise @@ -42,35 +48,34 @@ export type StructuredAgentSessionIdleSweepDeps = { idleMs?: number } -/** The host's reads of what a running child still serves besides its journal. */ +/** The host's reads of what a running child still serves. */ export type StructuredAgentSessionChildWorkReads = { + /** The host's projection of current work: an ended generation's turn or prompt holds nothing. + * Null when the host holds no conversation, which has none. */ + currentWork: () => Pick< + StructuredAgentSessionCurrentWork, + 'activeTurnId' | 'hasActionablePrompt' + > | null childWork: () => readonly AgentChildWorkView[] | undefined hasOpenDispatch: () => boolean providerHoldsDispatch: () => boolean } /** Work the running child owes apart from queued sends: a turn, a child agent, an open - * orchestration dispatch, a send the provider holds, or a prompt nobody answered. Whatever owes - * any of it keeps its child; it builds no snapshot. */ + * orchestration dispatch, a send the provider holds, or a prompt nobody answered (a subagent's + * too, which the lead turn cannot see). Whatever owes any of it keeps its child; it builds no + * snapshot. */ export function structuredAgentSessionChildHasOpenWork( - journal: Pick, reads: StructuredAgentSessionChildWorkReads ): boolean { - if ( - journal.activeTurnId() !== null || + const work = reads.currentWork() + return ( + (work !== null && work.activeTurnId() !== null) || agentChildWorkLiveness(reads.childWork()) !== null || reads.hasOpenDispatch() || - reads.providerHoldsDispatch() - ) { - return true - } - let pendingPrompt = false - journal.visitItems((_itemId, _sequence, body) => { - // A subagent can raise a prompt the lead turn cannot see. - pendingPrompt ||= - (body.kind === 'approval' || body.kind === 'question') && body.resolution.state === 'pending' - }) - return pendingPrompt + reads.providerHoldsDispatch() || + (work !== null && work.hasActionablePrompt()) + ) } export class StructuredAgentSessionIdleSweep { @@ -160,7 +165,14 @@ export class StructuredAgentSessionIdleSweep { private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { return ( this.queuedOrDelivering(sessionId, session) || - structuredAgentSessionChildHasOpenWork(session.journal, { + structuredAgentSessionChildHasOpenWork({ + currentWork: () => + heldStructuredAgentSessionCurrentWork( + this.deps.store, + sessionId, + session.journal, + session + ), childWork: () => this.deps.childWork(sessionId), hasOpenDispatch: () => this.deps.hasOpenDispatch(sessionId), providerHoldsDispatch: () => this.deps.providerHoldsDispatch(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.test-fixture.ts new file mode 100644 index 000000000000..56f71b566d25 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.test-fixture.ts @@ -0,0 +1,145 @@ +// A dead generation's leftover work, for the current-work tests: a child that leaves a running +// turn (and a prompt, when asked), and an exit whose settlement the database refuses. + +import { expect, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +export const TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: 'unfinished', + ordinal: 1 +} +export const TURN_KEY = agentJournalItemKey(TURN) +const IN_TURN = { kind: 'turn' as const, turnItemId: TURN_KEY } +export const APPROVAL: AgentJournalItemIdentity = { ...TURN, ordinal: 2 } +/** Refuses every row a settlement writes: its rows are all `recovered`. */ +export const REJECT_RECOVERED_ROWS = ` + CREATE TEMP TRIGGER reject_recovered BEFORE INSERT ON journal_rows + WHEN json_extract(NEW.row_json, '$.recovered') = 1 + BEGIN SELECT RAISE(ABORT, 'temporary storage failure'); END; +` + +/** The live child leaves a running turn, and a pending prompt when asked, in the journal. */ +export async function leaveUnfinishedWork( + current: QueuedMessageTestRig, + options: { prompt?: true } = {} +): Promise { + const events = current.providerEvents() + events.appendItem( + TURN, + { kind: 'turn', turnId: 'unfinished', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + if (options.prompt) { + events.appendItem( + APPROVAL, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: IN_TURN } + ) + } + await current.host.flushStreamedEvents(SESSION) +} + +/** The child dies on its own while the database refuses every settlement row: the exit releases + * its lease (proof written, fence moved) and its work stays saved as running. */ +export async function exitWhileSettlementFails(current: QueuedMessageTestRig) { + const context = current.host.collaboratorsForTests() + const session = context.sessions.get(SESSION) + const child = session?.child + if (!session || !child?.generation) { + throw new Error('expected the original child') + } + const database = openTestJournalHostDatabase(current.root) + const before = liveTestJournalRows(database.db, SESSION) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + database.db.exec(REJECT_RECOVERED_ROWS) + await current.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + cause: 'unexpected-exit', + reason: 'observed exit', + fence: child.fence, + acquisitionGeneration: child.generation + }) + await context.serialize(SESSION, async () => {}) + expect(session.child).toBeNull() + expect(current.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: child.fence + 1, + deathEvidence: { kind: 'exit-observed', ownerFence: child.fence } + }) + expect(liveTestJournalRows(database.db, SESSION)).toEqual(before) + return { session, database, deadFence: child.fence } +} + +/** A person's ordinary text send from the composer, with queueing on, at the current fence. */ +export function sendText(current: QueuedMessageTestRig, text: string) { + const fence = current.store.getRecord(SESSION)?.lease.runtimeFence + if (fence === undefined) { + throw new Error('expected the record') + } + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const fields = { body, delivery: 'queue-if-active' as const } + const result = current.host.send(CALLER, { + ...fields, + userSend: true, + envelope: { + ...current.envelope(fields, 'agentSession.send', clientOperationId), + expectedRuntimeFence: fence + } + }) + return { clientOperationId, result } +} + +export function currentJournal(current: QueuedMessageTestRig) { + const journal = current.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the open conversation') + } + return journal +} + +/** The host's projection of the chat's current work, as every host reader asks it. */ +export function currentWork(current: QueuedMessageTestRig): StructuredAgentSessionCurrentWork { + const work = hostStructuredAgentSessionCurrentWork( + { store: current.store, sessions: current.host.collaboratorsForTests().sessions }, + SESSION + ) + if (!work) { + throw new Error('expected the open conversation') + } + return work +} + +export function turnState(current: QueuedMessageTestRig): string | undefined { + return readAgentJournalTurn(currentJournal(current).itemBody(TURN_KEY) ?? undefined)?.state +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts index 75c88bcf480e..895f4f39d18b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts @@ -116,7 +116,7 @@ describe('a running turn whose record is older than the loaded page', () => { await runLongTurn('turn-1', 250) const state = hydrate() - expect(journal.activeTurnId()).toBe('turn-1') + expect(journal.runningTurn()?.turnId ?? null).toBe('turn-1') expect(state.hasOlder).toBe(true) // What every client read before: the record is off the page, so the loaded rows said idle. expect(activeStructuredAgentSessionTurnId(state.items)).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 54a1d769a001..39a3934bd26f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -40,6 +40,7 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' import { agentSessionOperationOutcomeUnknown, @@ -74,6 +75,8 @@ export type AgentSessionMutationRequest = { plan: MutationPlan /** Journal of the attached session, read after `prepareSession`; absent when this host holds none. */ journal: () => AgentSessionJournal | undefined + /** The host's projection of the chat's current work (`hostStructuredAgentSessionCurrentWork`). */ + currentWork?: () => StructuredAgentSessionCurrentWork | null /** Between the ledger's answer and the lease check, for a call that may first have to make the * session ready for itself. Answers with the refusal that ends the call, if any. */ prepareSession?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index f9b83c3b4f3a..0b3280335fbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -15,6 +15,7 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -72,6 +73,11 @@ export function mutateStructuredAgentSession( envelope, plan, journal: () => context.sessions.get(envelope.sessionId)?.journal, + currentWork: () => + hostStructuredAgentSessionCurrentWork( + { store: context.deps.store, sessions: context.sessions }, + envelope.sessionId + ), prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts index 776937662eb0..40ecfb34c6c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -11,11 +11,13 @@ export function mutationTurnContext( record: AgentSessionRecord ): AgentSessionTurnContext { const fence = record.lease.runtimeFence - const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + const { sessionId } = request.envelope + const persistedOptions = request.store.getRecord(sessionId)?.options return { - sessionId: request.envelope.sessionId, + sessionId, journal, fence, + ...(request.currentWork ? { currentWork: request.currentWork } : {}), adapter: request.adapter, agents: request.agents, agent: record.provider, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts index b4281859aba0..a92a9d036beb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts @@ -14,6 +14,7 @@ import { type PendingPromptValidation } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' type CancelOutcome = TurnOutcome type PendingPrompt = Extract @@ -64,7 +65,7 @@ export async function cancelStructuredAgentSessionPrompt( } function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): boolean { - const live = ctx.journal.liveTurnScope() + const live = contextStructuredAgentSessionCurrentWork(ctx).turnScope() const raised = pending.item.turnScope return live.kind === 'turn' && raised?.kind === 'turn' && raised.turnItemId === live.turnItemId } @@ -102,7 +103,7 @@ async function dismissPrompt( } }, // A revision: the prompt keeps the turn it was raised in. - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } ) committed = true } @@ -128,7 +129,7 @@ async function dismissPrompt( surface: 'row' }) }, - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 978d65d055bf..080a70dfb7ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -85,12 +85,26 @@ export function endProviderChild( ended: Omit & { endedAt?: AgentJournalCursor } +): boolean { + if (!recordProviderChildEnd(session, ended)) { + return false + } + session.child = null + return true +} + +/** Records how the child on record ended, leaving it on record for its own teardown: every reader + * projects its generation as ended from now on (`StructuredAgentSessionCurrentWork`). */ +export function recordProviderChildEnd( + session: ChildBearer, + ended: Omit & { + endedAt?: AgentJournalCursor + } ): boolean { const child = matchingChild(session, ended) if (!child) { return false } - session.child = null session.lastEndedChild = { ...ended, ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 915e781401ab..9052eb0eb38a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -42,6 +42,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' let rig: QueuedMessageTestRig @@ -555,6 +556,12 @@ describe('where the host would refuse the send', () => { if (!journal) { throw new Error('expected the conversation open') } + // The clear's generation end wakes the drain, which would send the card; holding the session's + // lane keeps it waiting while the test reads the gate (`release` lets the drain run). + // The gate exists before the task runs: other work may be queued ahead of it on the lane. + let release = (): void => undefined + const gate = new Promise((resolve) => (release = resolve)) + const held = rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, () => gate) const card = 'left-behind' await journal.queuedMessages.insert({ messageId: card, @@ -566,20 +573,33 @@ describe('where the host would refuse the send', () => { if (!record) { throw new Error('expected the record') } - return { card, journal, record, fence: record.lease.runtimeFence } + const done = async (): Promise => { + release() + await held + } + return { card, journal, record, fence: record.lease.runtimeFence, done } } it('a card queued after clear is sendable with the completed clear record still present', async () => { - const { card, journal, record, fence } = await cardAddedAfterClear() - const gate = structuredQueueSendGate(rig.store, HOST_TEST_SESSION) + const { card, journal, record, fence, done } = await cardAddedAfterClear() + const gate = structuredQueueSendGate( + { store: rig.store, sessions: rig.host.collaboratorsForTests().sessions }, + HOST_TEST_SESSION + ) expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBe(card) const { conversationCommand: _cleared, ...unblocked } = record - const next = readQueuePublication(journal, () => ({ record: unblocked, fence })) + const next = readQueuePublication(journal, () => ({ + record: unblocked, + work: new StructuredAgentSessionCurrentWork(journal, fence) + })) expect(next.nextQueuedMessageId).toBe(card) + await done() + // Released, the drain the clear's end woke sends it. + await vi.waitFor(() => expect(journal.queuedMessages.get(card)?.state).toBe('dispatched')) }) it('a rewind whose outcome is unknown names no next card', async () => { - const { journal, record, fence } = await cardAddedAfterClear() + const { journal, record, fence, done } = await cardAddedAfterClear() const { conversationCommand: _cleared, ...unblocked } = record const rewind = { operationId: hostTestOperationId(), @@ -590,9 +610,13 @@ describe('where the host would refuse the send', () => { retained: [] } const next = (gateRecord: typeof record) => - readQueuePublication(journal, () => ({ record: gateRecord, fence })).nextQueuedMessageId + readQueuePublication(journal, () => ({ + record: gateRecord, + work: new StructuredAgentSessionCurrentWork(journal, fence) + })).nextQueuedMessageId expect(next({ ...unblocked, rewind })).toBeNull() expect(next({ ...unblocked, rewind: { ...rewind, phase: 'provider-succeeded' } })).toBeNull() expect(next({ ...unblocked, rewind: { ...rewind, phase: 'completed' } })).not.toBeNull() + await done() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts index 9e4280fb54f7..694482b418b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts @@ -75,8 +75,12 @@ function hold( : { ...common, action } ) } +function gate() { + const host = { store: rig.store, sessions: rig.host.collaboratorsForTests().sessions } + return structuredQueueSendGate(host, HOST_TEST_SESSION) +} function publication() { - return readQueuePublication(journal(), structuredQueueSendGate(rig.store, HOST_TEST_SESSION)) + return readQueuePublication(journal(), gate()) } function published(id: string) { return publication().queuedMessages.find((message) => message.messageId === id) @@ -198,7 +202,7 @@ describe('edit leases and automatic delivery', () => { expect( shouldQueueStructuredAgentSessionSend({ journal: journal(), - ...structuredQueueSendGate(rig.store, HOST_TEST_SESSION)() + ...gate()(journal()) }) ).toBe(true) // An explicit Send still overrides, and spends the lease with the card. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index b9f6e3167920..742bf19a91f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -18,6 +18,7 @@ import { hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' +import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' let rig: QueuedMessageTestRig let host: QueuedMessageTestRig['host'] @@ -87,9 +88,12 @@ describe('the one queue gate', () => { }) // Read in place: the gate runs on every admission and drain step. const snapshot = vi.spyOn(journal, 'snapshot') - expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( - 'prompt' - ) + expect( + structuredQueueHold({ + record: store.getRecord(SESSION), + work: new StructuredAgentSessionCurrentWork(journal, 1) + }) + ).toBe('prompt') expect(snapshot).not.toHaveBeenCalled() snapshot.mockRestore() await journal.appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index ef7e36953bd3..6cb6b07bee25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -11,6 +11,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -49,6 +50,8 @@ export async function createQueuedMessageTestRig( idleSweep?: { idleMs: number; intervalMs: number } /** Teardown records its restart offers, which `restartOffers` lists. */ recoveryCapsule?: true + /** Where the chat runs, when not the default worktree. */ + location?: AgentSessionExecutionLocation } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) @@ -70,9 +73,11 @@ export async function createQueuedMessageTestRig( ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) }) let host = makeHost() - expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ - ok: true - }) + const firstAttach = hostTestAttachParams( + null, + options.location ? { location: options.location } : {} + ) + expect(await host.attach(QUEUED_RIG_CALLER, firstAttach)).toMatchObject({ ok: true }) function envelope( fields: Record, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac85a5ed8bfb..ca06988c7d00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -19,8 +19,11 @@ import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + contextStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWork +} from './structured-agent-session-current-work' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' @@ -39,22 +42,6 @@ export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): bool return body.blocks.every((block) => block.type === 'text') } -/** Walks the reduced items in place: the gate runs on every admission and - * drain step, so it must not render a snapshot of the whole journal. */ -export function pendingPromptExists(journal: Pick): boolean { - let pending = false - journal.visitItems((_itemId, _sequence, body) => { - if ( - !pending && - (body.kind === 'approval' || body.kind === 'question') && - body.resolution.state === 'pending' - ) { - pending = true - } - }) - return pending -} - /** Waiting, not held on its own, and not positioned behind a returned card or a * card the queue's pause holds: the queue never reorders. The admission rule * (§accept) and the drain's selection both read it; only the drain's (`automatic`) stops at a @@ -82,7 +69,8 @@ function oldestActionableQueuedMessage( * drain step: any hold returns early; whatever clears it publishes or * commits, which re-derives. * Send-now: overrides only `working` (plus FIFO order and the stored hold), - * never for a command card; `blocked` and `prompt` refuse readably. + * never for a command card while a turn runs; `blocked` and `prompt` refuse + * readably. * * `blocked` is whatever refuses any send (an uncertain rewind, a cleared source); * the rest are waits. A /compact is a queued message and then a turn, @@ -92,50 +80,37 @@ function oldestActionableQueuedMessage( */ export type StructuredQueueHold = 'blocked' | 'working' | 'prompt' -export function structuredQueueHold(input: { - journal: AgentSessionJournal +/** What the queue's gate reads: the record, and the host's projection of current work + * (`structuredAgentSessionCurrentWork`), so an ended generation's leftovers hold nothing. */ +export type StructuredQueueGateInput = { record: AgentSessionRecord | null - fence: number -}): StructuredQueueHold | null { + work: StructuredAgentSessionCurrentWork +} + +export function structuredQueueHold(input: StructuredQueueGateInput): StructuredQueueHold | null { // Whatever refuses any send refuses the queue too: an uncertain rewind or a source a // clear superseded. One rule, the immediate path's own. if (structuredAgentSessionSendBlock(input.record)) { return 'blocked' } - const { journal } = input // `prompt` outranks `working`: it is the one wait Send-now may not override, // so a prompt raised mid-turn must not read as merely `working`. - if (pendingPromptExists(journal)) { + if (input.work.hasActionablePrompt()) { return 'prompt' } - if ( - isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - input.fence - ) - ) { - return 'working' - } - return null + return input.work.working() ? 'working' : null } /** The card the drain sends next, or null while anything holds the queue: the drain's own pick * through the one gate, so a client told this reads what the drain acts on. Live facts only; the * backlog is never a gate, so a lone draft drains. */ -export function nextStructuredQueuedMessage(input: { - journal: AgentSessionJournal - record: AgentSessionRecord | null - fence: number -}): QueuedMessageRow | null { +export function nextStructuredQueuedMessage( + input: StructuredQueueGateInput & { journal: AgentSessionJournal } +): QueuedMessageRow | null { const next = oldestActionableQueuedMessage(input.journal, true) - const { journal, fence } = input // The gate's cheap `working` first: publication asks on every streamed frame, and the gate's // prompt check walks the whole fold. - if ( - next === null || - isStructuredAgentSessionMainAgentWorking(journal.activeTurnId(), journal.submissions(), fence) - ) { + if (next === null || input.work.working()) { return null } return structuredQueueHold(input) === null ? next : null @@ -149,11 +124,9 @@ export function nextStructuredQueuedMessage(input: { * turn starting is what lifts the pause — Orca's own queue policy, a stated * deviation from held-head backlog counting. */ -export function shouldQueueStructuredAgentSessionSend(input: { - journal: AgentSessionJournal - record: AgentSessionRecord | null - fence: number -}): boolean { +export function shouldQueueStructuredAgentSessionSend( + input: StructuredQueueGateInput & { journal: AgentSessionJournal } +): boolean { const hold = structuredQueueHold(input) if (hold === 'blocked') { // The immediate path's own refusal (`structuredAgentSessionSendBlock`) @@ -176,7 +149,10 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: Pick, + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'journal' | 'fence' | 'operationReceipt' | 'currentWork' + >, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem @@ -209,7 +185,7 @@ export async function maybeQueueStructuredAgentSessionSend( !shouldQueueStructuredAgentSessionSend({ journal: ctx.journal, record: context.deps.store.getRecord(ctx.sessionId), - fence: ctx.fence + work: contextStructuredAgentSessionCurrentWork(ctx) }) ) { return null @@ -257,6 +233,8 @@ export type QueuedMessageDrainDeps = { getRecord: (sessionId: string) => AgentSessionRecord | null serialize: (sessionId: string, task: () => Promise) => Promise conversationFence: (sessionId: string) => number + /** The host's projection of the chat's current work (`structuredAgentSessionCurrentWork`). */ + currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null /** The consumed submission is ordinary #22821 work from here on. */ wakeDelivery: (sessionId: string) => void logger: StructuredAgentSessionLogger @@ -264,9 +242,11 @@ export type QueuedMessageDrainDeps = { /** * The serialized drain. Woken by every journal commit (turn, submission, prompt, - * command and Stop settlements are all commits), by draft mutations, and by the - * conversation opening; each step re-derives everything and consumes at most one - * draft — the consumed submission then owes work, which gates the next. + * command and Stop settlements are all commits), by draft mutations, by the + * conversation opening, and by a generation ending, which may write nothing + * (`StructuredAgentSessionClientDelivery.publishGenerationEnded`); each step + * re-derives everything and consumes at most one draft — the consumed submission + * then owes work, which gates the next. */ export class StructuredAgentSessionQueuedMessageDrain { private readonly scheduled = new Set() @@ -287,17 +267,13 @@ export class StructuredAgentSessionQueuedMessageDrain { return } // Cheap pre-check so token streams do not pay a serialized step per delta. - // Skipping while working is safe: whatever ends the work is itself a commit - // that schedules again, and the step re-reads every gate from the fold. + // Skipping while working is safe: whatever ends the work is a commit or a generation's end, + // and each schedules again; the step re-reads every gate. try { if ( !journal.queuedMessages.settlementOwed() && (oldestActionableQueuedMessage(journal, true) === null || - isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - this.deps.conversationFence(sessionId) - )) + this.deps.currentWork(sessionId)?.working() !== false) ) { return } @@ -343,7 +319,8 @@ export class StructuredAgentSessionQueuedMessageDrain { const fence = this.deps.conversationFence(sessionId) // Whatever clears a hold publishes or commits, which re-derives this step. const record = this.deps.getRecord(sessionId) - const next = nextStructuredQueuedMessage({ journal, record, fence }) + const work = this.deps.currentWork(sessionId) + const next = work ? nextStructuredQueuedMessage({ journal, record, work }) : null if (this.disposed || !next) { return } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index a3fee6e2a339..f084a1193a8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -4,6 +4,7 @@ // from the operation ledger. No mutation returns draft text: // the published list is the one authority a client renders. +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' import type { @@ -131,7 +132,8 @@ export function sendQueuedStructuredAgentMessage( // The one queue gate; Send-now's override set is exactly `working` (plus // FIFO order and the stored hold, which the consume below clears). const record = context.deps.store.getRecord(ctx.sessionId) - const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence }) + const work = contextStructuredAgentSessionCurrentWork(ctx) + const hold = structuredQueueHold({ record, work }) if (hold === 'blocked') { return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.') } @@ -154,7 +156,7 @@ export function sendQueuedStructuredAgentMessage( } // A command never steers: handed over mid-turn it would only be refused. Clients offer its // Send only while the agent is idle; this answers an older one that offers it mid-turn. - if (hold === 'working' && row.body.command) { + if (row.body.command && work.working()) { return invalid("A command can't be sent while the agent is working.") } const submissionId = operationId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index d48d9bf27c39..1c8185485aa9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -15,10 +15,14 @@ import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { nextStructuredQueuedMessage } from './structured-agent-session-queued-messages' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + nextStructuredQueuedMessage, + type StructuredQueueGateInput +} from './structured-agent-session-queued-messages' +import { + heldStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWorkHost +} from './structured-agent-session-host-current-work' export type QueuePublication = { queuedMessages: AgentSessionQueuedMessage[] @@ -29,15 +33,20 @@ export type QueuePublication = { } /** What the drain's gate reads beyond the journal; resolved per read. */ -export type QueueSendGate = () => { record: AgentSessionRecord | null; fence: number } +export type QueueSendGate = (journal: AgentSessionJournal) => StructuredQueueGateInput export function structuredQueueSendGate( - store: Pick, + host: StructuredAgentSessionCurrentWorkHost, sessionId: string ): QueueSendGate { - return () => ({ - record: store.getRecord(sessionId), - fence: structuredAgentSessionConversationFence(store, sessionId) + return (journal) => ({ + record: host.store.getRecord(sessionId), + work: heldStructuredAgentSessionCurrentWork( + host.store, + sessionId, + journal, + host.sessions.get(sessionId) + ) }) } @@ -135,7 +144,8 @@ export function readQueuePublication( resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null // The test narrows the type. const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null - const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null + const nextQueuedMessageId = + nextStructuredQueuedMessage({ journal, ...gate(journal) })?.messageId ?? null const previous = publications.get(journal) if ( previous && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index fea528147494..75f533340284 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -14,6 +14,7 @@ import { } from '../agent-session-journal/queued-message-table' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import { isMainAgentWorking } from './structured-agent-session-turns-cancel' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionStopNamesTurnNotLive, structuredAgentSessionStoppedTurnId @@ -45,7 +46,11 @@ export async function runRecordedStop( const skipped = (error: unknown): void => report(ctx, 'event row', error) return stop(() => { try { - const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined + const turnId = + structuredAgentSessionStoppedTurnId( + contextStructuredAgentSessionCurrentWork(ctx), + event.turnId + ) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) .then(() => undefined, skipped) @@ -65,10 +70,10 @@ export async function runRecordedStop( * already over, as a late Stop from a phone can. */ export function stopReachesUnrecordedWork( - ctx: Pick, + ctx: Pick, namedTurnId: string | undefined ): 'unrecorded' | 'repeat' | 'late' { - const live = ctx.journal.activeTurnId() + const live = contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() // No turn published yet while the agent works: the named one may still be opening. if ( structuredAgentSessionStopNamesTurnNotLive(namedTurnId, live) && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index fc8078ad649f..a764aa613e6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' import { deleteQueuedStructuredAgentMessage, @@ -36,6 +37,8 @@ export function wireStructuredAgentSessionQueuedMessages( serialize: (sessionId, task) => context().serialize(sessionId, task), conversationFence: (sessionId) => structuredAgentSessionConversationFence(context().deps.store, sessionId), + currentWork: (sessionId) => + hostStructuredAgentSessionCurrentWork({ store: context().deps.store, sessions }, sessionId), wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), // Read lazily, like the rest of this wiring: the host's deps are not assigned yet. logger: deferredStructuredAgentSessionLogger(() => context().deps.logger) @@ -72,8 +75,11 @@ export function wireStructuredAgentSessionQueuedMessages( ) => holdQueuedStructuredAgentMessageEdit(context(), caller, params), /** Every journal publish: turn, submission, prompt, command and Stop * settlements are all commits, and each re-derives the drain's gates. */ - onJournalActivity: (sessionId: string) => { - sessions.touch(sessionId) + /** `activity`: false for a generation's end, which wakes the drain but is no activity. */ + onJournalActivity: (sessionId: string, activity = true) => { + if (activity) { + sessions.touch(sessionId) + } drain.schedule(sessionId) }, queuedMessageSend: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e5fa9576f6ae..d2fcee941a45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -155,11 +155,14 @@ export function journal( items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[] = [] ) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions(), appendItem() and the provenance reads; a real AgentSessionJournal needs an on-disk SQLite store. return { snapshot: () => ({ items, submissions, cursor: { epoch: EPOCH, sequence: items.length } }), submissions: () => submissions, - appendItem: async () => undefined + appendItem: async () => undefined, + // Created by the generation the record names: current while its lease is held. + itemFence: () => undefined, + wroteBeforeOpen: () => false } as never } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts new file mode 100644 index 000000000000..6ed2a1d09b77 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts @@ -0,0 +1,154 @@ +// A rewind after a failed cleanup: the dead generation's turn and prompt are still saved as +// running and pending, and the rewind rebuilds the epoch at the live fence. Through the rewind's +// own row builders and a real SQLite journal, each carries the old fold's provenance, so the host +// projection never promotes that work to the live generation's. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement, + rewindProviderRows +} from './structured-rewind-retained-host-rows' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} +const DEAD = 1 +const LIVE = 3 + +function codexItem(turnId: string, ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal } +} +const TURN = codexItem('turn-dead', 0) +const PROMPT = codexItem('turn-dead', 1) +const REPLY = codexItem('turn-dead', 2) +const LATER = codexItem('turn-live', 0) +const PENDING = { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} +const REPLY_BODY: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'partial' }] +} + +const LATER_BODY: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'mine' }] +} + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rewind-provenance-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** The dead generation's work as its cleanup left it: nothing settled. */ +async function leftByDeadGeneration(): Promise { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const scope = { fence: DEAD, ownerFence: DEAD, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(TURN, { kind: 'turn', turnId: 'turn-dead', state: 'running' }, scope) + const inTurn = { + ...scope, + turnScope: { kind: 'turn' as const, turnItemId: agentJournalItemKey(TURN) } + } + await journal.appendItem( + PROMPT, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: PENDING + }, + inTurn + ) + await journal.appendItem(REPLY, REPLY_BODY, inTurn) + return journal +} + +/** The rewind's rebuild, as `rewindStructuredAgentSession` and its recovery write it. */ +async function rewindAtLiveFence(journal: AgentSessionJournal): Promise { + const retained = journal.snapshot().items.map(({ itemId, observedAt, turnScope }) => { + const ownerFence = journal.itemFence(itemId) + return { + itemId, + body: journal.itemBody(itemId)!, + observedAt, + ...(turnScope ? { turnScope } : {}), + ...(ownerFence === undefined ? {} : { ownerFence }) + } + }) + // The live agent's history names the dead turn's prompt and reply it still holds, and one of + // its own. + const provider = rewindProviderRows( + [ + { identity: PROMPT, body: journal.itemBody(agentJournalItemKey(PROMPT))! }, + { identity: REPLY, body: REPLY_BODY }, + { identity: LATER, body: LATER_BODY } + ], + 2_000, + LIVE + ) + const merged = mergeRetainedHostLifecycleRows(retained, provider) + await journal.replaceEpochItems('handle_forked', LIVE, merged.map(retainedRowReplacement)) +} + +describe('a rewind after a failed cleanup', () => { + it("does not promote the dead generation's turn, prompt or reply to the live one", async () => { + const journal = await leftByDeadGeneration() + const before = journal.epoch + + await rewindAtLiveFence(journal) + + expect(journal.epoch).not.toBe(before) + const work = new StructuredAgentSessionCurrentWork(journal, LIVE) + // Rebuilt at the live fence, still the dead generation's work. + expect(journal.itemFence(agentJournalItemKey(TURN))).toBe(DEAD) + expect(journal.itemFence(agentJournalItemKey(PROMPT))).toBe(DEAD) + expect(journal.itemFence(agentJournalItemKey(REPLY))).toBe(DEAD) + expect(work.activeTurnId()).toBeNull() + expect(work.actionablePromptIds()).toEqual([]) + expect(work.working()).toBe(false) + // What the live agent itself reported is its own. + expect(work.isCurrentItem(agentJournalItemKey(LATER))).toBe(true) + }) + + it('leaves a generation that is still live holding its own work', async () => { + const journal = await leftByDeadGeneration() + + await rewindAtLiveFence(journal) + + const work = new StructuredAgentSessionCurrentWork(journal, DEAD) + expect(work.activeTurnId()).toBe('turn-dead') + expect(work.actionablePromptIds()).toEqual([agentJournalItemKey(PROMPT)]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 5c1f9df0ce41..4c48b155598c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -18,6 +18,7 @@ import { openWithAgent } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { rewindRefusal } from './structured-rewind-refusal' import { renameRewindTurnOpener } from './structured-rewind-journal-body' @@ -60,6 +61,8 @@ export async function rewindStructuredAgentSession( : { ok: true } }), journal: () => context.sessions.get(sessionId)?.journal, + currentWork: () => + hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId), publish: (journal) => context.publish(sessionId, journal), now: context.now, plan: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index 677963ab3f9b..b6e7cdb4d3c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' +import { + StructuredAgentSessionSendSettlement, + type SendSettlementJournal +} from './structured-agent-session-send-settlement' -function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessionJournal { +function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): SendSettlementJournal { return { + activeTurnId: () => null, cursor: () => ({ epoch: 'epoch-1', sequence: dispatchState === 'pending' ? 1 : 2 }), submissions: () => [ { @@ -17,21 +20,19 @@ function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessio resolvedAt: dispatchState === 'pending' ? null : 2 } ] - } as AgentSessionJournal + } } -function emptyJournal(): AgentSessionJournal { +function emptyJournal(): SendSettlementJournal { return { + activeTurnId: () => null, cursor: () => ({ epoch: 'epoch-1', sequence: 2 }), submissions: () => [] - } as unknown as AgentSessionJournal + } } /** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */ -function queuedJournal( - activeTurnId: string | null, - handedOver = false -): Pick { +function queuedJournal(activeTurnId: string | null, handedOver = false): SendSettlementJournal { return { cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }), activeTurnId: () => activeTurnId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index a9815b953f8f..b89d36ffe3b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -7,8 +7,12 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn' -/** What a settlement read needs of a journal. */ -type SendSettlementJournal = Pick +/** What a settlement read needs: the journal's sends and position, and the live generation's + * running turn as the host's projection reads it (`structuredAgentSessionCurrentWork`), so a send + * never reads as waiting behind a command an ended generation left. */ +export type SendSettlementJournal = Pick & { + activeTurnId: () => string | null +} type SettledSend = { cursor: AgentJournalCursor diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts index 4ed41e8c6884..7f97faa864f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts @@ -15,6 +15,7 @@ import { type StructuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' /** The full row the sidebar's fold would show, for callers that need the lead's own state beside * the working answer — the teardown snapshot records both from this one computation. */ @@ -25,9 +26,17 @@ export function structuredAgentSessionShownStatus( }, childWork: readonly AgentChildWorkView[] | undefined, /** The session's lease fence, as the status feed passes it: a send from an older one is not work. */ - fence: number | undefined + fence: number | undefined, + /** The host's projection of current work (`structuredAgentSessionCurrentWork`), as the status + * feed passes it: an ended generation's turn is not work. */ + scope?: StructuredAgentSessionWorkScope ): StructuredAgentSessionAgentStatus { - const status = projectStructuredAgentSessionStatus(journal.items, journal.submissions, fence) + const status = projectStructuredAgentSessionStatus( + journal.items, + journal.submissions, + fence, + scope + ) return structuredAgentSessionAgentStatus({ status, ...(childWork ? { childWork } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts index ae09e13d8782..02a4e11d91d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts @@ -126,7 +126,6 @@ const conversation = ( child?: StructuredAgentSessionProviderChild | null items?: Row[] submissions?: ReturnType[] - activeTurnId?: string newestTurnEndedAt?: number } = {} ) => { @@ -142,7 +141,6 @@ const conversation = ( visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {}) ), itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence, - activeTurnId: () => input.activeTurnId ?? null, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined ? null @@ -197,6 +195,7 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { invocation: { invocationId: 'spawn-subagent-1', generation: 1 } } const idle: StructuredAgentSessionChildWorkReads = { + currentWork: () => ({ activeTurnId: () => null, hasActionablePrompt: () => false }), childWork: () => [], hasOpenDispatch: () => false, providerHoldsDispatch: () => false @@ -223,7 +222,9 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { it('keeps it while it owes work the idle sweep also protects', async () => { // A pending prompt is the shared check's too; the idle sweep's tests cover that case. for (const kept of [ - await retire({ ...signedOut, activeTurnId: 'turn-1' }), + await retire(signedOut, { + currentWork: () => ({ activeTurnId: () => 'turn-1', hasActionablePrompt: () => false }) + }), await retire(signedOut, { childWork: () => [liveChildWork] }), await retire(signedOut, { hasOpenDispatch: () => true }), await retire(signedOut, { providerHoldsDispatch: () => true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts index 40ea59a69eb8..a4315eeb1ca0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts @@ -16,10 +16,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo /** What the check reads of a conversation; every read skips building a snapshot. */ type SignedOutReading = Pick & { - journal: Pick< - AgentSessionJournal, - 'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn' - > & { + journal: Pick & { submissions(): readonly Pick[] } } @@ -94,7 +91,7 @@ export async function retireSignedOutStructuredAgentSessionChild( if ( !session || !structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || - structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || + structuredAgentSessionChildHasOpenWork(deps.work) || owesWakeUp(session.journal, deps.work.childWork()) ) { return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index fed25d7dc9b0..b79b0cd3ff5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -57,7 +57,7 @@ function contextFor(session: StructuredAgentSessionChildExitSession) { }, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 9fa62fd170e3..0a24a6b805b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -360,6 +360,8 @@ describe('StructuredAgentSessionStatusFeed', () => { it('carries the record model and the running tool line the sidebar row shows', async () => { const journal = await openJournal() + // Written by the record's current generation: work an ended one left never reads working. + const fence = agentSessionRecordFixture().lease.runtimeFence const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), { location: indexed({ journal }).params.location, options: { model: 'gpt-5-codex' }, @@ -368,12 +370,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -384,7 +386,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( { ...USER_IDENTITY, ordinal: 2 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 2a26af994dcb..81b9a273c464 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -19,7 +19,10 @@ import type { import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionEndedChild, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' import { structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary' import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { @@ -46,12 +49,17 @@ type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } child?: Pick | null + /** What the current-work projection reads of the last child this host saw end. */ + lastEndedChild?: Pick + operationalRevision?: number restartResume?: AgentSessionStatusSummary['restartResume'] } export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null + /** `AgentSessionRecordStore.replacedRuntime`; absent where no runtime was replaced. */ + replacedRuntime?: (sessionId: string) => { fence: number } | undefined now: () => number /** Where a failing sink or observer is reported; neither may cost subscribers their event. */ logger: StructuredAgentSessionLogger @@ -86,7 +94,10 @@ export class StructuredAgentSessionStatusFeed { /** The user's newest accepted send each session was last projected with; a new one retires * settled children. */ private readonly acceptedSends = new Map() - private readonly projections = new StructuredAgentSessionJournalProjections() + // Read per call: field initializers run before the constructor assigns `deps`. + private readonly projections = new StructuredAgentSessionJournalProjections((sessionId) => + this.deps.replacedRuntime?.(sessionId) + ) constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -208,8 +219,9 @@ export class StructuredAgentSessionStatusFeed { sessionId: string, journal?: AgentSessionJournal ): StructuredAgentSessionJournalProjection | null { - const source = journal ?? this.deps.sessions.get(sessionId)?.journal - return source ? this.projections.read(source, this.deps.getRecord(sessionId)) : null + const session = this.deps.sessions.get(sessionId) + const source = journal ?? session?.journal + return source ? this.projections.read(source, this.deps.getRecord(sessionId), session) : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -220,7 +232,7 @@ export class StructuredAgentSessionStatusFeed { } const source = journal ?? session.journal const record = this.deps.getRecord(sessionId) - const projection = this.projections.read(source, record) + const projection = this.projections.read(source, record, session) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) const summary = structuredAgentSessionStatusSummary({ sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index f28a49c81aea..23347ef6948b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -7,6 +7,10 @@ import { isStructuredAgentSessionCommandEntry } from '../../../shared/structured import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + structuredAgentSessionCurrentWork, + type StructuredAgentSessionWorkEvidence +} from './structured-agent-session-current-work' import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' import { structuredAgentSessionStopping } from './structured-agent-session-stopping' @@ -18,6 +22,12 @@ export type StructuredAgentSessionJournalProjection = { epoch: string sequence: number fence: number | undefined + /** The live generation's fence (`structuredAgentSessionLiveFence`): an exit this host saw, or a + * release, changes what is current without a journal row. */ + liveFence: number | null + /** `StructuredAgentSessionCurrentWork.revision`: what this host saw end with no row or lease + * write still changes the row. */ + workRevision: number /** The Stop marks' settle revision: a settle edge writes no row, so it is a key of its own. */ stopRevision: number state: StructuredAgentSessionStatusState @@ -35,13 +45,34 @@ export class StructuredAgentSessionJournalProjections { StructuredAgentSessionJournalProjection >() + /** `AgentSessionRecordStore.replacedRuntime`, which ends what a replaced runtime held. */ + constructor( + private readonly replacedRuntime?: ( + sessionId: string + ) => StructuredAgentSessionWorkEvidence['replaced'] + ) {} + read( journal: AgentSessionJournal, - record: AgentSessionRecord | null + record: AgentSessionRecord | null, + /** What this host saw end (`StructuredAgentSessionHostSession`). */ + seen?: { + lastEndedChild?: StructuredAgentSessionWorkEvidence['ended'] + operationalRevision?: number + child?: StructuredAgentSessionWorkEvidence['child'] + } ): StructuredAgentSessionJournalProjection { const cursor = journal.cursor() // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence + const ended = seen?.lastEndedChild + const work = structuredAgentSessionCurrentWork(journal, { + record, + replaced: record ? this.replacedRuntime?.(record.sessionId) : undefined, + child: seen?.child ?? null, + ...(ended ? { ended } : {}), + revision: seen?.operationalRevision ?? 0 + }) const stopRevision = journal.stopMarks.revision() let projection = this.byJournal.get(journal) if ( @@ -49,6 +80,8 @@ export class StructuredAgentSessionJournalProjections { projection.epoch !== cursor.epoch || projection.sequence !== cursor.sequence || projection.fence !== fence || + projection.liveFence !== work.liveFence || + projection.workRevision !== work.revision || projection.stopRevision !== stopRevision ) { // A journalled submission bumps `lastSequence`, so the send-time working @@ -81,17 +114,45 @@ export class StructuredAgentSessionJournalProjections { firstInputSubmissionKey, submissionCount: snapshot.submissions.length, fence, + liveFence: work.liveFence, + workRevision: work.revision, stopRevision, - state: projectStructuredAgentSessionStatusState( - snapshot.items, - snapshot.submissions, - fence + // The host's projection of current work: an ended generation's is not Working. + state: withDiedTurnVerdict( + projectStructuredAgentSessionStatusState( + snapshot.items, + snapshot.submissions, + fence, + work.scope + ), + work.diedTurn() ), acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions), - stopping: structuredAgentSessionStopping(journal, snapshot.items, snapshot.submissions) + stopping: structuredAgentSessionStopping( + journal, + snapshot.items, + snapshot.submissions, + work.scope + ) } this.byJournal.set(journal, projection) } return projection } } + +/** A turn whose generation died on its own reads as its settlement writes it, interrupted, before + * that lands: never a finish with no verdict (`StructuredAgentSessionCurrentWork.diedTurn`). */ +function withDiedTurnVerdict( + state: StructuredAgentSessionStatusState, + died: { turnId: string } | null +): StructuredAgentSessionStatusState { + const request = state.latestRequest + return died && + state.summary.status === 'idle' && + request?.kind === 'turn' && + request.id === died.turnId && + request.turnState === 'running' + ? { ...state, summary: { ...state.summary, turnOutcome: 'interruption' } } + : state +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 250c00bd1f58..3ad178f350bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -122,7 +122,7 @@ async function stopOfStart(options: { held?: true } = {}): Promise { const handedOver = rig.dispatch.mock.calls.length - const mail = rig.send('mail for the worker') + const mail = rig.send('mail for the retry') await mail.result await rig.proveStart() await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) @@ -167,7 +167,7 @@ describe('a Stop of a start that never landed binds no later turn', () => { it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => { await stopOfStart() - const mail = rig.send('mail for the worker') + const mail = rig.send('mail for the retry') await mail.result await turnOpenedBy(mail.id) @@ -248,7 +248,7 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st expect(laterTurnEndRows()).toEqual([ expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) ]) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() }) it('binds no turn that opens after a Stop that stopped nothing', async () => { @@ -346,7 +346,7 @@ describe("a Stop's settle that ends the turn its interrupt took", () => { expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 17a274d6f5fc..9bba98fcf3da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -269,7 +269,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) const drained = sink.drained.bind(sink) vi.spyOn(sink, 'drained') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 55eb9080a5b1..26851761a5b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -364,7 +364,7 @@ describe('a restart between a Stop and its turn end', () => { return new Promise(() => undefined) }) void rig.stop() - await expect.poll(() => journal().activeTurnId()).toBe(TURN) + await expect.poll(() => journal().runningTurn()?.turnId ?? null).toBe(TURN) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await restartAndSettle('exit-observed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index ea0dae59199e..f243af3ba3c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -258,7 +258,7 @@ describe("a Stop's event", () => { await rig.settleAccepted(working, 'working') await turnRow('turn-1', 'interrupted') await eventually(async () => expect((await rig.handoff(next))?.handedOverAt).toBeDefined()) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() const fields = { turnId: 'turn-1' } await rig.host.cancel(QUEUED_RIG_CALLER, { envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 4cbc39073c17..526c82de0b25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -45,7 +45,7 @@ async function runningTurn(): Promise { throw new Error('no open journal') } // The turn row landed when the provider's event was handed over. - expect(journal.activeTurnId()).toBe('turn-1') + expect(journal.runningTurn()?.turnId ?? null).toBe('turn-1') return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index a3fee6b2ed40..ed528949a952 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -66,7 +66,7 @@ function openedTurn() { function nothingRuns(): boolean { return !isStructuredAgentSessionMainAgentWorking( - journal().activeTurnId(), + journal().runningTurn()?.turnId ?? null, journal().submissions() ) } @@ -169,11 +169,13 @@ describe("a person's close pressed before its send's turn showed", () => { await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + // The next child's turn, at the fence the stopped one's end moved to. + const fence = rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence await journal().appendItem( LATER, { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal().appendItem( LATER, @@ -184,7 +186,7 @@ describe("a person's close pressed before its send's turn showed", () => { startedAt: Date.now(), completedAt: Date.now() + 5 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const later = journal() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 8750fe909a0a..a6dc5d103b06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { isMainAgentWorking } from './structured-agent-session-turns-cancel' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' @@ -38,7 +39,7 @@ export type StructuredAgentSessionStopWindDown = { * keeps the child on record, and the next operation that reaches the agent joins that close. */ export async function endStoppedStructuredAgentSession( - ctx: Pick, + ctx: Pick, windDown: StructuredAgentSessionStopWindDown, stopChild: () => Promise, onError: (error: unknown) => void @@ -51,7 +52,10 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) - failedOn = structuredAgentSessionFailedStopMark(ctx.journal) + failedOn = structuredAgentSessionFailedStopMark( + contextStructuredAgentSessionCurrentWork(ctx), + ctx.journal + ) await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) } finally { windDown.settled?.(failedOn) @@ -61,7 +65,7 @@ export async function endStoppedStructuredAgentSession( /** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost * interrupt's says. Work that ended took the Stop, whatever became of the child. */ async function reviseStopNoteUnconfirmed( - ctx: Pick, + ctx: Pick, identity: AgentJournalItemIdentity ): Promise { if (!isMainAgentWorking(ctx)) { @@ -85,7 +89,7 @@ async function reviseStopNoteUnconfirmed( // Stop of that turn writes it: its proven end finds it there. A row keeps the scope it was created // with, so a note a Stop wrote before the turn showed is re-keyed, in one batch. Known limit: with // no turn open yet, the note keeps its key and scope, and no turn's end revises it. - const running = ctx.journal.activeTurnId() + const running = contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() const turnScope = running !== null ? structuredAgentSessionNamedTurnScope(ctx.journal, running) : null const onTurn = running !== null ? structuredAgentSessionStopNoteIdentity(running) : identity diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 92cd6f574331..3dbc001b9510 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -268,7 +268,7 @@ describe('a Stop that failed before the turn it meant to stop opened', () => { expect(await rig.stop()).toMatchObject({ ok: true }) // A Stop that ends its session ends the child on the session's next step. await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() return { sent, status } } @@ -283,7 +283,7 @@ describe('a Stop that failed before the turn it meant to stop opened', () => { await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') - await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1')) expect(status()).toMatchObject({ status: 'working', stopping: true }) await turn('turn-1', sent, 'interrupted') await eventually(() => expect(status()?.status).toBe('idle')) @@ -351,7 +351,7 @@ describe('a Stop pressed before its send opened a turn', () => { it('ends the turn its interrupt took at the settle, and Stopping with it', async () => { const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' }) - expect(journal().activeTurnId()).toBeNull() + expect(journal().runningTurn()?.turnId ?? null).toBeNull() await eventually(() => expect(status()?.status).toBe('idle')) expect(status()).not.toHaveProperty('stopping') }) @@ -390,7 +390,7 @@ describe('a Stop pressed before its send opened a turn', () => { expect(status()).not.toHaveProperty('stopping') await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') - await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1')) expect(status()).not.toHaveProperty('stopping') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index 284a474b9c65..c491814f542b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -10,6 +10,8 @@ import type { } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' @@ -19,7 +21,9 @@ import type { JournalStopFailedOn } from '../agent-session-journal/queued-messag export function structuredAgentSessionStopping( journal: Pick, items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] = [] + submissions: readonly AgentJournalSubmission[] = [], + /** The host's projection of current work: a Stop bound to an ended generation's turn is over. */ + scope?: StructuredAgentSessionWorkScope ): boolean { const stop = journal.stopMarks.latest() if (stop === null || !stopIsAPersons(stop.event.reason)) { @@ -33,7 +37,10 @@ export function structuredAgentSessionStopping( stop.event.turnId ?? stop.settle?.turnId ?? (failed && 'turnId' in failed ? failed.turnId : undefined) - const live = activeStructuredAgentSessionTurnId(items) + const live = activeStructuredAgentSessionTurnId( + items, + scope && ((item) => scope.isCurrentItem(item.itemId)) + ) if (bound !== undefined) { return bound === live } @@ -64,9 +71,10 @@ function firstSendHandedOverAfter( /** Where a Stop that failed leaves "Stopping…" (display only): the turn running now, or, with * none, the first that opens after this position. */ export function structuredAgentSessionFailedStopMark( - journal: Pick + work: Pick, + journal: Pick ): JournalStopFailedOn { - const live = journal.activeTurnId() + const live = work.activeTurnId() return live !== null ? { turnId: live } : { openedAfter: journal.cursor().sequence } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index dcd3e07c4f2a..5e0470430e6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -22,6 +22,7 @@ import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void export type AgentSessionSubscribeInput = { @@ -43,6 +44,9 @@ export type Subscriber = { queuePublication?: QueuePublication /** Fingerprint of the background-task roster last SENT. */ backgroundTasks?: string + /** The current-work view the last frame's fields were read under + * (`StructuredAgentSessionCurrentWork.viewKey`); a change with no row still owes a frame. */ + workView?: string } export type AgentSessionSubscribersHooks = { @@ -53,6 +57,12 @@ export type AgentSessionSubscribersHooks = { readBackgroundTasks?: SubscriberFieldHooks['readBackgroundTasks'] /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void + /** The host's projection of current work (`structuredAgentSessionCurrentWork`), which every + * page and frame states as its turn and prompt fields. */ + readCurrentWork?: ( + sessionId: string, + journal: AgentSessionJournal + ) => StructuredAgentSessionCurrentWork | undefined now?: () => number } @@ -100,10 +110,13 @@ export class AgentSessionSubscribers { }, subscriber, input.journal, - hostNow + hostNow, + this.hooks.readCurrentWork?.(input.sessionId, input.journal) ) } else { - const page = readAgentSessionHydrationPage(input.journal, input.fence) + const work = this.hooks.readCurrentWork?.(input.sessionId, input.journal) + const page = readAgentSessionHydrationPage(input.journal, input.fence, work) + subscriber.workView = work?.viewKey() this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, @@ -144,20 +157,36 @@ export class AgentSessionSubscribers { this.activityBySession.delete(sessionId) } } + this.deliverFrames(sessionId, journal, activity) + if (activity === undefined) { + this.hooks.onJournalPublished?.(sessionId, journal) + } + } + + /** The queue as frames publish it, for a history page to read the same. */ + readQueuePublication(sessionId: string): QueuePublication | undefined { + return this.hooks.readQueuePublication?.(sessionId) + } + + /** Each subscriber's frame for what changed, with none of a journal write's other edges. */ + deliverFrames( + sessionId: string, + journal: AgentSessionJournal, + activity?: AgentSessionTurnActivity | null + ): void { const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.deliver(subscriber, journal, hostNow, false, activity) } - if (activity === undefined) { - this.hooks.onJournalPublished?.(sessionId, journal) - } } /** Every subscriber back to a bounded tail page. */ snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void { - const page = readAgentSessionHydrationPage(journal, fence) + const work = this.hooks.readCurrentWork?.(sessionId, journal) + const page = readAgentSessionHydrationPage(journal, fence, work) const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { + subscriber.workView = work?.viewKey() this.emit(subscriber, { type: 'snapshot', sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 4af804263aec..1310663c9989 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -13,6 +13,7 @@ import type { } from '../../../shared/agent-session-wire' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' function refused( reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, @@ -70,7 +71,7 @@ export async function performThreadGoalChange( sentAs: 'goal' }, // Accepting a goal is delivering it, so it joins whatever turn runs now. - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } ) } const withdrawObjective = async (): Promise => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts index 0f86c053ad66..4a954f7e8fa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts @@ -5,15 +5,16 @@ import type { AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' export const STOP_NOTE_CANCELLATION_REQUESTED = 'Cancellation requested.' -/** The turn a Stop is about: the one it named, else the one running when it is read. */ +/** The turn a Stop is about: the one it named, else the live generation's running turn. */ export function structuredAgentSessionStoppedTurnId( - journal: Pick, + work: Pick, namedTurnId: string | undefined ): string | null { - return namedTurnId ?? journal.activeTurnId() + return namedTurnId ?? work.activeTurnId() } /** A Stop names a turn the journal does not show running, as a phone does once that turn ended. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index a588fa7168af..ab65c4512cc8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -25,7 +25,7 @@ import { structuredAgentSessionStopNamesTurnNotLive, structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' @@ -33,16 +33,12 @@ import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-wi import { settleTakenStop } from './structured-agent-session-taken-stop-settle' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it - * without waiting on the write queue. */ +/** Whether the host's projection reads working (`structuredAgentSessionCurrentWork`). Every write + * has landed by its call's return, so a Stop reads it without waiting on the write queue. */ export function isMainAgentWorking( - ctx: Pick + ctx: Pick ): boolean { - return isStructuredAgentSessionMainAgentWorking( - ctx.journal.activeTurnId(), - ctx.journal.submissions(), - ctx.fence - ) + return contextStructuredAgentSessionCurrentWork(ctx).working() } /** @@ -51,14 +47,15 @@ export function isMainAgentWorking( * its exit ends its turn, and the host's own exit handling waits behind this step. */ function stillRunsStoppedTurn( - ctx: Pick, + ctx: Pick, stoppedTurnId: string | null ): boolean { - if (!isMainAgentWorking(ctx)) { + const work = contextStructuredAgentSessionCurrentWork(ctx) + if (!work.working()) { return false } // Working with no turn open after the Stop's turn is a later send whose turn has not opened. - return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId + return stoppedTurnId === null || work.activeTurnId() === stoppedTurnId } /** Whether the Stop took back every send it found in flight, none of them having run @@ -148,6 +145,8 @@ type PerformCancelInput = { childWork?: () => readonly AgentChildWorkView[] | undefined /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ opensSettle?: true + /** The fence the note is written at: a child end this Stop caused moved it past `ctx.fence`. */ + noteFence?: () => number } async function cancelAndNote( @@ -161,17 +160,20 @@ async function cancelAndNote( kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // Every read of what runs goes through the host's projection: an ended generation's turn is no + // Stop's target, whether or not its cleanup landed. + const work = () => contextStructuredAgentSessionCurrentWork(ctx) // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = (input.turnId !== undefined && !input.scope ? structuredAgentSessionNamedTurnScope(ctx.journal, input.turnId) - : null) ?? ctx.journal.liveTurnScope() + : null) ?? work().turnScope() // Only the provider's end or the child's ends a command. A command the provider has not opened a // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's // dead-generation settlement writes the command's verdict. - const liveTurnId = ctx.journal.activeTurnId() + const liveTurnId = work().activeTurnId() // Read with the live turn, before the cancel settles it: the note is keyed by this turn. - const stoppedTurnId = structuredAgentSessionStoppedTurnId(ctx.journal, input.turnId) + const stoppedTurnId = structuredAgentSessionStoppedTurnId(work(), input.turnId) const namesTurnNotLive = structuredAgentSessionStopNamesTurnNotLive(input.turnId, liveTurnId) const runningCommand = input.stopChild !== undefined && @@ -223,7 +225,7 @@ async function cancelAndNote( ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), fence: ctx.fence, // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), + resolveLiveTurnId: () => work().activeTurnId(), ...(dispatchStatus ? { dispatchStatus } : {}), ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) @@ -330,7 +332,7 @@ async function cancelAndNote( binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined } else if (interruptFailed) { // A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own. - binding.failedOn = structuredAgentSessionFailedStopMark(ctx.journal) + binding.failedOn = structuredAgentSessionFailedStopMark(work(), ctx.journal) } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { const opened = await settleTakenStop(ctx, stoppedTurn) @@ -349,6 +351,7 @@ async function cancelAndNote( (stoppedTurn !== undefined ? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn) : null) ?? turnScope - await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope: noteScope }) + const fence = Math.max(ctx.fence, input.noteFence?.() ?? ctx.fence) + await ctx.journal.appendItem(noteIdentity, note, { fence, turnScope: noteScope }) return { ok: true, value } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index a7bbf523c3ce..b7b21c767d6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -26,6 +26,7 @@ import { import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { settledPrompt, validatePendingPrompt } from './structured-agent-session-prompt-state' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' export type AgentSessionPromptRequest = { @@ -134,7 +135,10 @@ export async function performPrompt( identity, { ...prompt, resolution }, // A revision: the prompt keeps the turn it was raised in. - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() }, + { + fence: ctx.fence, + turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() + }, // The answer's identity commits here, before the adapter tells the provider. ctx.operationReceipt ) @@ -159,7 +163,7 @@ export async function performPrompt( surface: 'row' }) }, - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } ) } const appended = committed.item diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index cb3d87e72e95..b9c6b5e0bbc5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -24,6 +24,10 @@ import { } from '../../../shared/agent-session-wire' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + contextStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWork +} from './structured-agent-session-current-work' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { @@ -53,6 +57,9 @@ export type AgentSessionTurnContext = { sessionId: string journal: AgentSessionJournal fence: number + /** The host's projection of what work is current (`structuredAgentSessionCurrentWork`), read + * fresh per call: a child's end can move it mid-operation. */ + currentWork?: () => StructuredAgentSessionCurrentWork | null adapter: StructuredAgentSessionAdapter /** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */ agents: StructuredAgentRegistry @@ -219,12 +226,13 @@ export async function handOverSubmission( await handOverStructuredAgentSessionCommand(ctx, submission, body) return } - // The message joins the turn running at handover, a steer, or opens its own. + // The message joins the turn the child it goes to is running, a steer, or opens its own; never a + // turn an ended generation left. await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence, - turnScope: ctx.journal.liveTurnScope() + turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() }) // The handover row's instant on the host clock; the turn this dispatch opens records it so the // live counter never re-anchors at turn-open. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index b1fdcb5d8605..1fb10c9d8f28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -115,7 +115,7 @@ describe('provider-exit settlement', () => { now = 60_000 return { ok: false, error: new Error('sink unavailable') } }, - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId, task) => task(), now: () => now } as never, @@ -191,11 +191,12 @@ describe('provider-exit settlement', () => { } await settleStructuredAgentSessionChildExit( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial. { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId, task) => task(), now: () => 1_234 } as never, @@ -308,7 +309,7 @@ describe('provider-exit settlement', () => { ] return { ok: true } }, - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } @@ -357,7 +358,7 @@ describe('provider-exit settlement', () => { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), - publishFence: vi.fn(), + generationEnded: vi.fn(), serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -411,7 +412,7 @@ describe('provider-exit settlement', () => { } } const log = recordingStructuredAgentSessionLogger() - const publishFence = vi.fn() + const generationEnded = vi.fn() const event = { type: 'ended' as const, sessionId: SESSION, @@ -425,7 +426,7 @@ describe('provider-exit settlement', () => { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), - publishFence, + generationEnded, serialize: async (_sessionId, task) => task(), now: () => 1, logger: log.logger @@ -433,7 +434,9 @@ describe('provider-exit settlement', () => { await settleStructuredAgentSessionChildExit(context, event) expect(session.child).toBeNull() - expect(publishFence).toHaveBeenCalledTimes(1) + expect(generationEnded).toHaveBeenCalledTimes(2) + expect(generationEnded).toHaveBeenNthCalledWith(1, SESSION) + expect(generationEnded).toHaveBeenLastCalledWith(SESSION, { restate: true }) expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement']) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index aae177bbd650..329f4405597f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -52,6 +52,8 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' +import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ @@ -71,13 +73,18 @@ function pendingSubmissionInFlight( return null } -/** The work identity to record: a running turn if one exists, else the send still awaiting one. */ +/** The work identity to record: a running turn if one exists, else the send still awaiting one. + * `scope`: the host's (`structuredAgentSessionCurrentWork`), so an ended generation's turn is none. */ export function structuredAgentSessionWorkInFlight( items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + scope?: StructuredAgentSessionWorkScope ): AgentSessionResumeWork | null { const current = agentSessionCurrentContextRows(items, submissions) - const turnId = activeStructuredAgentSessionTurnId(current.items) + const turnId = activeStructuredAgentSessionTurnId( + current.items, + scope && ((item) => scope.isCurrentItem(item.itemId)) + ) if (turnId) { return { kind: 'turn', id: turnId } } @@ -91,7 +98,8 @@ export function structuredAgentSessionWorkInFlight( */ function structuredAgentSessionResumeWork( items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + scope: StructuredAgentSessionWorkScope ): AgentSessionResumeWork | null { const bodies = new Map(items.map((item) => [item.itemId, item.body])) const bodyOf = (itemId: string) => bodies.get(itemId) @@ -107,7 +115,7 @@ function structuredAgentSessionResumeWork( ) { return null } - const inFlight = structuredAgentSessionWorkInFlight(items, submissions) + const inFlight = structuredAgentSessionWorkInFlight(items, submissions, scope) if (inFlight) { return inFlight } @@ -129,13 +137,17 @@ function boundedLabel(text: string | undefined): string { /** The prompts the session is blocked on, from the same items the projection called `attention` * over — one read, so the recorded state and the prompts it stands for cannot disagree. */ -function pendingPrompts(items: readonly AgentJournalRenderItem[]): AgentSessionRestartPrompt[] { +function pendingPrompts( + items: readonly AgentJournalRenderItem[], + scope: StructuredAgentSessionWorkScope +): AgentSessionRestartPrompt[] { const prompts: AgentSessionRestartPrompt[] = [] for (const item of items) { const body = item.body if ( (body.kind !== 'approval' && body.kind !== 'question') || - body.resolution.state !== 'pending' + body.resolution.state !== 'pending' || + !scope.isCurrentItem(item.itemId) ) { continue } @@ -197,16 +209,19 @@ export function structuredAgentSessionWorkingAtStop(input: { !(unanswered && isUnansweredHandedOverSubmission(submission)) ) const childWork = input.childWork(sessionId) + const record = input.getRecord(sessionId) + // The live generation is this host's own child, read first-hand like the rest, never the lease. + const { scope } = new StructuredAgentSessionCurrentWork(session.journal, session.child.fence) const status = structuredAgentSessionShownStatus( { items: current.items, submissions: handedOver }, childWork, - session.child.fence + session.child.fence, + scope ) if (status.state === 'done') { return null } - const work = structuredAgentSessionResumeWork(current.items, handedOver) - const record = input.getRecord(sessionId) + const work = structuredAgentSessionResumeWork(current.items, handedOver, scope) const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null if (!work || !head) { return null @@ -215,7 +230,7 @@ export function structuredAgentSessionWorkingAtStop(input: { // The lead's OWN state, not the fold: a settled lead with running children reads `done` here // and carries them in `tasks`, which is how the dialog tells the two apart. state: status.mainAgent.state, - prompts: pendingPrompts(current.items), + prompts: pendingPrompts(current.items, scope), tasks: liveTasks(childWork) } return { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 50ddcbc08e9e..9a0d655c771a 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -27,7 +27,14 @@ function contextWith( return { sessionId: 'session-1', fence: 1, - journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + journal: { + snapshot: () => ({ items: [] }), + submissions: () => [], + runningTurn: () => null, + itemFence: () => undefined, + visitItems: () => undefined, + wroteBeforeOpen: () => false + }, adapter } } @@ -59,6 +66,8 @@ const SETTLED = child({ settledAt: 300 }) const RECORD = { lease: {} } as unknown as AgentSessionRecord +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the running turn's id and item id. +const RUNNING_TURN = { turnId: 'turn-1', item: { itemId: 'turn-1' } } as never const STOP = 'Stop background tasks before using this command.' const WAIT = 'Wait for background tasks to finish before using this command.' @@ -120,6 +129,7 @@ describe('conversationCommandBlocked background work', () => { } ] }) as unknown as ReturnType + ctx.journal.runningTurn = () => RUNNING_TURN expect(conversationCommandBlocked(ctx, RECORD, [child()])?.message).toBe( 'Wait for the current turn to finish before using this command.' ) @@ -135,6 +145,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => const ctx = contextWith(undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. ctx.journal.snapshot = () => staleTurn as never + ctx.journal.runningTurn = () => RUNNING_TURN expect(conversationCommandBlocked(ctx, RECORD, [], 'at-rest')).toBeNull() expect(conversationCommandBlocked(ctx, RECORD, [])).toMatchObject({ details: { reason: 'turnActive' }, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 7d81e4a77180..b40265c16b4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -6,10 +6,13 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear' -import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + contextStructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWork, + type StructuredAgentSessionCurrentWorkJournal +} from './structured-agent-session-current-work' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { refuse, @@ -21,10 +24,11 @@ import { export type ConversationCommandAdmissionContext = { sessionId: string fence: number - journal: { + journal: StructuredAgentSessionCurrentWorkJournal & { snapshot(): Pick, 'items'> - submissions: AgentSessionJournal['submissions'] } + /** The host's projection of current work; without one, the generation at `fence` is live. */ + currentWork?: () => StructuredAgentSessionCurrentWork | null adapter: Pick } @@ -64,14 +68,18 @@ export function conversationCommandBlocked( if (record.lease.handoffStage || record.lease.handoffOperationId) { return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } - if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) { + // What is current is the host projection's to say: an ended generation's turn, prompt or send + // refuses nothing. + const work = contextStructuredAgentSessionCurrentWork(ctx) + if (admission !== 'at-rest' && work.activeTurnId()) { return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( items.some( (item) => (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' + item.body.resolution.state === 'pending' && + work.isCurrentItem(item.itemId) ) ) { return blocked( @@ -80,7 +88,9 @@ export function conversationCommandBlocked( ) } // The same liveness fold the strip's monitoring indicator reads: settled rows block nothing. - if (agentChildWorkLiveness(childWork) !== null) { + // Child records are the live generation's: its adapter ends them on every close, so with no + // generation live, any still running are an ended one's leftovers and hold nothing. + if (work.liveFence !== null && agentChildWorkLiveness(childWork) !== null) { return blocked( 'backgroundTasksRunning', stripOffersStop(childWork ?? [], ctx.adapter.backgroundTaskStops?.(ctx.sessionId)) @@ -93,8 +103,7 @@ export function conversationCommandBlocked( if ( submissions.some( (entry) => - !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && - isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence) + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && work.owesSend(entry) ) ) { return blocked( diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index ab6df634d13f..606d2093c93b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -11,6 +11,7 @@ import { agentSessionOperationKey } from '../../../shared/agent-session-operatio import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { sendPreparation } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -69,6 +70,8 @@ export function runStructuredConversationCommand( // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. prepareSession: sendPreparation(context, envelope, { refusesInRun: true }), journal: () => context.sessions.get(sessionId)?.journal, + currentWork: () => + hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId), publish: (journal) => context.publish(sessionId, journal), now: context.now, plan: { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 35e5b4fda216..cbd1197d511e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -248,15 +248,16 @@ it('hands over a message held behind the command when the command ends just as t await state.host.conversationCommand(CALLER, compactParams()) await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) const { journal: live } = state.host['sessions'].get(SESSION)! - const activeTurnId = live.activeTurnId + const runningTurn = live.runningTurn let ended = false - vi.spyOn(live, 'activeTurnId').mockImplementation(() => { - const read = activeTurnId() + // The loop reads the command through the host's current-work projection, which asks this. + vi.spyOn(live, 'runningTurn').mockImplementation(() => { + const read = runningTurn() // The provider's end lands the moment the loop's own step reads the command as running and // stops; no other reader's view of it matters here. if ( !ended && - read?.startsWith('compact:') && + read?.turnId.startsWith('compact:') && /at (?:StructuredAgentSessionDeliveryLoop\.)?prepare \(.*structured-agent-session-delivery-loop/.test( new Error('who reads').stack ?? '' ) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index d3bf795a19c6..05683fcc505c 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -73,7 +73,10 @@ describe('structured mailbox pointer host', () => { // far from the tail, so any page-sized read reports a busy worker as idle — and `@idle` then // wakes it mid-turn. const items = [runningTurn(), ...transcript(500)] - hostRef.current = { journalSnapshot: () => ({ items, submissions: [] }) } + hostRef.current = { + journalSnapshot: () => ({ items, submissions: [] }), + currentWork: () => null + } expect(await readStructuredSessionGateFacts('s1')).toEqual({ turnRunning: true, awaitingHuman: false diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index e51734b5b85e..92b1e0594823 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -47,7 +47,12 @@ export async function readStructuredSessionGateFacts( sessionId: string ): Promise { const snapshot = await readSessionJournal(sessionId) - return snapshot ? structuredSessionGateFacts(snapshot.items) : null + if (!snapshot) { + return null + } + // The host's projection of current work: an agent that ended is idle, whatever its rows say. + const work = getStructuredAgentSessionHost()?.currentWork(sessionId) + return structuredSessionGateFacts(snapshot.items, work?.scope) } /** What each recorded send settled as. */ diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index fc7758e07a19..8a99deb39950 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -8,6 +8,7 @@ */ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, projectStructuredAgentSessionStatus @@ -46,11 +47,17 @@ export type StructuredSessionGateFacts = { * and idle-with-history is the normal steady state of a working agent. */ export function structuredSessionGateFacts( - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + /** The host's projection of current work (`structuredAgentSessionCurrentWork`). */ + scope?: StructuredAgentSessionWorkScope ): StructuredSessionGateFacts { return { - turnRunning: activeStructuredAgentSessionTurnId(items) !== null, - awaitingHuman: projectStructuredAgentSessionStatus(items) === 'attention' + turnRunning: + activeStructuredAgentSessionTurnId( + items, + scope && ((item) => scope.isCurrentItem(item.itemId)) + ) !== null, + awaitingHuman: projectStructuredAgentSessionStatus(items, [], undefined, scope) === 'attention' } } diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index e255f5eefc80..dd1a7ec78dc9 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -83,7 +83,8 @@ function installHost(options: { present: true, sessionIds: options.tabListed ? [SESSION_ID] : [] }), - journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }) + journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }), + currentWork: () => null } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index dfda59b17b3f..ce9b35c8f3c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -101,12 +101,14 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), itemFence() and runningTurn() of a journal with no sends. journal: { cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, stopMarks: { latest: () => null, revision: () => 0 }, - snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) + snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }), + itemFence: () => undefined, + runningTurn: () => null } as unknown as AgentSessionJournal, params: { location: { @@ -143,11 +145,7 @@ export function hostStub(): StructuredAgentSessionHost { items: [], removedItemIds: [], submissions: [], - window: { - oldest: null, - newest: null, - nextCursor: { epoch: 'epoch-a', sequence: 0 } - }, + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-a', sequence: 0 } }, liveCursor: { epoch: 'epoch-a', sequence: 0 }, hasOlder: false, hasNewer: false diff --git a/src/main/runtime/structured-worker-cleared-session.test.ts b/src/main/runtime/structured-worker-cleared-session.test.ts index c4d07df01fb8..037ec7beb14c 100644 --- a/src/main/runtime/structured-worker-cleared-session.test.ts +++ b/src/main/runtime/structured-worker-cleared-session.test.ts @@ -207,6 +207,8 @@ function installHost(): void { open.add(id) return { items: [message(`${id}-1`, 'idle')], submissions: [] } }, + // No projection held: the gate reads the timeline as it stands. + currentWork: () => null, setSessionTabVisibility: async (id: string, visible: boolean) => { if (id === failHideOf) { throw new Error('the durable tab index is wedged') diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index b8ff6a140556..a90303343850 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -148,6 +148,16 @@ export type AgentSessionHistoryPage = { subagentRoster?: AgentSessionSubagentRosterEntry[] /** As of the page's read; a client applies it only from a page that replaces its state. */ latestTurn?: AgentSessionLatestTurn | null + /** Rides with `latestTurn`: the approvals and questions still waiting on the person that the + * agent running now raised, over the whole journal. One an agent that has ended raised is not + * listed, since nothing can take its answer. Absent from an older host, whose clients read every + * pending prompt they hold as waiting. */ + actionablePromptIds?: string[] + /** Rides with `latestTurn`: whether the agent running now is working, by the host's projection of + * current work — its running turn, or a send it owes, eligible or handed over and unanswered. + * What an agent that has ended left counts for nothing. Absent from an older host, whose + * clients derive it from the rows they hold. */ + working?: boolean } export type AgentSessionHistoryResult = @@ -207,6 +217,10 @@ export type AgentSessionSubscribeEvent = /** Rides every batch that carries rows, removals or submissions, so absent there means an * older host; absent on one that carries none, which changes no turn. */ latestTurn?: AgentSessionLatestTurn | null + /** Rides with `latestTurn` (`AgentSessionHistoryPage.actionablePromptIds`). */ + actionablePromptIds?: string[] + /** Rides with `latestTurn` (`AgentSessionHistoryPage.working`). */ + working?: boolean } & AgentSessionFrameFields) | ({ type: 'reset' @@ -459,9 +473,7 @@ export type AgentSessionSlashCommand = { /** The provider's own command surface, read per session. Additive read-only * surface: a host that predates it answers `method_not_found`, and the client * keeps rendering its curated catalog. */ -export type AgentSessionCommandsResult = { - commands?: AgentSessionSlashCommand[] -} +export type AgentSessionCommandsResult = { commands?: AgentSessionSlashCommand[] } /** Longest objective a client may send; matches the provider's own limit. */ export const AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH = 4000 @@ -473,9 +485,7 @@ export type AgentSessionThreadGoalChange = | { kind: 'status'; status: 'active' | 'paused' } | { kind: 'clear' } -export type AgentSessionThreadGoalResult = { - change: AgentSessionThreadGoalChange['kind'] -} +export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] } /** Provider-reported choices and effective next-turn values. Additive read-only * surface so older hosts can reject it without changing structured v1 writes. */ diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index a716b8f4a131..94d3ac921735 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -35,16 +35,20 @@ import { type StructuredAgentSessionToolAction } from './structured-agent-session-tool-call-block' +/** `current`: a host's answer to whether the running turn's item is current work + * (`StructuredAgentSessionWorkScope`); one an ended generation opened is none. */ export function activeStructuredAgentSessionTurnId( - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + current?: (item: AgentJournalRenderItem) => boolean ): string | null { for (let index = items.length - 1; index >= 0; index -= 1) { - if (isAgentSessionContextClear(items[index]?.body)) { + const item = items[index] + if (isAgentSessionContextClear(item?.body)) { return null } - const turn = readAgentJournalTurn(items[index]?.body) + const turn = readAgentJournalTurn(item?.body) if (turn) { - return turn.state === 'running' ? turn.turnId : null + return turn.state === 'running' && (!current || (item && current(item))) ? turn.turnId : null } } return null @@ -81,6 +85,14 @@ export function newestStructuredAgentSessionTurnBySequence( export function liveStructuredAgentSessionTurnScope( items: Iterable ): AgentJournalTurnScope { + const running = runningStructuredAgentSessionTurnItemBySequence(items) + return running ? { kind: 'turn', turnItemId: running.item.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} + +/** The newest turn record's item when that turn is running, by sequence. */ +export function runningStructuredAgentSessionTurnItemBySequence( + items: Iterable +): { item: AgentJournalRenderItem; turnId: string } | null { let newest: AgentJournalRenderItem | null = null for (const item of items) { if ( @@ -90,17 +102,8 @@ export function liveStructuredAgentSessionTurnScope( newest = item } } - return newest && readAgentJournalTurn(newest.body)?.state === 'running' - ? { kind: 'turn', turnItemId: newest.itemId } - : AGENT_JOURNAL_THREAD_SCOPE -} - -/** Whether that newest turn is still running, which is all most callers want. */ -export function activeStructuredAgentSessionTurnIdBySequence( - items: Iterable -): string | null { - const newest = newestStructuredAgentSessionTurnBySequence(items) - return newest?.state === 'running' ? newest.turnId : null + const turn = newest ? readAgentJournalTurn(newest.body) : null + return newest && turn?.state === 'running' ? { item: newest, turnId: turn.turnId } : null } /** The newest turn record whatever state it ended in, STATE INCLUDED. Restart resume compares both diff --git a/src/shared/structured-agent-session-main-agent-working.ts b/src/shared/structured-agent-session-main-agent-working.ts index 05e3b775aaa9..ea8af7771282 100644 --- a/src/shared/structured-agent-session-main-agent-working.ts +++ b/src/shared/structured-agent-session-main-agent-working.ts @@ -1,14 +1,30 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' +/** The host's answer to which work is current, built only by its operational projection + * (`structuredAgentSessionCurrentWork`). A client has none, so every running turn, pending prompt + * and unanswered send it holds reads as current, as it always has. */ +export type StructuredAgentSessionWorkScope = { + /** Whether the live generation's execution produced this item. */ + isCurrentItem: (itemId: string) => boolean + /** Whether this send is still owed: handed to the live generation and unanswered, or accepted by + * this host and not yet handed over. */ + owesSend: (submission: AgentJournalSubmission) => boolean +} + /** Whether the session's own agent is working: a running turn, or a send it has not answered. - * The one rule behind every session list's Working and the chat's own Stop. */ + * The one rule behind every session list's Working and the chat's own Stop. A host passes the turn + * its projection scoped, and that projection's `scope`. */ export function isStructuredAgentSessionMainAgentWorking( activeTurnId: string | null, submissions: readonly AgentJournalSubmission[], - currentFence?: number | null + currentFence?: number | null, + scope?: StructuredAgentSessionWorkScope ): boolean { return ( - activeTurnId !== null || hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence) + activeTurnId !== null || + (scope + ? submissions.some(scope.owesSend) + : hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence)) ) } diff --git a/src/shared/structured-agent-session-owed-work.ts b/src/shared/structured-agent-session-owed-work.ts index fe7c9695ae73..259bbfe6877c 100644 --- a/src/shared/structured-agent-session-owed-work.ts +++ b/src/shared/structured-agent-session-owed-work.ts @@ -1,6 +1,9 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { activeStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' -import { isStructuredAgentSessionMainAgentWorking } from './structured-agent-session-main-agent-working' +import { + isStructuredAgentSessionMainAgentWorking, + type StructuredAgentSessionWorkScope +} from './structured-agent-session-main-agent-working' import { agentSessionCurrentContextRows } from './agent-session-context-clear' /** A running turn or an unanswered send: what a `working` status means, and what an `attention` @@ -8,12 +11,17 @@ import { agentSessionCurrentContextRows } from './agent-session-context-clear' export function owesStructuredAgentSessionWork( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], - currentFence?: number | null + currentFence?: number | null, + scope?: StructuredAgentSessionWorkScope ): boolean { const current = agentSessionCurrentContextRows(items, submissions) return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(current.items), + activeStructuredAgentSessionTurnId( + current.items, + scope && ((item) => scope.isCurrentItem(item.itemId)) + ), current.submissions, - currentFence + currentFence, + scope ) } diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 610b08e91670..6b66904c2759 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -39,6 +39,7 @@ import { sha256 } from './sha256' import { readAgentMessageSource } from './agent-session-message-source' import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at' import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' +import type { StructuredAgentSessionWorkScope } from './structured-agent-session-main-agent-working' import { agentSessionCurrentContextRows } from './agent-session-context-clear' // Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one @@ -214,23 +215,32 @@ export function structuredAgentSessionTabId(sessionId: string): string { return `structured-agent-session-${sessionId}` } -function isPendingStructuredAgentSessionPrompt(item: AgentJournalRenderItem): boolean { - return ( +/** A prompt waiting on the user, unless the host's scope says an ended generation raised it: + * nothing can take its answer. */ +function pendingStructuredAgentSessionPrompt( + scope?: StructuredAgentSessionWorkScope +): (item: AgentJournalRenderItem) => boolean { + return (item) => (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ) + item.body.resolution.state === 'pending' && + (!scope || scope.isCurrentItem(item.itemId)) } +/** `scope`: a host's (`StructuredAgentSessionWorkScope`), so work an ended generation left neither + * holds nor reads as Working. */ export function projectStructuredAgentSessionStatus( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [], - currentFence?: number | null + currentFence?: number | null, + scope?: StructuredAgentSessionWorkScope ): StructuredAgentSessionProjectedStatus { ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions)) - if (items.some(isPendingStructuredAgentSessionPrompt)) { + if (items.some(pendingStructuredAgentSessionPrompt(scope))) { return 'attention' } - return owesStructuredAgentSessionWork(items, submissions, currentFence) ? 'working' : 'idle' + return owesStructuredAgentSessionWork(items, submissions, currentFence, scope) + ? 'working' + : 'idle' } /** The activity fields a sidebar row shows beside the prompt, named as the agent-status @@ -269,7 +279,8 @@ export function projectStructuredAgentSessionStatusSummary( export function projectStructuredAgentSessionStatusState( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [], - currentFence?: number | null + currentFence?: number | null, + scope?: StructuredAgentSessionWorkScope ): { summary: StructuredAgentSessionStatusProjection latestRequest: StructuredAgentSessionLatestRequest | null @@ -288,7 +299,7 @@ export function projectStructuredAgentSessionStatusState( } } ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions)) - const status = projectStructuredAgentSessionStatus(items, submissions, currentFence) + const status = projectStructuredAgentSessionStatus(items, submissions, currentFence, scope) const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null const toolName = statusToolCall ? normalizeOptionalField(statusToolCall.name, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) @@ -318,10 +329,11 @@ export function projectStructuredAgentSessionStatusState( ) return { latestRequest, - owesWork: status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence), + owesWork: + status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence, scope), pendingPromptIds: status === 'attention' - ? items.filter(isPendingStructuredAgentSessionPrompt).map((item) => item.itemId) + ? items.filter(pendingStructuredAgentSessionPrompt(scope)).map((item) => item.itemId) : [], summary: { status, From 31424b0926e1603a7a071eca75ef0822e7a2dcee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:18:33 -0700 Subject: [PATCH 03/13] feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly Desktop and mobile prefer the host's working and actionablePromptIds fields when present (merged across batches like latestTurn) and fall back to today's row derivation against an older host. A prompt an ended agent raised stops looking active; answering it is refused before any provider sees it, with " has stopped, so your answer was not sent. Send a message to continue." Cancelling it only dismisses it in the journal: it no longer triggers the chat's Stop, pauses the queue or withdraws queued cards. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../MobileNativeChatQueuedMessages.test.tsx | 22 +- ...obile-structured-actionable-prompt.test.ts | 66 +++++ .../mobile-structured-actionable-prompt.ts | 33 +++ .../mobile-structured-agent-session-cancel.ts | 14 +- .../mobile-structured-agent-session-rpc.ts | 11 +- .../use-mobile-native-chat-turn-disclosure.ts | 3 +- .../use-mobile-structured-agent-mutation.ts | 9 +- ...tured-agent-session-prompt-cancel.test.tsx | 16 ++ .../use-mobile-structured-agent-session.ts | 38 ++- ...use-mobile-structured-agent-turn-timing.ts | 7 +- .../use-mobile-structured-prompt-responses.ts | 10 +- ...use-mobile-structured-send-with-outcome.ts | 11 +- .../agent-session-stop-note-refresh.test.ts | 49 +++- ...uctured-agent-session-current-work.test.ts | 76 +++--- ...-agent-session-ended-prompt-cancel.test.ts | 226 ++++++++++++++++++ .../structured-agent-session-prompt-cancel.ts | 44 ++++ .../structured-agent-session-prompt-state.ts | 44 ++++ .../structured-agent-session-turns-prompt.ts | 13 +- .../native-chat/NativeChatMessageList.tsx | 30 +-- .../NativeChatResolutionReceipt.test.tsx | 16 ++ .../NativeChatResolutionReceipt.tsx | 12 +- .../NativeChatStructuredSession.tsx | 1 + .../native-chat/NativeChatTranscriptRow.tsx | 12 +- .../agent-session-failure-words-text.ts | 14 +- .../agent-session-provider-exit-words-text.ts | 31 +++ .../agent-session-write-notice-text.ts | 10 +- .../native-chat-transcript-slots.ts | 6 +- ...d-agent-session-actionable-prompts.test.ts | 57 +++++ ...ctured-agent-session-message-projection.ts | 9 +- ...uctured-agent-session-queued-cards.test.ts | 7 + .../structured-attention-read-observation.ts | 6 +- .../use-structured-agent-session-mutate.ts | 25 +- ...tructured-agent-session-transport-state.ts | 31 ++- ...t-session-transport-state.working.test.tsx | 153 ++++++++++++ .../use-structured-agent-session-transport.ts | 7 +- .../use-structured-agent-session.ts | 10 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-failure-copy.ts | 2 + .../agent-session-failure-words.test.ts | 8 +- src/shared/agent-session-failure-words.ts | 14 +- src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-notice.test.ts | 43 +++- src/shared/agent-session-refusal-notice.ts | 10 +- .../agent-session-refusal-reason-words.ts | 3 + ...-messages-waiting-behind-live-turn.test.ts | 52 ++++ ...-chat-messages-waiting-behind-live-turn.ts | 24 +- src/shared/native-chat-turn-membership.ts | 2 + .../structured-agent-session-coalescer.ts | 15 +- .../structured-agent-session-live-turn.ts | 64 +++++ .../structured-agent-session-reducer.test.ts | 74 +++++- .../structured-agent-session-reducer.ts | 23 +- 58 files changed, 1323 insertions(+), 150 deletions(-) create mode 100644 mobile/src/session/mobile-structured-actionable-prompt.test.ts create mode 100644 mobile/src/session/mobile-structured-actionable-prompt.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts create mode 100644 src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx create mode 100644 src/shared/native-chat-messages-waiting-behind-live-turn.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 22cc03d9be32..a6de0c69b638 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -113,6 +113,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b01a0fa66c18..24f78f532ba2 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' import type { ActionSheetAction } from '../components/ActionSheetModal' import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' -import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' +import { + mobileQueuedMessageCards, + type MobileQueuedMessageCard +} from './mobile-structured-queued-message-cards' vi.mock('react-native', () => ({ Pressable: 'Pressable', @@ -209,6 +212,23 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it('a waiting card still reads Steer while nothing runs, as when its agent died and its cleanup waits', async () => { + const [waiting] = mobileQueuedMessageCards( + [ + { + messageId: 'waiting', + position: 1, + state: 'waiting', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } + } + ], + [], + { agentWorking: false } + ) + const mounted = await mount({ cards: [waiting!], onSend: vi.fn(async () => true) }) + expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual(['Steer']) + }) + it('a command card never steers: Send only while the agent is idle, and no menu', async () => { const onSend = vi.fn(async () => true) const mounted = await mount({ diff --git a/mobile/src/session/mobile-structured-actionable-prompt.test.ts b/mobile/src/session/mobile-structured-actionable-prompt.test.ts new file mode 100644 index 000000000000..c170bc0e50ca --- /dev/null +++ b/mobile/src/session/mobile-structured-actionable-prompt.test.ts @@ -0,0 +1,66 @@ +// The phone answers only the prompts the host says still wait on the person: one an agent that +// has ended raised gets no card, and an older host, which names none, keeps today's rule. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import { + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' + +const PENDING = { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} + +function approval(itemId: string, sequence: number): AgentJournalRenderItem { + return { + itemId, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: PENDING + } + } +} + +function question(itemId: string, sequence: number): AgentJournalRenderItem { + return { + itemId, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'question', + question: 'Which one?', + options: [{ id: 'a', label: 'A' }], + resolution: PENDING + } + } +} + +const ITEMS = [approval('dead-approval', 1), question('dead-question', 2)] + +describe('the prompt a phone answers', () => { + it('skips one the host does not name, and answers one it does', () => { + const items = [...ITEMS, approval('live-approval', 3)] + expect( + actionableStructuredApproval({ items, actionablePromptIds: ['live-approval'] })?.itemId + ).toBe('live-approval') + expect( + actionableStructuredQuestion({ items, actionablePromptIds: ['live-approval'] }) + ).toBeNull() + }) + + it('is the first pending one when an older host names none', () => { + expect(actionableStructuredApproval({ items: ITEMS })?.itemId).toBe('dead-approval') + expect(actionableStructuredQuestion({ items: ITEMS })?.itemId).toBe('dead-question') + }) +}) diff --git a/mobile/src/session/mobile-structured-actionable-prompt.ts b/mobile/src/session/mobile-structured-actionable-prompt.ts new file mode 100644 index 000000000000..478927a9088e --- /dev/null +++ b/mobile/src/session/mobile-structured-actionable-prompt.ts @@ -0,0 +1,33 @@ +import { isActionableStructuredAgentSessionPrompt } from '../../../src/shared/structured-agent-session-live-turn' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { + pendingStructuredApproval, + pendingStructuredQuestion, + type StructuredApprovalItem, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' + +type PromptState = Pick + +/** The approval a person can answer: pending, and named by the host as waiting when it names them + * (`isActionableStructuredAgentSessionPrompt`): one an agent that ended raised is not. */ +export function actionableStructuredApproval(state: PromptState): StructuredApprovalItem | null { + return ( + state.items.find( + (item): item is StructuredApprovalItem => + pendingStructuredApproval(item) && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) + ) ?? null + ) +} + +/** The question a person can answer, by the same rule. */ +export function actionableStructuredQuestion(state: PromptState): StructuredQuestionItem | null { + return ( + state.items.find( + (item): item is StructuredQuestionItem => + pendingStructuredQuestion(item) && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) + ) ?? null + ) +} diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 49232afeb326..457a960c26cc 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -1,7 +1,9 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' -import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' -import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { + isActionableStructuredAgentSessionPrompt, + runningStructuredAgentSessionTurnId +} from '../../../src/shared/structured-agent-session-live-turn' import type { RpcClient } from '../transport/rpc-client' import { requestStructuredAgentSessionMutation, @@ -10,12 +12,14 @@ import { type PromptIdentity = { itemId: string; expectedRevision: number } +/** The prompt a Stop cancels: one the host says still waits on the person, when it says. */ export function pendingStructuredPromptIdentity( - items: readonly AgentJournalRenderItem[] + state: Pick ): PromptIdentity | undefined { - const prompt = items.find((item) => + const prompt = state.items.find((item) => item.body.kind === 'approval' || item.body.kind === 'question' - ? item.body.resolution.state === 'pending' + ? item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, state.actionablePromptIds) : false ) return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7b2da967d56c..4802042435b1 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -127,6 +127,8 @@ export async function requestStructuredAgentSessionMutation(args: { fields: Record clientOperationId?: string timeoutMs?: number + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): Promise> { const { client, @@ -136,8 +138,10 @@ export async function requestStructuredAgentSessionMutation(args: { expectedRuntimeFence, fields, clientOperationId, - timeoutMs + timeoutMs, + agentName } = args + const context = agentName ? { agentName } : {} try { const result = await callAgentSession>( client, @@ -171,7 +175,8 @@ export async function requestStructuredAgentSessionMutation(args: { code: result.refusal.code, message: agentSessionRefusalNotice( result.refusal, - phoneWriteKind(fingerprintMethod, fields) + phoneWriteKind(fingerprintMethod, fields), + context ) } } catch (error) { @@ -184,7 +189,7 @@ export async function requestStructuredAgentSessionMutation(args: { return { status: 'failed', message: agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields)) + agentSessionWriteNoticeParts(answered, phoneWriteKind(fingerprintMethod, fields), context) ) } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index ed93bda5924a..f75782b657aa 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -145,7 +145,8 @@ export function useMobileNativeChatTurnDisclosure({ rows, turnJournal?.items, stopping, - turnJournal?.submissions + turnJournal?.submissions, + turnJournal ?? {} ) : null if (!ids?.size) { diff --git a/mobile/src/session/use-mobile-structured-agent-mutation.ts b/mobile/src/session/use-mobile-structured-agent-mutation.ts index 4eb8a203121b..d8a6a30fb7bf 100644 --- a/mobile/src/session/use-mobile-structured-agent-mutation.ts +++ b/mobile/src/session/use-mobile-structured-agent-mutation.ts @@ -22,8 +22,10 @@ export function useMobileStructuredAgentMutate(args: { enabled: boolean stateRef: { readonly current: StructuredAgentSessionState } onSendError: (message: string) => void + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): MobileStructuredAgentMutate { - const { client, enabled, onSendError, sessionId, stateRef } = args + const { agentName, client, enabled, onSendError, sessionId, stateRef } = args return useCallback( async ( method: string, @@ -41,7 +43,8 @@ export function useMobileStructuredAgentMutate(args: { fingerprintMethod, sessionId, expectedRuntimeFence: targetFence, - fields + fields, + ...(agentName ? { agentName } : {}) }) if (result.status === 'accepted') { return { @@ -56,6 +59,6 @@ export function useMobileStructuredAgentMutate(args: { onSendError(result.message) return { status: 'rejected' } }, - [client, enabled, onSendError, sessionId, stateRef] + [agentName, client, enabled, onSendError, sessionId, stateRef] ) } diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 7a14895fd66e..7b26f7003784 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -266,6 +266,22 @@ describe('mobile structured prompt cancellation', () => { expect(hook.turnId).toBeNull() }) + it("reads Working from the host's answer, and from its rows only when an older host gives none", () => { + // A turn row an agent that has since ended left running: the host says it is not working. + state = { ...state, items: [runningTurn()], working: false } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.isWorking).toBe(false) + act(() => renderer?.unmount()) + const { working: _answered, ...olderHost } = state + state = olderHost + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.isWorking).toBe(true) + }) + it('uses the rendered prompt identity when the journal changes before tap', async () => { act(() => { renderer = create(createElement(Harness, { promptCancelSupported: true })) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ceb172306486..c0e4512a81d2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -7,12 +7,15 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/st import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { isStructuredAgentSessionThinking, - runningStructuredAgentSessionTurnId + runningStructuredAgentSessionTurnId, + structuredAgentSessionHostSaysWorking } from '../../../src/shared/structured-agent-session-live-turn' import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { - pendingStructuredApproval, - pendingStructuredQuestion, + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' +import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' @@ -88,7 +91,8 @@ export function useMobileStructuredAgentSession(args: { sessionId, enabled, stateRef, - onSendError + onSendError, + ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }) const options = useMobileStructuredAgentOptions({ @@ -157,7 +161,10 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state) - const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + // The host's answer counts only the agent running now; an older host gives none. + const isWorking = structuredAgentSessionHostSaysWorking(state.working, () => + isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + ) const backgroundTasks = useMobileStructuredBackgroundTasks({ sessionKey, state, @@ -196,13 +203,22 @@ export function useMobileStructuredAgentSession(args: { [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state.items] ) const status = state.status === 'idle' ? 'idle' : state.status + // The host's answer where it gives one: a prompt an agent that ended raised takes no answer. const approvalPrompt = useMemo( - () => state.items.find(pendingStructuredApproval) ?? null, - [state.items] + () => + actionableStructuredApproval({ + items: state.items, + actionablePromptIds: state.actionablePromptIds + }), + [state.items, state.actionablePromptIds] ) const questionPrompt = useMemo( - () => state.items.find(pendingStructuredQuestion) ?? null, - [state.items] + () => + actionableStructuredQuestion({ + items: state.items, + actionablePromptIds: state.actionablePromptIds + }), + [state.items, state.actionablePromptIds] ) // What a refused command's line on the phone stands on. const commandRefusalCauses = useMemo( @@ -219,7 +235,7 @@ export function useMobileStructuredAgentSession(args: { queuedMessages, queuePause, submissions: state.submissions, - agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + agentWorking: isWorking, mutate, appendComposerText, onSendError, @@ -278,7 +294,7 @@ export function useMobileStructuredAgentSession(args: { void stopPress.track(() => requestCancel()) }, cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => - requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), + requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current)), permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index a701cdbc21d1..d23bb936c545 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -21,11 +21,14 @@ export function useMobileStructuredAgentTurnTiming( items, submissions, latestTurn, + working, hostClock }: { items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] latestTurn?: AgentSessionLatestTurn | null + /** The host's working answer, which places rows waiting behind the live turn. */ + working?: boolean hostClock?: StructuredAgentHostClock | null }, turnId: string | null @@ -40,8 +43,8 @@ export function useMobileStructuredAgentTurnTiming( [items, submissions, turnId, latestTurn] ) const turnJournal = useMemo( - () => ({ items, submissions, latestTurn }), - [items, submissions, latestTurn] + () => ({ items, submissions, latestTurn, ...(working !== undefined ? { working } : {}) }), + [items, submissions, latestTurn, working] ) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index 093029b2be52..eb2a0b38caa1 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -8,8 +8,10 @@ import { } from '../../../src/shared/agent-session-question-answer' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { - pendingStructuredApproval, - pendingStructuredQuestion, + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' +import { structuredApprovalResponseTarget, structuredQuestionResponseTarget } from './mobile-structured-agent-prompts' @@ -54,7 +56,7 @@ export function useMobileStructuredPromptResponses(args: { async (optionId: string): Promise => { const target = structuredApprovalResponseTarget( optionId, - stateRef.current.items.find(pendingStructuredApproval) ?? null + actionableStructuredApproval(stateRef.current) ) if (!target) { return false @@ -107,7 +109,7 @@ export function useMobileStructuredPromptResponses(args: { const respondQuestion = useCallback( async (answer: string): Promise => { - const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null + const prompt = actionableStructuredQuestion(stateRef.current) if (prompt?.body.questions) { const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision) const grouped = advanceGroupedQuestion({ diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index c8fd8a74b884..16106fc5bbbd 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -19,9 +19,9 @@ import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { - pendingStructuredApproval, - pendingStructuredQuestion -} from './mobile-structured-agent-prompts' + actionableStructuredApproval, + actionableStructuredQuestion +} from './mobile-structured-actionable-prompt' /** Whether a send made now asks the host to queue it. The host's queue waits on any pending prompt; * one this build cannot answer would hold the send forever, so it starts a turn instead. */ @@ -100,9 +100,8 @@ export function useMobileStructuredSendWithOutcome(args: { pending: commandPending, controller: { agent: agent ?? undefined, ...controller }, busy: () => - stateRef.current.items.some( - (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) - ) + actionableStructuredApproval(stateRef.current) || + actionableStructuredQuestion(stateRef.current) ? 'prompt' : runningStructuredAgentSessionTurnId(stateRef.current) ? 'working' diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts index 7a3e6864096c..1f22d20e6d23 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts @@ -1,10 +1,19 @@ // The stop-note refresh frame carries rows, so it restates the host's current work as every rows -// frame does: a client reads a rows frame without it as an older host's. +// frame does: a client reads a rows frame without it as an older host's and falls back to its own +// raw derivation (an ended turn as Working, an ended agent's prompt as answerable). import { 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 { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from '../../../shared/structured-agent-session-reducer' +import { isActionableStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' @@ -46,8 +55,31 @@ const projectedNote: AgentJournalRenderItem = { } const items = [staleTurn, stalePrompt, projectedNote] +/** What a current host publishes: the ended generation's turn and prompt are not current. */ +function page(): AgentSessionHistoryPage { + return { + sessionId: 's', + epoch: 'e', + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'e', sequence: 1 }, + newest: { epoch: 'e', sequence: 3 }, + nextCursor: { epoch: 'e', sequence: 1 } + }, + liveCursor: { epoch: 'e', sequence: 3 }, + hasOlder: false, + hasNewer: false, + latestTurn: null, + actionablePromptIds: [], + working: false + } +} + describe('the stop-note refresh frame', () => { - it("restates the host's current work", () => { + it("restates the host's current work, so a client keeps the host's answers", () => { const emitted: AgentSessionSubscribeEvent[] = [] const journal = { visitItems: (visit: (itemId: string, sequence: number, body: unknown) => void) => @@ -93,5 +125,16 @@ describe('the stop-note refresh frame', () => { } expect(frame.batch.items.length).toBeGreaterThan(0) expect(frame).toMatchObject({ working: false, actionablePromptIds: [], latestTurn: null }) + + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 's', page: page(), fence: 2 } + }) + state = reduceStructuredAgentSession(state, { type: 'event', event: frame }) + expect(state.working).toBe(false) + expect(state.latestTurn).toBeNull() + expect(isActionableStructuredAgentSessionPrompt('prompt-old', state.actionablePromptIds)).toBe( + false + ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts index bdd580d7dc96..d5cebdf77ab5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts @@ -1,17 +1,23 @@ // The host's one projection of current work (`structuredAgentSessionCurrentWork`), end to end // against the real host, store and SQLite journal: what an ended generation left holds nothing and -// reads as nothing to every host reader (the projection, the session list, the frames and pages a -// chat client is sent, Stop, the queue), whether or not its cleanup landed, while a live owner's -// work holds exactly as before. +// reads as nothing to every reader (the projection, the session list, a chat client, Stop, a prompt +// answer, the queue), whether or not its cleanup or its release landed, while a live owner's work +// holds exactly as before. import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { + isActionableStructuredAgentSessionPrompt, + runningStructuredAgentSessionTurnId +} from '../../../shared/structured-agent-session-live-turn' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' import { liveTestJournalRows, openTestJournalHostDatabase @@ -49,26 +55,15 @@ const FOLDER: AgentSessionExecutionLocation = { workspaceKind: 'folder' } -type HostWork = Pick< - Extract, - 'latestTurn' | 'actionablePromptIds' | 'working' -> - -/** A chat subscriber and the session list, each fed by the host as a real one is: the subscriber - * keeps the current-work fields of the newest frame or page that stated them. */ +/** A chat client and the session list, each fed by the host as a real one is. */ function watch(current: QueuedMessageTestRig) { - let work: HostWork = {} + let chat: StructuredAgentSessionState = EMPTY_STRUCTURED_AGENT_SESSION let listed: AgentSessionStatusSummary | undefined current.host.subscribe({ id: 'chat-1', sessionId: SESSION, emit: (event) => { - const fields = - event.type === 'snapshot' ? event.page : event.type === 'batch' ? event : undefined - if (fields?.working !== undefined) { - const { latestTurn, actionablePromptIds, working } = structuredClone(fields) - work = { latestTurn, actionablePromptIds, working } - } + chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) } }) current.host.subscribeStatus({ @@ -81,7 +76,19 @@ function watch(current: QueuedMessageTestRig) { } } }) - return { work: () => work, listed: () => listed } + return { + chat: () => chat, + listed: () => listed, + /** Whether the chat would let the person answer the prompt. */ + answerable: () => + chat.items.some( + (item) => + item.itemId === APPROVAL_ID && + item.body.kind === 'approval' && + item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, chat.actionablePromptIds) + ) + } } /** The tail page a client hydrates from. */ @@ -126,11 +133,9 @@ describe("a dead generation's leftovers, its settlement refused and its lease re // Read interrupted, as its settlement will write it: never running, never finished. expect(page.latestTurn?.turn.state).toBe('interrupted') expect(page.actionablePromptIds).toEqual([]) - expect(page.working).toBe(false) - await vi.waitFor(() => - expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) - ) - expect(view.work().latestTurn?.turn.state).not.toBe('running') + await vi.waitFor(() => expect(view.chat().actionablePromptIds).toEqual([])) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBeNull() + expect(view.answerable()).toBe(false) }) it('gives Stop nothing to stop: the dead turn is not touched', async () => { @@ -150,7 +155,7 @@ describe('a close Orca asked for, its settlement refused', () => { await current.workingSend() await leaveUnfinishedWork(current, { prompt: true }) const view = watch(current) - await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) + await vi.waitFor(() => expect(view.answerable()).toBe(true)) const database = openTestJournalHostDatabase(current.root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) database.db.exec(REJECT_RECOVERED_ROWS) @@ -172,10 +177,8 @@ describe('a close Orca asked for, its settlement refused', () => { expect(currentJournal(current).cursor().sequence).toBe(before) expect(turnState(current)).toBe('running') - await vi.waitFor(() => - expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false }) - ) - expect(view.work().latestTurn?.turn.state).not.toBe('running') + await vi.waitFor(() => expect(view.answerable()).toBe(false)) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBeNull() expect(view.listed()?.status).not.toBe('working') }) }) @@ -239,12 +242,9 @@ describe('a live owner', () => { const page = await hydration(current) expect(page.latestTurn?.turn).toMatchObject({ turnId: 'unfinished', state: 'running' }) expect(page.actionablePromptIds).toEqual([APPROVAL_ID]) - expect(page.working).toBe(true) - await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID])) - expect(view.work()).toMatchObject({ - working: true, - latestTurn: { turn: { turnId: 'unfinished', state: 'running' } } - }) + await vi.waitFor(() => expect(view.chat().actionablePromptIds).toEqual([APPROVAL_ID])) + expect(runningStructuredAgentSessionTurnId(view.chat())).toBe('unfinished') + expect(view.answerable()).toBe(true) expect(view.listed()?.status).not.toBe('idle') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts new file mode 100644 index 000000000000..351d48db55d8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts @@ -0,0 +1,226 @@ +// A Cancel that names a card an ended generation raised, from a client that still shows it. As on +// main: the card is dismissed in the journal alone, and a provider whose card Cancel routes +// (Claude, Pi) stops nothing. One with no route gets the turn's own cancel with the card, never the +// chat's Stop, so queued messages stay and no Stop event pauses the queue: ACP interrupts the live +// turn the request names; Codex finds no such card in its live child and interrupts nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudePromptCancelRoute } from '../../claude/claude-structured-prompt-replies' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} +const ENDED = 1 +const LIVE = 2 + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +/** Generation 1 raised a card and ended without its cleanup; generation 2 runs `turn-2`, with a + * message queued behind it. */ +async function endedCardAndLiveTurn( + kind: 'approval' | 'question' = 'approval' +): Promise<{ journal: AgentSessionJournal; itemId: string }> { + root = await mkdtemp(join(tmpdir(), 'orca-ended-prompt-cancel-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const turn = (turnId: string, fence: number) => + journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state: 'running', startedAt: fence }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const old = await turn('turn-1', ENDED) + const pending = { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + const card = await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, + kind === 'approval' + ? { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: pending + } + : { + kind: 'question', + question: 'Which?', + options: [{ id: 'a', label: 'A' }], + resolution: pending + }, + { fence: ENDED, turnScope: { kind: 'turn', turnItemId: old.itemId } } + ) + await turn('turn-2', LIVE) + await journal.queuedMessages.insert({ + messageId: 'queued-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after this' }] }, + fingerprint: 'queued-1', + hostInstance: 'host-1' + }) + return { journal, itemId: card.itemId } +} + +/** The queue as the live turn left it: its message waiting, and nothing pausing it. */ +function queueUntouched(journal: AgentSessionJournal): void { + expect(journal.queuedMessages.list().map((card) => [card.messageId, card.state])).toEqual([ + ['queued-1', 'waiting'] + ]) + expect(structuredQueuePauses(journal)).toEqual([]) +} + +function provider(routePromptCancel?: StructuredAgentSessionAdapter['routePromptCancel']) { + return { + cancelTurn: vi.fn(async (_input: { turnId?: string; prompt?: unknown }) => ({ + cancelled: true + })), + dismissPrompt: vi.fn(async () => undefined), + ...(routePromptCancel ? { routePromptCancel } : {}) + } +} + +function context( + journal: AgentSessionJournal, + adapter: ReturnType +): AgentSessionTurnContext { + return { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: LIVE, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a Cancel reaches only cancelTurn, dismissPrompt and routePromptCancel, all supplied. + adapter: adapter as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } +} + +function chatStop() { + return vi.fn(async (): Promise => ({ + outcome: { ok: true, value: { turnId: 'turn-2', cancelled: true } }, + endsSession: false + })) +} + +function resolution(journal: AgentSessionJournal, itemId: string) { + const body = journal.snapshot().items.find((item) => item.itemId === itemId)?.body + return body?.kind === 'approval' || body?.kind === 'question' ? body.resolution.state : undefined +} + +describe('a card an ended generation raised, cancelled from a client that still shows it', () => { + const pi = () => ({ kind: 'dismiss' as const }) + it.each([ + ['Claude approval', 'approval', claudePromptCancelRoute], + ['Claude question', 'question', claudePromptCancelRoute], + ['Pi (every card dismisses)', 'approval', pi] + ] as const)( + '%s: dismissed only, even when the request names the live turn', + async (_name, kind, route) => { + const { journal, itemId } = await endedCardAndLiveTurn(kind) + const agent = provider(route) + const stop = chatStop() + const interrupt = vi.fn() + + const result = await cancelStructuredAgentSessionPrompt( + context(journal, agent), + { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } }, + { stop, interrupt } + ) + + expect(result).toMatchObject({ ok: true, value: { turnId: 'turn-2', cancelled: true } }) + expect(resolution(journal, itemId)).toBe('cancelled') + expect(stop).not.toHaveBeenCalled() + expect(interrupt).not.toHaveBeenCalled() + expect(agent.cancelTurn).not.toHaveBeenCalled() + expect(agent.dismissPrompt).not.toHaveBeenCalled() + queueUntouched(journal) + } + ) + + it.each([ + ['ACP: the named live turn is interrupted', true], + ['Codex: its live child holds no such card, so nothing is interrupted, as on main', false] + ] as const)("no card route, through the turn's own cancel. %s", async (_name, interrupts) => { + const { journal, itemId } = await endedCardAndLiveTurn() + const agent = provider() + agent.cancelTurn.mockResolvedValueOnce({ cancelled: interrupts }) + const stop = chatStop() + const ctx = context(journal, agent) + const input = { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } } + + const result = await cancelStructuredAgentSessionPrompt(ctx, input, { + stop, + // What the host's plan runs: the turn's cancel, with the card, as on main. + interrupt: () => performCancel(ctx, { clientOperationId: 'cancel-1', ...input }) + }) + + expect(result).toMatchObject({ ok: true, value: { cancelled: interrupts } }) + expect(agent.cancelTurn).toHaveBeenCalledOnce() + expect(agent.cancelTurn.mock.calls[0]?.[0]).toMatchObject({ + turnId: 'turn-2', + prompt: { itemId } + }) + expect(stop).not.toHaveBeenCalled() + expect(resolution(journal, itemId)).toBe('cancelled') + queueUntouched(journal) + }) + + it.each([ + ['naming no turn', undefined], + ["naming the ended generation's turn", 'turn-1'] + ])('Codex or ACP, %s: dismissed only, and the live turn runs on', async (_name, turnId) => { + const { journal, itemId } = await endedCardAndLiveTurn() + const agent = provider() + const stop = chatStop() + const interrupt = vi.fn() + + const result = await cancelStructuredAgentSessionPrompt( + context(journal, agent), + { ...(turnId ? { turnId } : {}), prompt: { itemId, expectedRevision: 1 } }, + { stop, interrupt } + ) + + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(resolution(journal, itemId)).toBe('cancelled') + expect(interrupt).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + expect(agent.cancelTurn).not.toHaveBeenCalled() + queueUntouched(journal) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts index a92a9d036beb..aaf7885b6326 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts @@ -10,6 +10,8 @@ import { AgentSessionPromptUnavailableError } from './structured-agent-session-a import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' import { answerCancelOfSettledPrompt, + dismissEndedGenerationPrompt, + isEndedGenerationPrompt, validatePendingPrompt, type PendingPromptValidation } from './structured-agent-session-prompt-state' @@ -35,6 +37,9 @@ export async function cancelStructuredAgentSessionPrompt( sessionId: ctx.sessionId, prompt: validated.prompt }) + if (isEndedGenerationPrompt(ctx, validated)) { + return cancelEndedGenerationPrompt(ctx, input, validated, route ? null : routes.interrupt) + } if (!route) { return routes.interrupt() } @@ -64,6 +69,45 @@ export async function cancelStructuredAgentSessionPrompt( return dismissed.ok ? cancelled : dismissed } +/** A card an ended generation raised: no live provider holds it, so it is dismissed in the journal + * alone. As on main, a provider whose card Cancel routes (a dismissal, or a Stop of the turn that + * raised it) stops nothing, since the live turn did not raise it. One with no route gets the + * named live turn's own cancel with the card, as main's plan does, never the chat's Stop, so + * queued messages stay and the queue is not paused: ACP interrupts that turn, and Codex, finding + * no such card in its live child, interrupts nothing. */ +async function cancelEndedGenerationPrompt( + ctx: AgentSessionTurnContext, + input: { turnId?: string }, + pending: PendingPrompt, + interrupt: (() => Promise) | null +): Promise { + const liveTurnId = contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() + const interrupted = + interrupt && input.turnId !== undefined && input.turnId === liveTurnId + ? await interrupt() + : null + if (interrupted && !interrupted.ok) { + return interrupted + } + // The interrupt's provider may have let go of it already; what is still pending is dismissed. + const still = validatePendingPrompt(ctx, { + itemId: pending.item.itemId, + expectedRevision: pending.item.revision + }) + if (still.ok) { + const dismissed = await dismissEndedGenerationPrompt(ctx, still) + if (!dismissed.ok) { + return dismissed + } + } + return ( + interrupted ?? { + ok: true, + value: { ...(input.turnId ? { turnId: input.turnId } : {}), cancelled: true } + } + ) +} + function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): boolean { const live = contextStructuredAgentSessionCurrentWork(ctx).turnScope() const raised = pending.item.turnScope diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 8cc9b7e4bc92..288e99521eed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -7,7 +7,9 @@ import { type AgentSessionCancelResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' type PendingPromptBody = Extract @@ -91,3 +93,45 @@ export function validatePendingPrompt( } return { ok: true, item, prompt } } + +/** A pending prompt an ended generation raised (`StructuredAgentSessionCurrentWork.isCurrentItem`): + * no live provider holds it, so a Cancel of it reaches no provider. */ +export function isEndedGenerationPrompt( + ctx: Pick, + pending: Extract +): boolean { + return !contextStructuredAgentSessionCurrentWork(ctx).isCurrentItem(pending.item.itemId) +} + +/** Records an ended generation's prompt as cancelled by the caller, in the journal only. */ +export async function dismissEndedGenerationPrompt( + ctx: Pick, + pending: Extract +): Promise> { + const identity = parseAgentJournalItemKey(pending.item.itemId) + if (!identity) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `Item id ${pending.item.itemId} is not a well-formed item key.` + ) + } + } + await ctx.journal.appendItem( + identity, + { + ...pending.prompt, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + }, + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } + ) + return { ok: true, value: null } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index b7b21c767d6f..5b8f090b22ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -100,6 +100,14 @@ export async function performPrompt( return held ? { ok: true, value: held } : validated } const { prompt } = validated + const work = contextStructuredAgentSessionCurrentWork(ctx) + if (!work.isCurrentItem(input.itemId)) { + // The agent that asked has ended: an answer would reach whichever agent runs now, or none. + return invalid( + 'promptOwnerEnded', + `Item ${input.itemId} was raised by an agent that has ended.` + ) + } const choice = readPromptChoice(prompt, input) if (!choice) { return invalid( @@ -135,10 +143,7 @@ export async function performPrompt( identity, { ...prompt, resolution }, // A revision: the prompt keeps the turn it was raised in. - { - fence: ctx.fence, - turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() - }, + { fence: ctx.fence, turnScope: work.turnScope() }, // The answer's identity commits here, before the adapter tells the provider. ctx.operationReceipt ) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 3ae1545874c1..10b3e7414e31 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -27,6 +27,7 @@ import { NativeChatWaitingTranscriptItems } from './NativeChatTranscriptItems' import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' +import type { StructuredAgentSessionHostWork } from './use-structured-agent-session-transport-state' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { splitNativeChatSlotsWaitingBehindLiveTurn, @@ -67,7 +68,7 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe * `unshown`: it cannot (the prompt is only in the agent's terminal). */ export type NativeChatAwaitingInput = 'shown' | 'unshown' -type NativeChatNavigationRequest = +type NavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } | { kind: 'rail'; messageId: string; requestId: number } @@ -77,6 +78,7 @@ export function NativeChatMessageList({ journalItems, journalSubmissions, journalLatestTurn: latestTurn, + journalHostWork: hostWork, subagentRoster, railOutline = null, isVisible = true, @@ -99,6 +101,8 @@ export function NativeChatMessageList({ journalSubmissions?: readonly AgentJournalSubmission[] /** The host's newest turn record, which places a live turn whose record is not loaded. */ journalLatestTurn?: AgentSessionLatestTurn | null + /** The host's prompts still waiting on the person and its working answer (an older host: none). */ + journalHostWork?: StructuredAgentSessionHostWork /** Every subagent the session's rosters named, whether or not its roster row is loaded. */ subagentRoster?: Parameters[2] /** User messages older than the loaded window, from the host's outline. */ @@ -121,9 +125,7 @@ export function NativeChatMessageList({ stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { - const [navigationRequest, setNavigationRequest] = useState( - null - ) + const [navigationRequest, setNavigationRequest] = useState(null) const navigationSequence = useRef(0) const revealedDiff = navigationRequest?.kind === 'diff' ? navigationRequest.target : null const railJump = navigationRequest?.kind === 'rail' ? navigationRequest : null @@ -223,9 +225,10 @@ export function NativeChatMessageList({ allSlots, journalItems, stopping, - journalSubmissions + journalSubmissions, + { latestTurn, ...hostWork } ), - [allSlots, journalItems, stopping, journalSubmissions] + [allSlots, journalItems, stopping, journalSubmissions, latestTurn, hostWork] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, @@ -267,11 +270,8 @@ export function NativeChatMessageList({ }) const requestRailJump = useCallback((item: NativeChatRailItem) => { navigationSequence.current += 1 - setNavigationRequest({ - kind: 'rail', - messageId: item.id, - requestId: navigationSequence.current - }) + const requestId = navigationSequence.current + setNavigationRequest({ kind: 'rail', messageId: item.id, requestId }) }, []) const railHistoryJump = useNativeChatRailHistoryJump({ items: rail.items, @@ -304,10 +304,8 @@ export function NativeChatMessageList({ openSubagentSections(target.subagentSections) } navigationSequence.current += 1 - setNavigationRequest({ - kind: 'diff', - target: { ...target, requestId: navigationSequence.current } - }) + const requestId = navigationSequence.current + setNavigationRequest({ kind: 'diff', target: { ...target, requestId } }) }, [beginNavigation, readerLeavesEnd, openSubagentSections] ) @@ -333,6 +331,7 @@ export function NativeChatMessageList({ const rowContext = useMemo( () => ({ agentName: structuredAgentLabel(session.agent), + actionablePromptIds: hostWork?.actionablePromptIds, expandSignal, revealedDiff, taskListPredecessors, @@ -348,6 +347,7 @@ export function NativeChatMessageList({ }), [ session.agent, + hostWork, allowFileUriLinks, expandSignal, expandedTurnIds, diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index 72f1f62c21d3..00c9c4b94e05 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -218,6 +218,22 @@ describe('resolution receipts', () => { expect(screen.queryByText('1 grouped question from Claude')).toBeNull() }) + it('reports, without breathing, a question whose agent ended (the host says nobody waits)', () => { + const body: AgentJournalQuestionItem = { + kind: 'question', + question: 'Which branch?', + options: [], + resolution: { ...approval.resolution, state: 'pending', selectedOptionId: null } + } + + const { rerender } = render() + expect(screen.getByText('Awaiting user input:')).toHaveClass('animate-pulse') + rerender() + expect(screen.queryByText('Awaiting user input:')).toBeNull() + expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse') + expect(screen.getByText('Which branch?')).toBeInTheDocument() + }) + it('keeps an opened question open once it is answered', () => { const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth') Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx index f3e585ae5f9f..4d65f6400d1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx @@ -9,11 +9,15 @@ import { export function NativeChatResolutionReceipt({ body, - disclosureId + disclosureId, + awaiting = true }: { body: NativeChatResolvedPrompt /** Message this receipt stands in for; keys the question row's disclosure. */ disclosureId?: string + /** False when the host says nobody waits on this pending prompt: the agent that raised it ended, + * so its row reports what was asked instead of breathing. */ + awaiting?: boolean }): React.JSX.Element | null { const askDisclosureKey = disclosureId === undefined ? undefined : `ask:${disclosureId}` const subject: NativeChatAskRowSubject | null = @@ -35,7 +39,11 @@ export function NativeChatResolutionReceipt({ } if (body.resolution.state === 'pending') { return body.kind === 'question' ? ( - + ) : null } const { resolution } = body diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b3efd9fa4c65..5d91e057be31 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -294,6 +294,7 @@ export function NativeChatStructuredSession( journalItems={controller.journalItems} journalSubmissions={controller.submissions} journalLatestTurn={controller.latestTurn} + journalHostWork={controller.hostWork} subagentRoster={controller.subagentRoster} railOutline={controller.railOutline} isVisible={props.isVisible} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 446deba8da5c..aac778310a4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -14,11 +14,14 @@ import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { NativeChatRewindContext } from './native-chat-rewind-context' import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' /** Everything a row needs that is the same for every row. Held as one memoized * object so a row's props change only when that row's own slot does. */ export type NativeChatTranscriptRowContext = { agentName?: string + /** The prompts the host says still wait on the person; absent from an older host. */ + actionablePromptIds?: readonly string[] | undefined expandSignal: boolean revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap @@ -98,7 +101,14 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ {/* A turn with no user bubble carries its bar above its first row. */} {slot.statusAbove ? statusRow : null} {!slot.drawsMessage ? null : receipt ? ( - + ) : ( string> = @@ -148,18 +149,7 @@ const PIECES: Record - translate( - 'components.native-chat.failureWords.providerExitedRow', - COPY.providerExitedRow, - values - ), - providerExitedRejection: (values) => - translate( - 'components.native-chat.failureWords.providerExitedRejection', - COPY.providerExitedRejection, - values - ), + ...PROVIDER_EXIT_PIECES, providerRejected: () => translate('components.native-chat.failureWords.providerRejected', COPY.providerRejected), providerRejectedQuoted: (values) => diff --git a/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts b/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts new file mode 100644 index 000000000000..a9b3785888de --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-provider-exit-words-text.ts @@ -0,0 +1,31 @@ +// Desktop words for an agent that stopped, kept beside the other failure pieces' translations. + +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../shared/agent-session-failure-copy' + +export const PROVIDER_EXIT_PIECES = { + providerExitedRow: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRow', + COPY.providerExitedRow, + values + ), + providerExitedRejection: (values) => + translate( + 'components.native-chat.failureWords.providerExitedRejection', + COPY.providerExitedRejection, + values + ), + providerExitedAnswer: (values) => + translate( + 'components.native-chat.failureWords.providerExitedAnswer', + COPY.providerExitedAnswer, + values + ) +} satisfies Partial< + Record string> +> diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 3589b1084a0b..97248e1be30c 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,7 +2,10 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' -import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' +import { + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, @@ -178,7 +181,8 @@ export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNot export function agentSessionWriteFailureText( failure: AgentSessionWriteFailure, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { - return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write)) + return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write, context)) } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 2b4a748a79de..14c1e0e0cbc5 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -322,13 +322,15 @@ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], journalItems: readonly AgentJournalRenderItem[] | undefined, stopping = false, - journalSubmissions?: readonly AgentJournalSubmission[] + journalSubmissions?: readonly AgentJournalSubmission[], + host: Parameters[4] = {} ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), journalItems, stopping, - journalSubmissions + journalSubmissions, + host ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts new file mode 100644 index 000000000000..530ef37163e0 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts @@ -0,0 +1,57 @@ +// The desktop chat answers only the prompts the host says still wait on the person: one an agent +// that has ended raised gets no card, and an older host, which names none, keeps today's rule. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer' +import { pendingStructuredSessionPrompts } from './structured-agent-session-message-projection' +import { structuredAttentionReadObservation } from './structured-attention-read-observation' + +function approval(itemId: string, sequence: number): AgentJournalRenderItem { + return { + itemId, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } +} + +const ITEMS = [approval('live-prompt', 1), approval('dead-prompt', 2)] +const ids = (items: AgentJournalRenderItem[]) => items.map((item) => item.itemId) + +describe('the prompt cards a desktop chat offers', () => { + it('are the ones the host names', () => { + expect(ids(pendingStructuredSessionPrompts(ITEMS, ['live-prompt']))).toEqual(['live-prompt']) + expect(pendingStructuredSessionPrompts(ITEMS, [])).toEqual([]) + }) + + it('are every pending prompt when an older host names none', () => { + expect(ids(pendingStructuredSessionPrompts(ITEMS))).toEqual(['live-prompt', 'dead-prompt']) + }) +}) + +describe('what marks a chat read', () => { + it('changes when the host stops naming a prompt, with no row changing', () => { + const ask: AgentJournalRenderItem = { + itemId: 'ask', + revision: 1, + sequence: 0, + observedAt: 0, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + const state = { ...EMPTY_STRUCTURED_AGENT_SESSION, items: [ask, ...ITEMS] } + const named = structuredAttentionReadObservation({ + ...state, + actionablePromptIds: ['live-prompt'] + }) + const ended = structuredAttentionReadObservation({ ...state, actionablePromptIds: [] }) + expect(ended).not.toBe(named) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index 25ff332ceb87..fa9297874932 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -6,6 +6,7 @@ import type { import type { StructuredAgentSessionOptimisticMessage } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredAgentSessionMessages as projectMessages } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredQuestionMessages } from './structured-agent-question-projection' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' /** The desktop's transcript: a message the host accepted and then rejected stays where it was * sent, as not sent, unless the queue holds it as a card. */ @@ -27,12 +28,16 @@ export type StructuredPromptItem = AgentJournalRenderItem & { body: Extract } +/** The prompt cards a person can answer: pending, and named by the host as waiting + * (`isActionableStructuredAgentSessionPrompt`) when the host names them. */ export function pendingStructuredSessionPrompts( - items: AgentJournalRenderItem[] + items: AgentJournalRenderItem[], + actionablePromptIds?: readonly string[] ): StructuredPromptItem[] { return agentSessionCurrentContextRows(items).items.filter( (item): item is StructuredPromptItem => (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' + item.body.resolution.state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, actionablePromptIds) ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 5653e8f25b19..7b4b692dfe49 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -164,6 +164,13 @@ describe('queued message cards', () => { expect(cards.map((card) => queuedMessageCardSteers(card))).toEqual([true, true]) }) + it('a waiting card still offers Steer while nothing runs, as when its agent died and its cleanup waits', () => { + const cards = projectQueuedMessageCards([draft('waiting', 1)], [], { agentWorking: false }) + expect(cards.map((card) => [card.hold, queuedMessageCardSteers(card)])).toEqual([ + ['turn', true] + ]) + }) + it("the header names the queue's pause while it holds a card, and none over cards Resume would not send", () => { const stopped = { reason: 'stopped' } as const const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => diff --git a/src/renderer/src/components/native-chat/structured-attention-read-observation.ts b/src/renderer/src/components/native-chat/structured-attention-read-observation.ts index a614438deb57..8a365d4fc652 100644 --- a/src/renderer/src/components/native-chat/structured-attention-read-observation.ts +++ b/src/renderer/src/components/native-chat/structured-attention-read-observation.ts @@ -1,5 +1,6 @@ import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import { projectStructuredAgentSessionStatusState } from '../../../../shared/structured-agent-session-projection' +import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/structured-agent-session-live-turn' const observations = new WeakMap() @@ -16,7 +17,10 @@ export function structuredAttentionReadObservation(state: StructuredAgentSession const request = projection.latestRequest const key = JSON.stringify([ state.cursor?.epoch, - projection.pendingPromptIds, + // The host's answer where it gives one: a prompt an agent that ended raised waits on no one. + projection.pendingPromptIds.filter((itemId) => + isActionableStructuredAgentSessionPrompt(itemId, state.actionablePromptIds) + ), request?.turnState !== 'running' ? [request?.kind, request?.id, request?.outcome] : null ]) observations.set(state, key) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index bfccacad0933..55b02cf3ccc5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -57,11 +57,16 @@ function namesWhatItStops(fingerprintMethod: string, fields: Record + fields: Record, + agentName: string | undefined ): { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } { return { kind: 'not-done', - notice: agentSessionWriteFailureText(failure, writeKind(fingerprintMethod, fields)), + notice: agentSessionWriteFailureText( + failure, + writeKind(fingerprintMethod, fields), + agentName ? { agentName } : {} + ), failure } } @@ -73,11 +78,13 @@ export function useStructuredAgentSessionMutate(args: { /** Read at settle time, not at call time: the fence can move while a request * is in flight, and a result from the previous fence is not this session's. */ stateRef: { current: { fence: number | null } } + /** The chat's agent, which a refusal about it names. */ + agentName?: string }): { write: StructuredAgentSessionWrite mutate: StructuredAgentSessionMutate } { - const { enabled = true, sessionId, stateRef, target } = args + const { agentName, enabled = true, sessionId, stateRef, target } = args const inFlightStops = useRef( new Map>>() ) @@ -144,13 +151,19 @@ export function useStructuredAgentSessionMutate(args: { error instanceof RuntimeRpcCallError ? error.code : undefined ), fingerprintMethod, - fields + fields, + agentName ) : { kind: 'dropped' } } if (!result.ok) { return settlesHere() - ? notDone(agentSessionRefusalFailure(result.refusal), fingerprintMethod, fields) + ? notDone( + agentSessionRefusalFailure(result.refusal), + fingerprintMethod, + fields, + agentName + ) : { kind: 'dropped' } } if (!settlesHere()) { @@ -158,7 +171,7 @@ export function useStructuredAgentSessionMutate(args: { } return { kind: 'done', value: result.value } }, - [enabled, sessionId, stateRef, target] + [agentName, enabled, sessionId, stateRef, target] ) const write = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index ef730a1781df..84f1eec124a0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -1,5 +1,8 @@ import { useMemo } from 'react' -import { runningStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-live-turn' +import { + runningStructuredAgentSessionTurnId, + structuredAgentSessionHostSaysWorking +} from '../../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../../shared/structured-agent-session-main-agent-working' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import type { StructuredAgentSubagentRoster } from '../../../../shared/structured-agent-session-subagent-roster' @@ -13,6 +16,13 @@ const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] const NO_SUBAGENT_ROSTER: StructuredAgentSubagentRoster = new Map() +/** The host's prompts still waiting on the person and its working answer; empty from an older + * host, whose readers derive both from the rows. */ +export type StructuredAgentSessionHostWork = { + actionablePromptIds?: readonly string[] + working?: boolean +} + export function useStructuredAgentSessionTransportState( state: StructuredAgentSessionState, enabled: boolean @@ -22,14 +32,20 @@ export function useStructuredAgentSessionTransportState( const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null const latestTurn = enabled ? state.latestTurn : undefined + // The host's answer to which pending prompts still wait on the person; absent from an older host. + const actionablePromptIds = enabled ? state.actionablePromptIds : undefined const current = useMemo( () => agentSessionCurrentContextRows(journalItems, submissions), [journalItems, submissions] ) // The host's whole-journal turn, never the loaded rows': a long turn's record is off the page. const turnId = runningStructuredAgentSessionTurnId({ items: journalItems, latestTurn }) - // The rule the host projects every session list's Working from, so this chat cannot disagree. - const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) + // The host's own answer, which counts only the agent running now; an older host gives none, and + // the rule it projects every session list's Working from stands in, so this chat cannot disagree. + const hostWorking = enabled ? state.working : undefined + const isWorking = structuredAgentSessionHostSaysWorking(hostWorking, () => + isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence) + ) const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null // The host names the card its queue sends next. That send lands in a later update than a turn's // end or a Resume, so until then the chat still reads as working and nothing flips in between. @@ -55,9 +71,18 @@ export function useStructuredAgentSessionTransportState( structuredSessionForegroundCommands(roster, { items: journalItems, latestTurn }) ) }, [enabled, journalItems, latestTurn, state.backgroundTasks, turnId]) + const hostWork = useMemo( + () => ({ + ...(actionablePromptIds ? { actionablePromptIds } : {}), + ...(hostWorking !== undefined ? { working: hostWorking } : {}) + }), + [actionablePromptIds, hostWorking] + ) return { journalItems, latestTurn, + actionablePromptIds, + hostWork, subagentRoster, submissions, fence, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx new file mode 100644 index 000000000000..4edac4098a2e --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +// Whether the chat's own agent is working is the host's answer when it gives one: it counts only +// the agent running now, so a turn an ended agent left running reads as stopped and Resume shows. +// An older host gives none, and the chat derives it from its rows as before. + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +afterEach(cleanup) + +/** A turn row an agent that has since ended left running. */ +const LEFT_RUNNING: AgentJournalRenderItem = { + itemId: 'turn-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } +} + +function page(fields: Partial = {}): AgentSessionHistoryPage { + const cursor = { epoch: 'epoch-a', sequence: 1 } + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [LEFT_RUNNING], + removedItemIds: [], + submissions: [], + window: { oldest: cursor, newest: cursor, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + ...fields + } +} + +function loaded(fields: Partial): StructuredAgentSessionState { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: page(fields) + }) +} + +function isWorking(state: StructuredAgentSessionState): boolean { + return renderHook(() => useStructuredAgentSessionTransportState(state, true)).result.current + .isWorking +} + +describe('the chat’s Working', () => { + it('is the host’s answer: a turn an ended agent left running reads as stopped', () => { + expect(isWorking(loaded({ working: false }))).toBe(false) + expect(isWorking(loaded({ items: [], working: true }))).toBe(true) + }) + + it('falls back to the rows when an older host gives no answer', () => { + expect(loaded({}).working).toBeUndefined() + expect(isWorking(loaded({}))).toBe(true) + }) + + it('follows a frame that restates it with no row, and keeps it across a frame that names none', () => { + const stopped = reduceStructuredAgentSession(loaded({ working: true }), { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + items: [], + removedItemIds: [], + submissions: [], + cursor: { epoch: 'epoch-a', sequence: 1 } + }, + working: false + } + }) + expect(stopped.working).toBe(false) + expect(isWorking(stopped)).toBe(false) + const quiet = reduceStructuredAgentSession(stopped, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + items: [], + removedItemIds: [], + submissions: [], + cursor: { epoch: 'epoch-a', sequence: 1 } + } + } + }) + expect(quiet.working).toBe(false) + }) + + it('offers Resume over held cards once the host says the agent stopped; an older host’s rows hide it', () => { + const held = { + queuePause: { reason: 'stopped' as const }, + queuedMessages: [ + { + messageId: 'held', + position: 1, + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + state: 'waiting' as const + } + ] + } + const resume = (state: StructuredAgentSessionState) => + renderHook(() => { + const transport = useStructuredAgentSessionTransportState(state, true) + const { mutate } = useStructuredAgentSessionMutate({ + sessionId: 'session-a', + target: { kind: 'local' }, + stateRef: { current: { fence: 1 } } + }) + return useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages: transport.queuedMessages ?? [], + queuePause: transport.queuePause, + submissions: [], + hasPendingPrompt: false, + isWorking: transport.isWorking, + composerScopeKey: undefined, + mutate, + editTransport: { + target: { kind: 'local' }, + sessionId: 'session-a', + capable: false, + write: async () => ({ kind: 'dropped' }) + } + }) + }).result.current.queueResume + expect(resume(loaded({ ...held, working: false }))).toBeDefined() + expect(resume(loaded(held))).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts index 47ca68a5db76..0eb220affd2e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts @@ -9,8 +9,10 @@ export function useStructuredAgentSessionTransport(args: { target: RuntimeClientTarget isVisible: boolean enabled: boolean + /** The chat's agent, which a refused write about it names. */ + agentName?: string }) { - const { enabled, isVisible, sessionId, target } = args + const { agentName, enabled, isVisible, sessionId, target } = args const providerVisible = isVisible && enabled useStructuredAgentSessionHold({ sessionId, @@ -25,7 +27,8 @@ export function useStructuredAgentSessionTransport(args: { sessionId, target, stateRef, - enabled + enabled, + ...(agentName ? { agentName } : {}) }) useEffect(() => { stateRef.current = read.state diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 608bf683791a..c22e712eb803 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -91,7 +91,8 @@ export function useStructuredAgentSession(args: { sessionId, target, isVisible, - enabled: transportEnabled + enabled: transportEnabled, + agentName: structuredAgentLabel(agent) }) const commandPending = useRef(false) const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) @@ -134,7 +135,10 @@ export function useStructuredAgentSession(args: { hostStopping, mutate }) - const prompts = pendingStructuredSessionPrompts(transportState.journalItems) + const prompts = pendingStructuredSessionPrompts( + transportState.journalItems, + transportState.actionablePromptIds + ) const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) // A send after a command the queue will run goes behind it, even with follow-ups off. // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot @@ -250,6 +254,8 @@ export function useStructuredAgentSession(args: { journalItems: transcriptItems, /** The host's newest turn record, which places a live turn whose record is not loaded. */ latestTurn: transportState.latestTurn, + /** The host's prompts still waiting on the person and its working answer. */ + hostWork: transportState.hostWork, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 55263d5ee7fc..40ab6edccb6a 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2892,6 +2892,7 @@ "notSignedIn": "{{agent}} is not signed in.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerExitedAnswer": "{{agent}} has stopped, so your answer was not sent. Send a message to continue.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRejected": "The provider did not accept this message.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f8812801cee..7725d93308e1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18062,6 +18062,7 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", + "providerExitedAnswer": "{{agent}} has stopped, so your answer was not sent. Send a message to continue.", "providerRejected": "The provider did not accept this message.", "providerRejectedQuoted": "The provider did not accept this message: {{detail}}.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2c7e4f05eda9..369c746c8396 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17762,6 +17762,7 @@ "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", "providerExitedRow": "{{agent}} se detuvo mientras esta respuesta estaba en curso. Puede continuar en esta conversación.", "providerExitedRejection": "{{agent}} se detuvo antes de que se enviara este mensaje.", + "providerExitedAnswer": "{{agent}} se detuvo, así que su respuesta no se envió. Envíe un mensaje para continuar.", "providerRejected": "El proveedor no aceptó este mensaje.", "providerRejectedQuoted": "El proveedor no aceptó este mensaje: {{detail}}.", "attachmentEmpty": "Una imagen de este mensaje está vacía, así que el mensaje no se envió.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 04f622973aba..44aa3ff9b9ac 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17864,6 +17864,7 @@ "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", "providerExitedRow": "{{agent}} s'est arrêté pendant que cette réponse était en cours. Vous pouvez continuer dans cette conversation.", "providerExitedRejection": "{{agent}} s'est arrêté avant l'envoi de ce message.", + "providerExitedAnswer": "{{agent}} s'est arrêté, donc votre réponse n'a pas été envoyée. Envoyez un message pour continuer.", "providerRejected": "Le fournisseur n'a pas accepté ce message.", "providerRejectedQuoted": "Le fournisseur n'a pas accepté ce message : {{detail}}.", "attachmentEmpty": "Une image de ce message est vide, le message n'a donc pas été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a632ba19b243..cd2fdd042efd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17797,6 +17797,7 @@ "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", "providerExitedRow": "この応答の途中で {{agent}} が停止しました。この会話で続けることができます。", "providerExitedRejection": "このメッセージが送信される前に {{agent}} が停止しました。", + "providerExitedAnswer": "{{agent}} が停止したため、回答は送信されませんでした。続けるにはメッセージを送信してください。", "providerRejected": "プロバイダーはこのメッセージを受け付けませんでした。", "providerRejectedQuoted": "プロバイダーはこのメッセージを受け付けませんでした: {{detail}}。", "attachmentEmpty": "このメッセージの画像が空のため、メッセージは送信されませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4e9aac6cd485..ebd15870baa9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17797,6 +17797,7 @@ "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", "providerExitedRow": "이 응답이 진행되는 동안 {{agent}}이(가) 중지되었습니다. 이 대화에서 계속할 수 있습니다.", "providerExitedRejection": "이 메시지가 전송되기 전에 {{agent}}이(가) 중지되었습니다.", + "providerExitedAnswer": "{{agent}}이(가) 중지되어 답변이 전송되지 않았습니다. 계속하려면 메시지를 보내세요.", "providerRejected": "공급자가 이 메시지를 받아들이지 않았습니다.", "providerRejectedQuoted": "공급자가 이 메시지를 받아들이지 않았습니다: {{detail}}.", "attachmentEmpty": "이 메시지의 이미지가 비어 있어 메시지가 전송되지 않았습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0592f647928e..151a8f16647c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17776,6 +17776,7 @@ "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", "providerExitedRow": "{{agent}} 在生成此回复时停止了。您可以在此对话中继续。", "providerExitedRejection": "{{agent}} 在发送此消息前已停止。", + "providerExitedAnswer": "{{agent}} 已停止,因此您的回答未发送。发送消息以继续。", "providerRejected": "提供商未接受此消息。", "providerRejectedQuoted": "提供商未接受此消息:{{detail}}。", "attachmentEmpty": "此消息中的一张图片为空,因此消息未发送。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index d498565e3847..045d62135118 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -76,6 +76,8 @@ export const AGENT_SESSION_FAILURE_COPY = { providerExitedRow: '{{agent}} stopped while this response was in progress. You can continue in this conversation.', providerExitedRejection: '{{agent}} stopped before this message was sent.', + providerExitedAnswer: + '{{agent}} has stopped, so your answer was not sent. Send a message to continue.', providerRejected: 'The provider did not accept this message.', providerRejectedQuoted: 'The provider did not accept this message: {{detail}}.', attachmentEmpty: 'An image on this message is empty, so the message was not sent.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index bfdc644b8bee..f0eff4cdbffa 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -24,7 +24,7 @@ import { /** What Orca's own text looks like: a code or marker, a uuid, a path, an exception. */ const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ -const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] +const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection', 'answer'] const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ 'cancelled', 'writeFailed', @@ -76,7 +76,11 @@ describe('the words written beside a failure fact', () => { expect(sentence).not.toMatch(ORCA_INTERNAL) if (surface === 'row') { expect(agentSessionFailureWords(fact, { ...context, surface }).text).toBe(sentence) - } else if (isSubmissionRejectionFact(fact) && !LEGACY_MARKER_KINDS.has(fact.kind)) { + } else if ( + surface === 'rejection' && + isSubmissionRejectionFact(fact) && + !LEGACY_MARKER_KINDS.has(fact.kind) + ) { expect(agentSessionFailureWords(fact, { ...context, surface }).reason).toBe(sentence) } } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index cccb145c8af3..64764c2a4035 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -54,8 +54,9 @@ export type AgentJournalDispatchRejection = { rejection: SubmissionRejectionFact } -/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ -export type AgentSessionFailureSurface = 'row' | 'rejection' +/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. + * `answer`: a refused answer to an approval or question, about it. */ +export type AgentSessionFailureSurface = 'row' | 'rejection' | 'answer' export type AgentSessionFailureWordsContext = { /** The chat's agent, when the writer knows it. */ @@ -235,7 +236,14 @@ const FAILURE_SENTENCES = { : say('chooseClaudeAccountThenSend') ]), providerExited: (context, _fact, surface, say) => - say(surface === 'row' ? 'providerExitedRow' : 'providerExitedRejection', agent(say, context)), + say( + surface === 'row' + ? 'providerExitedRow' + : surface === 'answer' + ? 'providerExitedAnswer' + : 'providerExitedRejection', + agent(say, context) + ), restartFailed: couldNot('couldNotRestart'), providerRejected: (_context, fact, _surface, say) => quotingPersonDetail(say, 'providerRejected', 'providerRejectedQuoted', fact.detail), diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index ae52282631a8..5046cc529652 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -47,6 +47,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'rewindUnconfirmed', // A prompt card or an option 'promptGone', + /** The agent that raised the prompt has ended, so nothing can take its answer. */ + 'promptOwnerEnded', 'optionRejected', // The provider 'providerStarting', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index aadf9e26fa49..c784fecbee3f 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -451,6 +451,12 @@ describe('the notice for every reason a host names', () => { } const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') + // "...so your answer was not sent" is that sentence's own. + const ownerStopped = + write === 'answer' && + parts.some( + (part) => typeof part === 'object' && 'surface' in part && part.surface === 'answer' + ) const unsupported = failure.code === 'structured_agent_session_unsupported' && failure.details?.reason !== 'hostUnsupported' && @@ -467,7 +473,9 @@ describe('the notice for every reason a host names', () => { ] as const ).some((sentence) => parts.includes(sentence)) expect(notDone, cell).toEqual( - answeredAway || unsupported || saysNotDone || clearWhileWorking ? [] : [NOT_DONE[write]] + answeredAway || ownerStopped || unsupported || saysNotDone || clearWhileWorking + ? [] + : [NOT_DONE[write]] ) } }) @@ -526,6 +534,12 @@ describe('the notice for every reason a host names', () => { 'answer', 'This question was already answered or has changed.' ], + [ + 'agent_session_operation_invalid', + 'promptOwnerEnded', + 'answer', + 'The agent has stopped, so your answer was not sent. Send a message to continue.' + ], [ 'agent_session_checkpoint_stale', 'fenceStale', @@ -539,6 +553,33 @@ describe('the notice for every reason a host names', () => { }) }) +describe('an answer to a prompt whose agent stopped', () => { + it('names the agent the client knows, on the phone and on desktop alike', () => { + const refusal = { + code: 'agent_session_operation_invalid' as const, + message: HOST_TEXT, + details: { reason: 'promptOwnerEnded' as const } + } + expect(agentSessionRefusalNotice(refusal, 'answer', { agentName: 'Codex' })).toBe( + 'Codex has stopped, so your answer was not sent. Send a message to continue.' + ) + }) + + it.each(['providerStartFailed', 'notSignedIn', 'cliMissing', 'historyTooLarge'] as const)( + 'keeps that sentence its own: %s still reads "Your answer was not sent."', + (reason) => { + const refusal = { + code: 'agent_session_operation_invalid' as const, + message: HOST_TEXT, + details: { reason } + } + expect(agentSessionRefusalNotice(refusal, 'answer', { agentName: 'Codex' })).toBe( + 'Your answer was not sent.' + ) + } + ) +}) + describe('a refusal from a host that names no reason this build knows', () => { it.each([ ['an older host', undefined], diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index f81e1b51a901..dfaaca943741 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -125,6 +125,11 @@ function reasonParts( return undefined } if ('fact' in words) { + if (write === 'answer' && words.fact === 'providerExited') { + // An approval or question was waiting on the person when its agent stopped: one sentence + // that says the answer was not sent, names the agent, and says how to go on. + return [{ failure: { kind: words.fact }, surface: 'answer', context }] + } return write === 'send' || write === 'composer-send' ? [ NOT_DONE[write], @@ -252,10 +257,11 @@ export function agentSessionWriteNoticeEnglish( * message is not read. */ export function agentSessionRefusalNotice( refusal: Pick, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): string { return agentSessionWriteNoticeEnglish( - agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write) + agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), write, context) ) } diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 03c5c1c04dd2..76b9d4ff7303 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -88,6 +88,9 @@ const REASON_WORDS = { rewindRefused: codeWords('hostFinding'), rewindUnconfirmed: codeWords('hostFinding'), promptGone: causeWords('questionChanged', 'nothingLeft'), + // An approval or question waited on the person when its agent stopped: said naming the agent, + // with sending a message as the step on. + promptOwnerEnded: { fact: 'providerExited', action: 'nothingLeft' }, optionRejected: causeWords('optionRejected', 'retry'), providerStarting: AGENT_STARTING, goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), diff --git a/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts b/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts new file mode 100644 index 000000000000..91fecb6b5195 --- /dev/null +++ b/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { nativeChatMessagesWaitingBehindLiveTurn } from './native-chat-messages-waiting-behind-live-turn' + +/** A /compact whose turn record still reads running. */ +const compactRunning: AgentJournalRenderItem[] = [ + { + itemId: 'compact-entry', + revision: 0, + sequence: 1, + observedAt: 1, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }, + { + itemId: 'compact-turn', + revision: 0, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'compact-turn', userItemId: 'compact-entry', state: 'running' } + } +] +const queued = [{ id: 'card-1', role: 'user' as const, queued: true as const }] + +describe('a queued card behind a running /compact', () => { + it('waits behind it while the host says it runs, or an older host says nothing', () => { + expect(nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning)).toEqual( + new Set(['card-1']) + ) + const latestTurn = { + itemId: 'compact-turn', + observedAt: 2, + turn: { turnId: 'compact-turn', state: 'running' as const } + } + expect( + nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning, false, [], { latestTurn }) + ).toEqual(new Set(['card-1'])) + }) + + it('waits behind nothing once the host says no turn runs: the agent that ran it ended', () => { + expect( + nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning, false, [], { + latestTurn: null, + working: false + }) + ).toEqual(new Set()) + }) +}) diff --git a/src/shared/native-chat-messages-waiting-behind-live-turn.ts b/src/shared/native-chat-messages-waiting-behind-live-turn.ts index 767bb1e8c826..6c42a8029dbc 100644 --- a/src/shared/native-chat-messages-waiting-behind-live-turn.ts +++ b/src/shared/native-chat-messages-waiting-behind-live-turn.ts @@ -1,10 +1,11 @@ // Which rows wait at the tail, after the live turn's activity line, rather than in the transcript. import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionLatestTurn } from './agent-session-wire' import { readAgentJournalTurn } from './agent-session-turn-record' import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' -import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' import { structuredAgentSessionOpeningSendIn } from './structured-agent-session-opening-send' /** @@ -30,7 +31,10 @@ export function nativeChatMessagesWaitingBehindLiveTurn( }[], items: readonly AgentJournalRenderItem[] | null | undefined, stopping = false, - submissions?: readonly AgentJournalSubmission[] + submissions?: readonly AgentJournalSubmission[], + /** The host's view of current work (`latestTurn`, `working`): an ended generation's running turn + * or unanswered send holds nothing. Absent from an older host, whose rows are read instead. */ + host: { latestTurn?: AgentSessionLatestTurn | null; working?: boolean } = {} ): ReadonlySet { const waits = (message: (typeof messages)[number]): boolean => message.queued === true || (stopping && message.sentWhileStopping === true) @@ -45,9 +49,11 @@ export function nativeChatMessagesWaitingBehindLiveTurn( if (candidates.length === 0 || !items) { return new Set() } - const behindHold = stopping || commandTurnRunning(items) + const behindHold = stopping || commandTurnRunning(items, host.latestTurn) const behindOpening = - submissions !== undefined && structuredAgentSessionOpeningSendIn(items, submissions) !== null + host.working !== false && + submissions !== undefined && + structuredAgentSessionOpeningSendIn(items, submissions) !== null return new Set( candidates .filter( @@ -59,8 +65,14 @@ export function nativeChatMessagesWaitingBehindLiveTurn( ) } -function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { - const running = liveStructuredAgentSessionTurnScope(items) +function commandTurnRunning( + items: readonly AgentJournalRenderItem[], + latestTurn: AgentSessionLatestTurn | null | undefined +): boolean { + const running = runningStructuredAgentSessionTurnScope({ + items, + ...(latestTurn !== undefined ? { latestTurn } : {}) + }) const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body return ( running.kind === 'turn' && diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 40dbf096e2ef..43c28719dda2 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -46,6 +46,8 @@ export type NativeChatTurnJournal = { * record is not loaded, so the turn's loaded rows still draw under its bar. Absent from older * hosts. */ latestTurn?: AgentSessionLatestTurn | null + /** The host's answer to whether the session's own agent is working; absent from older hosts. */ + working?: boolean } /** diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index f80658838dc1..fc418c8bf5e6 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -1,5 +1,9 @@ import type { AgentSessionSubscribeEvent } from './agent-session-wire' -import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' +import { + actionablePromptIdsAfterStructuredAgentSessionBatch, + hostWorkingAfterStructuredAgentSessionBatch, + latestTurnAfterStructuredAgentSessionBatch +} from './structured-agent-session-live-turn' export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 @@ -29,6 +33,11 @@ function mergeBatch( } // As applying both in turn would leave it, so an older host's rows still drop a stale claim. const latestTurn = latestTurnAfterStructuredAgentSessionBatch(left.latestTurn, right) + const actionablePromptIds = actionablePromptIdsAfterStructuredAgentSessionBatch( + left.actionablePromptIds, + right + ) + const working = hostWorkingAfterStructuredAgentSessionBatch(left.working, right) return { type: 'batch', ...(right.commands !== undefined || left.commands !== undefined @@ -55,7 +64,9 @@ function mergeBatch( ...(right.activity !== undefined || left.activity !== undefined ? { activity: right.activity !== undefined ? right.activity : (left.activity ?? null) } : {}), - ...(latestTurn !== undefined ? { latestTurn } : {}) + ...(latestTurn !== undefined ? { latestTurn } : {}), + ...(actionablePromptIds !== undefined ? { actionablePromptIds } : {}), + ...(working !== undefined ? { working } : {}) } } diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 94d3ac921735..0316defad8f2 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -175,6 +175,70 @@ export function latestTurnAfterStructuredAgentSessionBatch( return carriesRows || event.latestTurn !== undefined ? event.latestTurn : previous } +/** The host's actionable prompts once `event` applies, under `latestTurn`'s rule: a batch carrying + * rows restates them, so one without them came from an older host and drops the claim. */ +export function actionablePromptIdsAfterStructuredAgentSessionBatch( + previous: string[] | undefined, + event: Extract +): string[] | undefined { + const { items, removedItemIds, submissions } = event.batch + const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0 + return carriesRows || event.actionablePromptIds !== undefined + ? event.actionablePromptIds + : previous +} + +/** The host's working answer once `event` applies, under `latestTurn`'s rule. */ +export function hostWorkingAfterStructuredAgentSessionBatch( + previous: boolean | undefined, + event: Extract +): boolean | undefined { + const { items, removedItemIds, submissions } = event.batch + const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0 + return carriesRows || event.working !== undefined ? event.working : previous +} + +/** Whether a frame's turn, prompts and working answer, when it names them, are what the state + * already holds: a generation's end can change them with no row, so a frame that does is never a + * no-op. */ +export function restatesStructuredAgentSessionHostWork( + event: Pick< + Extract, + 'latestTurn' | 'actionablePromptIds' | 'working' + >, + state: { + latestTurn?: AgentSessionLatestTurn | null + actionablePromptIds?: string[] + working?: boolean + } +): boolean { + return ( + (event.latestTurn === undefined || + JSON.stringify(event.latestTurn) === JSON.stringify(state.latestTurn)) && + (event.actionablePromptIds === undefined || + event.actionablePromptIds.join('\n') === state.actionablePromptIds?.join('\n')) && + (event.working === undefined || event.working === state.working) + ) +} + +/** Whether the session's own agent is working: the host's answer when it gave one, else what the + * client derives from its rows (an older host). */ +export function structuredAgentSessionHostSaysWorking( + hostWorking: boolean | undefined, + derived: () => boolean +): boolean { + return hostWorking ?? derived() +} + +/** Whether a pending prompt waits on the person: the host names the ones that do, and a prompt an + * agent that has ended raised is not among them. An older host names none: every pending one. */ +export function isActionableStructuredAgentSessionPrompt( + itemId: string, + actionablePromptIds: readonly string[] | undefined +): boolean { + return actionablePromptIds === undefined || actionablePromptIds.includes(itemId) +} + /** * Whether the newest thing the active turn produced is the model's own reasoning. * diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 1592e949b1b2..1838740ae486 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionHistoryPage, AgentSessionLatestTurn } from './agent-session-wire' -import { runningStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' +import { + isActionableStructuredAgentSessionPrompt, + runningStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession @@ -595,6 +598,75 @@ describe('structured agent session reducer', () => { }) }) + describe("the host's actionable prompts", () => { + const prompt = (itemId: string, sequence: number): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + const PROMPTS = [prompt('live-prompt', 1), prompt('dead-prompt', 2)] + const opened = (page: AgentSessionHistoryPage) => + reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page } + }) + const batch = ( + state: ReturnType, + items: AgentJournalRenderItem[], + actionablePromptIds?: string[] + ) => + reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: state.cursor!.sequence + items.length }, + items, + removedItemIds: [], + submissions: [] + }, + ...(actionablePromptIds !== undefined ? { actionablePromptIds } : {}) + } + }) + const actionable = (state: ReturnType) => + state.items + .filter((entry) => + isActionableStructuredAgentSessionPrompt(entry.itemId, state.actionablePromptIds) + ) + .map((entry) => entry.itemId) + + it('reads them from the snapshot, keeps them across a frame without rows, and takes the next', () => { + const state = opened({ ...hydrationPage(PROMPTS), actionablePromptIds: ['live-prompt'] }) + expect(actionable(state)).toEqual(['live-prompt']) + const quiet = batch(state, []) + expect(quiet.actionablePromptIds).toEqual(['live-prompt']) + // The generation ended with no row written: the host's frame names none. + const ended = batch(quiet, [], []) + expect(actionable(ended)).toEqual([]) + }) + + it('an older host names none: its page reduces, and every pending prompt stays answerable', () => { + // As an older host serialized it: no such field at all. + const older: AgentSessionHistoryPage = JSON.parse(JSON.stringify(hydrationPage(PROMPTS))) + const state = opened(older) + expect(state.actionablePromptIds).toBeUndefined() + expect(actionable(state)).toEqual(['live-prompt', 'dead-prompt']) + // Rows from a downgraded host restate nothing: back to every pending prompt. + const named = opened({ ...hydrationPage(PROMPTS), actionablePromptIds: ['live-prompt'] }) + const downgraded = batch(named, [prompt('another', 3)]) + expect(downgraded.actionablePromptIds).toBeUndefined() + }) + }) + it('records the host clock from frames that carry it and keeps it otherwise', () => { const snapshot = reduceStructuredAgentSession( EMPTY_STRUCTURED_AGENT_SESSION, diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 6ff6124feb38..f20c673e7969 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -26,7 +26,12 @@ import { import { compareAgentJournalItems } from './agent-session-journal-position' import { readAgentJournalTurn } from './agent-session-turn-record' import { queuePublicationField } from './structured-agent-session-queue-publication-field' -import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' +import { + actionablePromptIdsAfterStructuredAgentSessionBatch, + hostWorkingAfterStructuredAgentSessionBatch, + latestTurnAfterStructuredAgentSessionBatch, + restatesStructuredAgentSessionHostWork +} from './structured-agent-session-live-turn' import { foldStructuredAgentSubagentRoster, foldStructuredAgentSubagentRosterPage, @@ -75,6 +80,12 @@ export type StructuredAgentSessionState = { /** The host's newest turn record over the whole journal, which says whether a turn runs; absent * from an older host, whose answer is read off `items` instead. */ latestTurn?: AgentSessionLatestTurn | null + /** The prompts the host says still wait on the person (`AgentSessionHistoryPage`); absent from + * an older host, whose every pending prompt in `items` is read as waiting. */ + actionablePromptIds?: string[] + /** Whether the host says the session's own agent is working (`AgentSessionHistoryPage.working`); + * absent from an older host, whose answer is derived from `items` and `submissions`. */ + working?: boolean /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number @@ -130,6 +141,10 @@ function replacePage( subagentRoster: foldStructuredAgentSubagentRosterPage(undefined, page), activity: activity ?? null, ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), + ...(page.actionablePromptIds !== undefined + ? { actionablePromptIds: page.actionablePromptIds } + : {}), + ...(page.working !== undefined ? { working: page.working } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined @@ -275,6 +290,7 @@ export function reduceStructuredAgentSession( (event.nextQueuedMessageId === undefined || event.nextQueuedMessageId === state.nextQueuedMessageId) && backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && + restatesStructuredAgentSessionHostWork(event, state) && activity?.turnId === state.activity?.turnId && activity?.text === state.activity?.text && state.status === 'ready' && @@ -310,6 +326,11 @@ export function reduceStructuredAgentSession( readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, latestTurn: latestTurnAfterStructuredAgentSessionBatch(state.latestTurn, event), + actionablePromptIds: actionablePromptIdsAfterStructuredAgentSessionBatch( + state.actionablePromptIds, + event + ), + working: hostWorkingAfterStructuredAgentSessionBatch(state.working, event), ...queuePublicationField(event, state), backgroundTasks, ...(activity !== undefined ? { activity } : {}), From 7f946e4f121d2ea84d36e8cc38e13ae2704c1175 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:55:53 -0700 Subject: [PATCH 04/13] refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row. --- .../agent-session-wire/structured-agent-session-current-work.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index 2cee82ca63f9..37d3110df691 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -97,7 +97,7 @@ export function structuredAgentSessionReplacedRuntimeEnded( } /** `StructuredAgentSessionCurrentWork.handsOver`, for a reader that needs no other answer. */ -export function structuredAgentSessionHandsOver( +function structuredAgentSessionHandsOver( journal: Pick, submission: Pick< AgentJournalSubmission, From d6925c418d295736afc1b87ea134cce05423b03e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:02:23 -0700 Subject: [PATCH 05/13] test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one). --- .../journal-item-provenance.test.ts | 71 +++---------------- 1 file changed, 8 insertions(+), 63 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index e275e8102eda..0e3cac81ddeb 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -17,7 +17,6 @@ import { retainedRowReplacement } from '../agent-session-wire/structured-rewind- import type { JournalHostDatabase } from './journal-host-database' import { createTrackedJournalOpener, - insertTestJournalRowJson, liveTestJournalRows, openTestJournalHostDatabase } from './journal-host-database-test-support' @@ -75,26 +74,24 @@ afterEach(async () => { }) describe('execution provenance', () => { - it("records a provider observation's owner, and a host revision keeps it", async () => { + it("records a provider observation's owner, a host revision keeps it, and a reopen reads it", async () => { const journal = await open() await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) // Host bookkeeping under a later writer fence states no owner of its own. await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE }) - - expect(journal.itemFence(TURN_ID)).toBe(3) - // Every row states it: none leaves the reader to guess from the writer fence. - expect(storedOwnerFences()).toEqual([3, 3]) - }) - - it('a first write with no stated owner is its writer generation’s', async () => { - const journal = await open() + // A first write with no stated owner is its writer generation's. await journal.appendItem( item(2), { kind: 'status', text: 'note' }, { fence: 6, turnScope: SCOPE } ) + + expect(journal.itemFence(TURN_ID)).toBe(3) expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6) - expect(storedOwnerFences()).toEqual([6]) + // Every row states it: none leaves the reader to guess from the writer fence. + expect(storedOwnerFences()).toEqual([3, 3, 6]) + await journals.closeAll() + expect((await open()).itemFence(TURN_ID)).toBe(3) }) it('a lifecycle batch states it on each item: the batch owner, else the kept value', async () => { @@ -120,30 +117,6 @@ describe('execution provenance', () => { expect(storedOwnerFences()).toEqual([2, 3, 2]) }) - it('survives a reopen from storage', async () => { - const journal = await open() - await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) - await journals.closeAll() - - expect((await open()).itemFence(TURN_ID)).toBe(3) - }) - - it('epoch replacement carries each item’s provenance', async () => { - const journal = await open() - await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) - const before = journal.epoch - - await journal.replaceEpochItems('legacy_import', 5, [ - { identity: TURN, body: RUNNING, ownerFence: journal.itemFence(TURN_ID) ?? 0 }, - { identity: item(2), body: { kind: 'status', text: 'new' }, ownerFence: 5 } - ]) - - expect(journal.epoch).not.toBe(before) - // Written at fence 5, the dead generation's turn is still fence 2's work. - expect(journal.itemFence(TURN_ID)).toBe(2) - expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(5) - }) - it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => { const row = { itemId: TURN_ID, @@ -155,32 +128,4 @@ describe('execution provenance', () => { const { ownerFence: _stated, ...older } = row expect(retainedRowReplacement(older).ownerFence).toBe(0) }) - - it('a stored provenance that is not a fence is dropped, and the row reads as its writer’s', async () => { - const journal = await open() - await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) - const rows = liveTestJournalRows(database.db, IDENTITY.sessionId) - const stored = rows.find((row) => row.rowJson.includes('"kind":"item"')) - const last = rows.at(-1)?.seq - if (!stored || last === undefined) { - throw new Error('expected the item row') - } - await journals.closeAll() - database = openTestJournalHostDatabase(root) - const parsed: Record = JSON.parse(stored.rowJson) - insertTestJournalRowJson( - database.db, - IDENTITY.sessionId, - last + 1, - JSON.stringify({ - ...parsed, - seq: last + 1, - revision: Number(parsed.revision) + 1, - fence: 7, - ownerFence: -1 - }) - ) - - expect((await open()).itemFence(TURN_ID)).toBe(2) - }) }) From 362532edc7e38b0ba23ce27b158b040180b3deb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:02:58 -0700 Subject: [PATCH 06/13] test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work. --- ...uctured-agent-session-current-work.test.ts | 26 ------------------- ...ed-agent-session-rewind-provenance.test.ts | 10 ------- 2 files changed, 36 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts index bdd580d7dc96..5fcc711c09d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts @@ -11,7 +11,6 @@ import type { AgentSessionStatusSummary, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import { liveTestJournalRows, openTestJournalHostDatabase @@ -31,7 +30,6 @@ import { sendText, turnState } from './structured-agent-session-leftover-settlement.test-fixture' -import { structuredAgentSessionCurrentWork } from './structured-agent-session-current-work' let rig: QueuedMessageTestRig | undefined @@ -248,27 +246,3 @@ describe('a live owner', () => { expect(view.listed()?.status).not.toBe('idle') }) }) - -describe('an owner whose adjudication concludes with no row written', () => { - it('changes the published answer when it goes from unverifiable to released', () => { - const journal = { - runningTurn: () => null, - itemFence: () => undefined, - visitItems: () => undefined, - submissions: () => [], - wroteBeforeOpen: () => false - } - const record = agentSessionRecordFixture() - const unverifiable = structuredAgentSessionCurrentWork(journal, { - record: { lease: { ...record.lease, unreconciled: true } }, - child: null - }) - const released = structuredAgentSessionCurrentWork(journal, { - record: { lease: { ...record.lease, claimStatus: 'released' } }, - child: null - }) - - expect(unverifiable.liveFence).toBe(released.liveFence) - expect(unverifiable.viewKey()).not.toBe(released.viewKey()) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts index 6ed2a1d09b77..01325b2c5d28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts @@ -141,14 +141,4 @@ describe('a rewind after a failed cleanup', () => { // What the live agent itself reported is its own. expect(work.isCurrentItem(agentJournalItemKey(LATER))).toBe(true) }) - - it('leaves a generation that is still live holding its own work', async () => { - const journal = await leftByDeadGeneration() - - await rewindAtLiveFence(journal) - - const work = new StructuredAgentSessionCurrentWork(journal, DEAD) - expect(work.activeTurnId()).toBe('turn-dead') - expect(work.actionablePromptIds()).toEqual([agentJournalItemKey(PROMPT)]) - }) }) From 4c6cbff684caf0955b40896087968d478681009c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:05:09 -0700 Subject: [PATCH 07/13] test(native-chat): fewer client tests: one flow per client, one fallback each Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows. --- .../MobileNativeChatQueuedMessages.test.tsx | 22 +--- ...obile-structured-actionable-prompt.test.ts | 66 ----------- .../agent-session-stop-note-refresh.test.ts | 49 +------- ...-agent-session-ended-prompt-cancel.test.ts | 107 +++--------------- ...d-agent-session-actionable-prompts.test.ts | 57 ---------- ...uctured-agent-session-queued-cards.test.ts | 7 -- ...t-session-transport-state.working.test.tsx | 85 +------------- .../agent-session-refusal-notice.test.ts | 20 ---- ...-messages-waiting-behind-live-turn.test.ts | 52 --------- .../structured-agent-session-reducer.test.ts | 104 +++++++---------- 10 files changed, 67 insertions(+), 502 deletions(-) delete mode 100644 mobile/src/session/mobile-structured-actionable-prompt.test.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts delete mode 100644 src/shared/native-chat-messages-waiting-behind-live-turn.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 24f78f532ba2..b01a0fa66c18 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -4,10 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' import type { ActionSheetAction } from '../components/ActionSheetModal' import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' -import { - mobileQueuedMessageCards, - type MobileQueuedMessageCard -} from './mobile-structured-queued-message-cards' +import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards' vi.mock('react-native', () => ({ Pressable: 'Pressable', @@ -212,23 +209,6 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) - it('a waiting card still reads Steer while nothing runs, as when its agent died and its cleanup waits', async () => { - const [waiting] = mobileQueuedMessageCards( - [ - { - messageId: 'waiting', - position: 1, - state: 'waiting', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } - } - ], - [], - { agentWorking: false } - ) - const mounted = await mount({ cards: [waiting!], onSend: vi.fn(async () => true) }) - expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual(['Steer']) - }) - it('a command card never steers: Send only while the agent is idle, and no menu', async () => { const onSend = vi.fn(async () => true) const mounted = await mount({ diff --git a/mobile/src/session/mobile-structured-actionable-prompt.test.ts b/mobile/src/session/mobile-structured-actionable-prompt.test.ts deleted file mode 100644 index c170bc0e50ca..000000000000 --- a/mobile/src/session/mobile-structured-actionable-prompt.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// The phone answers only the prompts the host says still wait on the person: one an agent that -// has ended raised gets no card, and an older host, which names none, keeps today's rule. - -import { describe, expect, it } from 'vitest' -import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' -import { - actionableStructuredApproval, - actionableStructuredQuestion -} from './mobile-structured-actionable-prompt' - -const PENDING = { - state: 'pending' as const, - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null -} - -function approval(itemId: string, sequence: number): AgentJournalRenderItem { - return { - itemId, - revision: 1, - sequence, - observedAt: sequence, - body: { - kind: 'approval', - title: 'Run command?', - detail: null, - options: [{ id: 'yes', label: 'Allow' }], - resolution: PENDING - } - } -} - -function question(itemId: string, sequence: number): AgentJournalRenderItem { - return { - itemId, - revision: 1, - sequence, - observedAt: sequence, - body: { - kind: 'question', - question: 'Which one?', - options: [{ id: 'a', label: 'A' }], - resolution: PENDING - } - } -} - -const ITEMS = [approval('dead-approval', 1), question('dead-question', 2)] - -describe('the prompt a phone answers', () => { - it('skips one the host does not name, and answers one it does', () => { - const items = [...ITEMS, approval('live-approval', 3)] - expect( - actionableStructuredApproval({ items, actionablePromptIds: ['live-approval'] })?.itemId - ).toBe('live-approval') - expect( - actionableStructuredQuestion({ items, actionablePromptIds: ['live-approval'] }) - ).toBeNull() - }) - - it('is the first pending one when an older host names none', () => { - expect(actionableStructuredApproval({ items: ITEMS })?.itemId).toBe('dead-approval') - expect(actionableStructuredQuestion({ items: ITEMS })?.itemId).toBe('dead-question') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts index 1f22d20e6d23..7a3e6864096c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.test.ts @@ -1,19 +1,10 @@ // The stop-note refresh frame carries rows, so it restates the host's current work as every rows -// frame does: a client reads a rows frame without it as an older host's and falls back to its own -// raw derivation (an ended turn as Working, an ended agent's prompt as answerable). +// frame does: a client reads a rows frame without it as an older host's. import { 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 { - AgentSessionHistoryPage, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession -} from '../../../shared/structured-agent-session-reducer' -import { isActionableStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-live-turn' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' @@ -55,31 +46,8 @@ const projectedNote: AgentJournalRenderItem = { } const items = [staleTurn, stalePrompt, projectedNote] -/** What a current host publishes: the ended generation's turn and prompt are not current. */ -function page(): AgentSessionHistoryPage { - return { - sessionId: 's', - epoch: 'e', - direction: 'tail', - items, - removedItemIds: [], - submissions: [], - window: { - oldest: { epoch: 'e', sequence: 1 }, - newest: { epoch: 'e', sequence: 3 }, - nextCursor: { epoch: 'e', sequence: 1 } - }, - liveCursor: { epoch: 'e', sequence: 3 }, - hasOlder: false, - hasNewer: false, - latestTurn: null, - actionablePromptIds: [], - working: false - } -} - describe('the stop-note refresh frame', () => { - it("restates the host's current work, so a client keeps the host's answers", () => { + it("restates the host's current work", () => { const emitted: AgentSessionSubscribeEvent[] = [] const journal = { visitItems: (visit: (itemId: string, sequence: number, body: unknown) => void) => @@ -125,16 +93,5 @@ describe('the stop-note refresh frame', () => { } expect(frame.batch.items.length).toBeGreaterThan(0) expect(frame).toMatchObject({ working: false, actionablePromptIds: [], latestTurn: null }) - - let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'event', - event: { type: 'snapshot', sessionId: 's', page: page(), fence: 2 } - }) - state = reduceStructuredAgentSession(state, { type: 'event', event: frame }) - expect(state.working).toBe(false) - expect(state.latestTurn).toBeNull() - expect(isActionableStructuredAgentSessionPrompt('prompt-old', state.actionablePromptIds)).toBe( - false - ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts index 351d48db55d8..8a2c77554395 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts @@ -1,8 +1,6 @@ -// A Cancel that names a card an ended generation raised, from a client that still shows it. As on -// main: the card is dismissed in the journal alone, and a provider whose card Cancel routes -// (Claude, Pi) stops nothing. One with no route gets the turn's own cancel with the card, never the -// chat's Stop, so queued messages stay and no Stop event pauses the queue: ACP interrupts the live -// turn the request names; Codex finds no such card in its live child and interrupts nothing. +// A Cancel that names a question an ended generation raised, from a client that still shows it: +// the card is dismissed in the journal alone. Main ran the chat's Stop here, which stopped the live +// turn and withdrew the queue. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -22,7 +20,7 @@ import type { StructuredAgentSessionChatStopRun } from './structured-agent-sessi import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' -import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -47,9 +45,7 @@ afterEach(async () => { /** Generation 1 raised a card and ended without its cleanup; generation 2 runs `turn-2`, with a * message queued behind it. */ -async function endedCardAndLiveTurn( - kind: 'approval' | 'question' = 'approval' -): Promise<{ journal: AgentSessionJournal; itemId: string }> { +async function endedCardAndLiveTurn(): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-ended-prompt-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const turn = (turnId: string, fence: number) => @@ -67,20 +63,12 @@ async function endedCardAndLiveTurn( } const card = await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, - kind === 'approval' - ? { - kind: 'approval', - title: 'Approve?', - detail: null, - options: [{ id: 'allow', label: 'Allow' }], - resolution: pending - } - : { - kind: 'question', - question: 'Which?', - options: [{ id: 'a', label: 'A' }], - resolution: pending - }, + { + kind: 'question', + question: 'Which?', + options: [{ id: 'a', label: 'A' }], + resolution: pending + }, { fence: ENDED, turnScope: { kind: 'turn', turnItemId: old.itemId } } ) await turn('turn-2', LIVE) @@ -143,84 +131,25 @@ function resolution(journal: AgentSessionJournal, itemId: string) { return body?.kind === 'approval' || body?.kind === 'question' ? body.resolution.state : undefined } -describe('a card an ended generation raised, cancelled from a client that still shows it', () => { - const pi = () => ({ kind: 'dismiss' as const }) - it.each([ - ['Claude approval', 'approval', claudePromptCancelRoute], - ['Claude question', 'question', claudePromptCancelRoute], - ['Pi (every card dismisses)', 'approval', pi] - ] as const)( - '%s: dismissed only, even when the request names the live turn', - async (_name, kind, route) => { - const { journal, itemId } = await endedCardAndLiveTurn(kind) - const agent = provider(route) - const stop = chatStop() - const interrupt = vi.fn() - - const result = await cancelStructuredAgentSessionPrompt( - context(journal, agent), - { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } }, - { stop, interrupt } - ) - - expect(result).toMatchObject({ ok: true, value: { turnId: 'turn-2', cancelled: true } }) - expect(resolution(journal, itemId)).toBe('cancelled') - expect(stop).not.toHaveBeenCalled() - expect(interrupt).not.toHaveBeenCalled() - expect(agent.cancelTurn).not.toHaveBeenCalled() - expect(agent.dismissPrompt).not.toHaveBeenCalled() - queueUntouched(journal) - } - ) - - it.each([ - ['ACP: the named live turn is interrupted', true], - ['Codex: its live child holds no such card, so nothing is interrupted, as on main', false] - ] as const)("no card route, through the turn's own cancel. %s", async (_name, interrupts) => { +describe('a question an ended generation raised, cancelled from a client that still shows it', () => { + it('is dismissed only, even when the request names the live turn: no Stop, the queue untouched', async () => { const { journal, itemId } = await endedCardAndLiveTurn() - const agent = provider() - agent.cancelTurn.mockResolvedValueOnce({ cancelled: interrupts }) - const stop = chatStop() - const ctx = context(journal, agent) - const input = { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } } - - const result = await cancelStructuredAgentSessionPrompt(ctx, input, { - stop, - // What the host's plan runs: the turn's cancel, with the card, as on main. - interrupt: () => performCancel(ctx, { clientOperationId: 'cancel-1', ...input }) - }) - - expect(result).toMatchObject({ ok: true, value: { cancelled: interrupts } }) - expect(agent.cancelTurn).toHaveBeenCalledOnce() - expect(agent.cancelTurn.mock.calls[0]?.[0]).toMatchObject({ - turnId: 'turn-2', - prompt: { itemId } - }) - expect(stop).not.toHaveBeenCalled() - expect(resolution(journal, itemId)).toBe('cancelled') - queueUntouched(journal) - }) - - it.each([ - ['naming no turn', undefined], - ["naming the ended generation's turn", 'turn-1'] - ])('Codex or ACP, %s: dismissed only, and the live turn runs on', async (_name, turnId) => { - const { journal, itemId } = await endedCardAndLiveTurn() - const agent = provider() + const agent = provider(claudePromptCancelRoute) const stop = chatStop() const interrupt = vi.fn() const result = await cancelStructuredAgentSessionPrompt( context(journal, agent), - { ...(turnId ? { turnId } : {}), prompt: { itemId, expectedRevision: 1 } }, + { turnId: 'turn-2', prompt: { itemId, expectedRevision: 1 } }, { stop, interrupt } ) - expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(result).toMatchObject({ ok: true, value: { turnId: 'turn-2', cancelled: true } }) expect(resolution(journal, itemId)).toBe('cancelled') - expect(interrupt).not.toHaveBeenCalled() expect(stop).not.toHaveBeenCalled() + expect(interrupt).not.toHaveBeenCalled() expect(agent.cancelTurn).not.toHaveBeenCalled() + expect(agent.dismissPrompt).not.toHaveBeenCalled() queueUntouched(journal) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts deleted file mode 100644 index 530ef37163e0..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-actionable-prompts.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// The desktop chat answers only the prompts the host says still wait on the person: one an agent -// that has ended raised gets no card, and an older host, which names none, keeps today's rule. - -import { describe, expect, it } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer' -import { pendingStructuredSessionPrompts } from './structured-agent-session-message-projection' -import { structuredAttentionReadObservation } from './structured-attention-read-observation' - -function approval(itemId: string, sequence: number): AgentJournalRenderItem { - return { - itemId, - revision: 1, - sequence, - observedAt: sequence, - body: { - kind: 'approval', - title: 'Run command?', - detail: null, - options: [{ id: 'yes', label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - } -} - -const ITEMS = [approval('live-prompt', 1), approval('dead-prompt', 2)] -const ids = (items: AgentJournalRenderItem[]) => items.map((item) => item.itemId) - -describe('the prompt cards a desktop chat offers', () => { - it('are the ones the host names', () => { - expect(ids(pendingStructuredSessionPrompts(ITEMS, ['live-prompt']))).toEqual(['live-prompt']) - expect(pendingStructuredSessionPrompts(ITEMS, [])).toEqual([]) - }) - - it('are every pending prompt when an older host names none', () => { - expect(ids(pendingStructuredSessionPrompts(ITEMS))).toEqual(['live-prompt', 'dead-prompt']) - }) -}) - -describe('what marks a chat read', () => { - it('changes when the host stops naming a prompt, with no row changing', () => { - const ask: AgentJournalRenderItem = { - itemId: 'ask', - revision: 1, - sequence: 0, - observedAt: 0, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } - } - const state = { ...EMPTY_STRUCTURED_AGENT_SESSION, items: [ask, ...ITEMS] } - const named = structuredAttentionReadObservation({ - ...state, - actionablePromptIds: ['live-prompt'] - }) - const ended = structuredAttentionReadObservation({ ...state, actionablePromptIds: [] }) - expect(ended).not.toBe(named) - }) -}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 7b4b692dfe49..5653e8f25b19 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -164,13 +164,6 @@ describe('queued message cards', () => { expect(cards.map((card) => queuedMessageCardSteers(card))).toEqual([true, true]) }) - it('a waiting card still offers Steer while nothing runs, as when its agent died and its cleanup waits', () => { - const cards = projectQueuedMessageCards([draft('waiting', 1)], [], { agentWorking: false }) - expect(cards.map((card) => [card.hold, queuedMessageCardSteers(card)])).toEqual([ - ['turn', true] - ]) - }) - it("the header names the queue's pause while it holds a card, and none over cards Resume would not send", () => { const stopped = { reason: 'stopped' } as const const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx index 4edac4098a2e..ae005ce95e17 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.working.test.tsx @@ -1,11 +1,11 @@ // @vitest-environment happy-dom // Whether the chat's own agent is working is the host's answer when it gives one: it counts only -// the agent running now, so a turn an ended agent left running reads as stopped and Resume shows. +// the agent running now, so a turn an ended agent left running reads as stopped. // An older host gives none, and the chat derives it from its rows as before. import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' import { @@ -13,14 +13,7 @@ import { reduceStructuredAgentSession, type StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: vi.fn() -})) - import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' -import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' afterEach(cleanup) @@ -76,78 +69,4 @@ describe('the chat’s Working', () => { expect(loaded({}).working).toBeUndefined() expect(isWorking(loaded({}))).toBe(true) }) - - it('follows a frame that restates it with no row, and keeps it across a frame that names none', () => { - const stopped = reduceStructuredAgentSession(loaded({ working: true }), { - type: 'event', - event: { - type: 'batch', - sessionId: 'session-a', - batch: { - items: [], - removedItemIds: [], - submissions: [], - cursor: { epoch: 'epoch-a', sequence: 1 } - }, - working: false - } - }) - expect(stopped.working).toBe(false) - expect(isWorking(stopped)).toBe(false) - const quiet = reduceStructuredAgentSession(stopped, { - type: 'event', - event: { - type: 'batch', - sessionId: 'session-a', - batch: { - items: [], - removedItemIds: [], - submissions: [], - cursor: { epoch: 'epoch-a', sequence: 1 } - } - } - }) - expect(quiet.working).toBe(false) - }) - - it('offers Resume over held cards once the host says the agent stopped; an older host’s rows hide it', () => { - const held = { - queuePause: { reason: 'stopped' as const }, - queuedMessages: [ - { - messageId: 'held', - position: 1, - body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, - state: 'waiting' as const - } - ] - } - const resume = (state: StructuredAgentSessionState) => - renderHook(() => { - const transport = useStructuredAgentSessionTransportState(state, true) - const { mutate } = useStructuredAgentSessionMutate({ - sessionId: 'session-a', - target: { kind: 'local' }, - stateRef: { current: { fence: 1 } } - }) - return useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: transport.queuedMessages ?? [], - queuePause: transport.queuePause, - submissions: [], - hasPendingPrompt: false, - isWorking: transport.isWorking, - composerScopeKey: undefined, - mutate, - editTransport: { - target: { kind: 'local' }, - sessionId: 'session-a', - capable: false, - write: async () => ({ kind: 'dropped' }) - } - }) - }).result.current.queueResume - expect(resume(loaded({ ...held, working: false }))).toBeDefined() - expect(resume(loaded(held))).toBeUndefined() - }) }) diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index c784fecbee3f..af4855706da2 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -534,12 +534,6 @@ describe('the notice for every reason a host names', () => { 'answer', 'This question was already answered or has changed.' ], - [ - 'agent_session_operation_invalid', - 'promptOwnerEnded', - 'answer', - 'The agent has stopped, so your answer was not sent. Send a message to continue.' - ], [ 'agent_session_checkpoint_stale', 'fenceStale', @@ -564,20 +558,6 @@ describe('an answer to a prompt whose agent stopped', () => { 'Codex has stopped, so your answer was not sent. Send a message to continue.' ) }) - - it.each(['providerStartFailed', 'notSignedIn', 'cliMissing', 'historyTooLarge'] as const)( - 'keeps that sentence its own: %s still reads "Your answer was not sent."', - (reason) => { - const refusal = { - code: 'agent_session_operation_invalid' as const, - message: HOST_TEXT, - details: { reason } - } - expect(agentSessionRefusalNotice(refusal, 'answer', { agentName: 'Codex' })).toBe( - 'Your answer was not sent.' - ) - } - ) }) describe('a refusal from a host that names no reason this build knows', () => { diff --git a/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts b/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts deleted file mode 100644 index 91fecb6b5195..000000000000 --- a/src/shared/native-chat-messages-waiting-behind-live-turn.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentJournalRenderItem } from './agent-session-journal-types' -import { nativeChatMessagesWaitingBehindLiveTurn } from './native-chat-messages-waiting-behind-live-turn' - -/** A /compact whose turn record still reads running. */ -const compactRunning: AgentJournalRenderItem[] = [ - { - itemId: 'compact-entry', - revision: 0, - sequence: 1, - observedAt: 1, - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - }, - { - itemId: 'compact-turn', - revision: 0, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'compact-turn', userItemId: 'compact-entry', state: 'running' } - } -] -const queued = [{ id: 'card-1', role: 'user' as const, queued: true as const }] - -describe('a queued card behind a running /compact', () => { - it('waits behind it while the host says it runs, or an older host says nothing', () => { - expect(nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning)).toEqual( - new Set(['card-1']) - ) - const latestTurn = { - itemId: 'compact-turn', - observedAt: 2, - turn: { turnId: 'compact-turn', state: 'running' as const } - } - expect( - nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning, false, [], { latestTurn }) - ).toEqual(new Set(['card-1'])) - }) - - it('waits behind nothing once the host says no turn runs: the agent that ran it ended', () => { - expect( - nativeChatMessagesWaitingBehindLiveTurn(queued, compactRunning, false, [], { - latestTurn: null, - working: false - }) - ).toEqual(new Set()) - }) -}) diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 1838740ae486..8a8343a1e904 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionHistoryPage, AgentSessionLatestTurn } from './agent-session-wire' -import { - isActionableStructuredAgentSessionPrompt, - runningStructuredAgentSessionTurnId -} from './structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession @@ -598,12 +595,12 @@ describe('structured agent session reducer', () => { }) }) - describe("the host's actionable prompts", () => { - const prompt = (itemId: string, sequence: number): AgentJournalRenderItem => ({ - itemId, + it("keeps the host's working answer and prompts across a quiet frame, takes a row-less restatement, and drops them for rows that name none", () => { + const pending: AgentJournalRenderItem = { + itemId: 'prompt', revision: 1, - sequence, - observedAt: sequence, + sequence: 1, + observedAt: 1, body: { kind: 'approval', title: 'Run command?', @@ -611,60 +608,45 @@ describe('structured agent session reducer', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } } - }) - const PROMPTS = [prompt('live-prompt', 1), prompt('dead-prompt', 2)] - const opened = (page: AgentSessionHistoryPage) => - reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'event', - event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page } - }) - const batch = ( - state: ReturnType, + } + const frame = ( items: AgentJournalRenderItem[], - actionablePromptIds?: string[] - ) => - reduceStructuredAgentSession(state, { - type: 'event', - event: { - type: 'batch', - sessionId: 'session-a', - batch: { - cursor: { epoch: 'epoch-a', sequence: state.cursor!.sequence + items.length }, - items, - removedItemIds: [], - submissions: [] - }, - ...(actionablePromptIds !== undefined ? { actionablePromptIds } : {}) - } - }) - const actionable = (state: ReturnType) => - state.items - .filter((entry) => - isActionableStructuredAgentSessionPrompt(entry.itemId, state.actionablePromptIds) - ) - .map((entry) => entry.itemId) - - it('reads them from the snapshot, keeps them across a frame without rows, and takes the next', () => { - const state = opened({ ...hydrationPage(PROMPTS), actionablePromptIds: ['live-prompt'] }) - expect(actionable(state)).toEqual(['live-prompt']) - const quiet = batch(state, []) - expect(quiet.actionablePromptIds).toEqual(['live-prompt']) - // The generation ended with no row written: the host's frame names none. - const ended = batch(quiet, [], []) - expect(actionable(ended)).toEqual([]) - }) - - it('an older host names none: its page reduces, and every pending prompt stays answerable', () => { - // As an older host serialized it: no such field at all. - const older: AgentSessionHistoryPage = JSON.parse(JSON.stringify(hydrationPage(PROMPTS))) - const state = opened(older) - expect(state.actionablePromptIds).toBeUndefined() - expect(actionable(state)).toEqual(['live-prompt', 'dead-prompt']) - // Rows from a downgraded host restate nothing: back to every pending prompt. - const named = opened({ ...hydrationPage(PROMPTS), actionablePromptIds: ['live-prompt'] }) - const downgraded = batch(named, [prompt('another', 3)]) - expect(downgraded.actionablePromptIds).toBeUndefined() + host: { working?: boolean; actionablePromptIds?: string[] } = {} + ) => ({ + type: 'event' as const, + event: { + type: 'batch' as const, + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 1 }, + items, + removedItemIds: [], + submissions: [] + }, + ...host + } + }) + const opened = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: { ...hydrationPage([pending]), working: true, actionablePromptIds: ['prompt'] } + } }) + const quiet = reduceStructuredAgentSession(opened, frame([])) + expect(quiet).toMatchObject({ working: true, actionablePromptIds: ['prompt'] }) + // The generation ended with no row written: the host restates both. + const ended = reduceStructuredAgentSession( + quiet, + frame([], { working: false, actionablePromptIds: [] }) + ) + expect(ended).toMatchObject({ working: false, actionablePromptIds: [] }) + // Rows from a downgraded host name neither: back to deriving from rows. + const downgraded = reduceStructuredAgentSession(ended, frame([{ ...pending, revision: 2 }])) + expect(downgraded.working).toBeUndefined() + expect(downgraded.actionablePromptIds).toBeUndefined() }) it('records the host clock from frames that carry it and keeps it otherwise', () => { From f0a45c2bfd2c9d9aa014e7b274d7822d1b4b935e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:07:13 -0700 Subject: [PATCH 08/13] test(native-chat): an unstamped roster entry from an older build reads as its row provenance Moved here from Part 4 so it sits beside stampJournalEntryProvenance. --- .../journal-item-provenance.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index 0e3cac81ddeb..aea4b86e3552 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -13,6 +13,7 @@ import { type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { isSubagentGroupBlock } from '../../../shared/native-chat-types' import { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows' import type { JournalHostDatabase } from './journal-host-database' import { @@ -20,6 +21,8 @@ import { liveTestJournalRows, openTestJournalHostDatabase } from './journal-host-database-test-support' +import { stampJournalEntryProvenance } from './journal-item-provenance' +import { subagentGroupJournalBody } from './journal-subagent-group-body' import type { AgentSessionJournal } from './journal-store' const IDENTITY: AgentSessionJournalIdentity = { @@ -128,4 +131,13 @@ describe('execution provenance', () => { const { ownerFence: _stated, ...older } = row expect(retainedRowReplacement(older).ownerFence).toBe(0) }) + + it('reads a roster entry an older build never stamped as its row’s provenance', () => { + const roster = subagentGroupJournalBody('group', [ + { id: 'a', label: 'A', state: 'working', startedAt: 1 } + ]) + const stamped = stampJournalEntryProvenance(roster, { body: roster, ownerFence: 2 }, 3) + const groups = stamped.kind === 'message' ? stamped.blocks.filter(isSubagentGroupBlock) : [] + expect(groups[0]?.agents[0]?.ownerFence).toBe(2) + }) }) From ada76fe63756987a9eab1c7785b80e9822f3ac42 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:46:49 -0700 Subject: [PATCH 09/13] test(native-chat): build the older-build roster row inline, after main removed its body helper Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads. --- .../journal-item-provenance.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index aea4b86e3552..5a464ccb536c 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -22,7 +22,6 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { stampJournalEntryProvenance } from './journal-item-provenance' -import { subagentGroupJournalBody } from './journal-subagent-group-body' import type { AgentSessionJournal } from './journal-store' const IDENTITY: AgentSessionJournalIdentity = { @@ -133,9 +132,17 @@ describe('execution provenance', () => { }) it('reads a roster entry an older build never stamped as its row’s provenance', () => { - const roster = subagentGroupJournalBody('group', [ - { id: 'a', label: 'A', state: 'working', startedAt: 1 } - ]) + const roster: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'subagent-group', + groupId: 'group', + agents: [{ id: 'a', label: 'A', state: 'working', startedAt: 1 }] + } + ] + } const stamped = stampJournalEntryProvenance(roster, { body: roster, ownerFence: 2 }, 3) const groups = stamped.kind === 'message' ? stamped.blocks.filter(isSubagentGroupBlock) : [] expect(groups[0]?.agents[0]?.ownerFence).toBe(2) From d4471e8f4084e6b8b487f8e5f3f97e049683a017 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:11:57 -0700 Subject: [PATCH 10/13] refactor(native-chat): provenance is per item only; drop per-entry stamping Main's #27947 writes one saved row per subagent, and each background task already has its own row, so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps inside a row mattered only for the shared roster rows saved before #27947, and no run writes into those any more, so the row's own provenance settles them correctly. Removes the entry stamping, the optional ownerFence on roster entries and background tasks, and their tests. --- .../journal-item-provenance.test.ts | 19 --- .../journal-item-provenance.ts | 115 +----------------- .../journal-row-builders.ts | 19 +-- ...der-timeline-assembler-attribution.test.ts | 11 +- src/shared/agent-session-journal-schemas.ts | 6 +- src/shared/native-chat-types.ts | 5 - 6 files changed, 11 insertions(+), 164 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index 5a464ccb536c..0e3cac81ddeb 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -13,7 +13,6 @@ import { type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { isSubagentGroupBlock } from '../../../shared/native-chat-types' import { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows' import type { JournalHostDatabase } from './journal-host-database' import { @@ -21,7 +20,6 @@ import { liveTestJournalRows, openTestJournalHostDatabase } from './journal-host-database-test-support' -import { stampJournalEntryProvenance } from './journal-item-provenance' import type { AgentSessionJournal } from './journal-store' const IDENTITY: AgentSessionJournalIdentity = { @@ -130,21 +128,4 @@ describe('execution provenance', () => { const { ownerFence: _stated, ...older } = row expect(retainedRowReplacement(older).ownerFence).toBe(0) }) - - it('reads a roster entry an older build never stamped as its row’s provenance', () => { - const roster: AgentJournalItemBody = { - kind: 'message', - role: 'assistant', - blocks: [ - { - type: 'subagent-group', - groupId: 'group', - agents: [{ id: 'a', label: 'A', state: 'working', startedAt: 1 }] - } - ] - } - const stamped = stampJournalEntryProvenance(roster, { body: roster, ownerFence: 2 }, 3) - const groups = stamped.kind === 'message' ? stamped.blocks.filter(isSubagentGroupBlock) : [] - expect(groups[0]?.agents[0]?.ownerFence).toBe(2) - }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts index 06f37469f5b7..e78edbbac6c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts @@ -1,98 +1,8 @@ -// Execution provenance: the generation whose execution produced an item (`JournalItemRow.ownerFence`) -// and each entry of a container row. -// -// Each entry of a container row: every subagent roster entry and every -// background task. One roster row is revised in place across generations (a resumed provider keeps -// the group's identity), so the row's own provenance cannot say which generation each child -// belongs to. A provider's observation stamps the entries it changed or added with its own -// generation; an entry it carries unchanged keeps the one it had (or, never stamped, the row's -// earlier provenance). Host bookkeeping states no provenance and stamps nothing. +// Execution provenance: the generation whose execution produced an item (`JournalItemRow.ownerFence`), +// kept apart from each row's writer fence. A provider's observation states it; host bookkeeping +// keeps the item's. -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import type { JournalReducerState } from './journal-reducer' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatBlock -} from '../../../shared/native-chat-types' - -type Entry = { ownerFence?: number } - -/** An entry's observed fields, in a fixed order: its provenance is not part of what was seen. */ -function observationKey(entry: Entry): string { - const { ownerFence: _ownerFence, ...fields } = entry - return JSON.stringify(Object.entries(fields).sort(([left], [right]) => left.localeCompare(right))) -} - -function stamp( - entry: T, - previous: T | undefined, - previousItemFence: number | undefined, - ownerFence: number -): T { - if (previous && observationKey(previous) === observationKey(entry)) { - const kept = previous.ownerFence ?? previousItemFence - return kept === undefined || kept === entry.ownerFence ? entry : { ...entry, ownerFence: kept } - } - // No earlier entry to compare: one that already names its generation (history carried into a new - // epoch) keeps it. - if (!previous && entry.ownerFence !== undefined) { - return entry - } - return entry.ownerFence === ownerFence ? entry : { ...entry, ownerFence } -} - -function hasEntries(body: AgentJournalItemBody): boolean { - return ( - body.kind === 'message' && - body.blocks.some((block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block)) - ) -} - -/** The body as written by an observation of generation `ownerFence`, each entry stamped. */ -export function stampJournalEntryProvenance( - body: AgentJournalItemBody, - previous: { body: AgentJournalItemBody; ownerFence?: number } | undefined, - ownerFence: number -): AgentJournalItemBody { - if (body.kind !== 'message' || !hasEntries(body)) { - return body - } - const earlier: readonly NativeChatBlock[] = - previous?.body.kind === 'message' ? previous.body.blocks : [] - const blocks = body.blocks.map((block): NativeChatBlock => { - if (isSubagentGroupBlock(block)) { - const group = earlier.find( - (candidate) => isSubagentGroupBlock(candidate) && candidate.groupId === block.groupId - ) - const agents = group && isSubagentGroupBlock(group) ? group.agents : [] - return { - ...block, - agents: block.agents.map((agent) => - stamp( - agent, - agents.find((candidate) => candidate.id === agent.id), - previous?.ownerFence, - ownerFence - ) - ) - } - } - if (isBackgroundTaskBlock(block)) { - const task = earlier.find( - (candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === block.taskId - ) - return stamp( - block, - task && isBackgroundTaskBlock(task) ? task : undefined, - previous?.ownerFence, - ownerFence - ) - } - return block - }) - return { ...body, blocks } -} /** The provenance a row states: the writer's own claim when it has one — a provider's observation, * or history carried into a new epoch — else host bookkeeping's, which keeps the item's, and for @@ -108,22 +18,3 @@ export function journalItemOwnerFence( const kept = state.items.has(resolvedItemId) ? state.itemFences.get(resolvedItemId) : undefined return kept ?? write.fence } - -/** A provider's observation stamps each roster entry and background task it saw - * (`stampJournalEntryProvenance`); host bookkeeping states no provenance and stamps nothing. */ -export function journalObservedBody( - state: Pick, - resolvedItemId: string, - body: AgentJournalItemBody, - ownerFence: number | undefined -): AgentJournalItemBody { - if (ownerFence === undefined) { - return body - } - const previous = state.items.get(resolvedItemId) - return stampJournalEntryProvenance( - body, - previous && { body: previous.body, ownerFence: state.itemFences.get(resolvedItemId) }, - ownerFence - ) -} 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 229e1656ec5f..6a0c6ab9759d 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 @@ -13,7 +13,7 @@ import { agentSessionJournalProviderHandle } from '../../../shared/agent-session import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { journalItemOwnerFence, journalObservedBody } from './journal-item-provenance' +import { journalItemOwnerFence } from './journal-item-provenance' import type { JournalReducerState } from './journal-reducer' import type { JournalDispatchRow, @@ -190,15 +190,7 @@ export function journalLifecycleBatchRowBuilder( const revision = nextJournalItemRevision(current, itemId, runningRevisions) return journalLifecycleMutationRow( mutation.kind === 'item' - ? { - ...mutation, - body: journalObservedBody( - current, - resolved, - turnEndAfterStop(current, resolved, mutation.body), - mutation.ownerFence - ) - } + ? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) } : mutation, itemId, revision, @@ -258,12 +250,7 @@ export function buildJournalItemRow( // A tombstoned row keeps its revision in `tombstones`, and the reducer drops // any item at or below it — so a re-add has to outrank the tombstone too. const revision = nextJournalItemRevision(input.state, itemId, input.revisions) - const body = journalObservedBody( - input.state, - resolved, - turnEndAfterStop(input.state, resolved, input.body), - input.ownerFence - ) + const body = turnEndAfterStop(input.state, resolved, input.body) return { kind: 'item', itemId, 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 82703eaf0d70..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 @@ -215,14 +215,9 @@ describe('a background task', () => { 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' } }) - // The entry keeps the generation that ran it (`ownerFence`), stamped when it was observed. - const unverifiable = backgroundTask('bg', 'unverifiable') - expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual({ - ...unverifiable, - blocks: unverifiable.blocks.map((block) => - block.type === 'background-task' ? { ...block, ownerFence: 1 } : block - ) - }) + expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual( + backgroundTask('bg', 'unverifiable') + ) expect(await backgroundTaskState(rig, 'ran')).toBe('done') }) }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 51c8bb40aab6..5cd2f87caf2f 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -70,8 +70,7 @@ const SubagentEntry = z.object({ state: z.string().min(1), tokens: z.number().optional(), startedAt: z.number().optional(), - settledAt: z.number().optional(), - ownerFence: z.number().int().nonnegative().optional() + settledAt: z.number().optional() }) /** Renderers select blocks by `type` equality and skip what they cannot draw, @@ -135,8 +134,7 @@ const Block = openDiscriminatedUnion( outputFile: z.string().optional(), tokens: z.number().optional(), startedAt: z.number().optional(), - settledAt: z.number().optional(), - ownerFence: z.number().int().nonnegative().optional() + settledAt: z.number().optional() }) ]) ) diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index e39be210eea2..ea35e3ac5318 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -151,9 +151,6 @@ export type NativeChatSubagentEntry = { startedAt?: number /** Epoch ms the entry latched terminal. */ settledAt?: number - /** The agent generation (lease fence) whose execution this entry last observed: stamped by the - * journal on a fresh observation, kept when carried unchanged. Absent: the row's own. */ - ownerFence?: number } /** One spawn group's roster, revised in place as its children report activity. @@ -216,8 +213,6 @@ export type NativeChatBackgroundTaskBlock = { startedAt?: number /** Epoch ms the row latched terminal. */ settledAt?: number - /** As `NativeChatSubagentEntry.ownerFence`. */ - ownerFence?: number } export type NativeChatBlock = From efa2676c7b56458a095bc965194b67d6944b9a46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:57:34 -0700 Subject: [PATCH 11/13] fix(native-chat): an item's provenance is set when it is created; revisions keep it A newer run's provider can revise an item an older run created: Pi's start and an ACP session/load both report context usage onto the newest saved turn. Restamping that turn with the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted. Only creation states an owner now, as main's itemFences rule did. --- .../agent-session-journal/journal-item-fold.ts | 7 ++----- .../journal-item-provenance.test.ts | 8 +++++--- .../journal-item-provenance.ts | 14 +++++--------- .../journal-lifecycle-batch-appender.ts | 2 +- .../structured-agent-session-event-sink.ts | 2 +- ...uctured-agent-session-journal-append-options.ts | 2 +- 6 files changed, 15 insertions(+), 20 deletions(-) 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 94e1c85d5bc2..c45d8975b25d 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 @@ -28,7 +28,8 @@ export function upsertJournalItem( itemId: string, revision: number, next: AgentJournalRenderItem, - /** The write's fence, and the provenance it states (`JournalItemRow.ownerFence`). */ + /** The write's fence, and the provenance it states (`JournalItemRow.ownerFence`), which only + * creation reads: a revision keeps the item's. */ fences: { fence: number; ownerFence?: number } ): void { const tombstoned = state.tombstones.get(itemId) @@ -46,10 +47,6 @@ export function upsertJournalItem( state.tombstones.delete(itemId) return } - // A revision keeps the provenance it found unless it states one: an owner's observation does. - if (fences.ownerFence !== undefined) { - state.itemFences.set(itemId, fences.ownerFence) - } // Creation sequence is the ordering key; a revision refreshes content only. // `observedAt` is pinned with it: clients sort the timeline by that timestamp, // so letting a revision advance it makes the row jump past everything that diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index 0e3cac81ddeb..8d2f1123980d 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -1,5 +1,5 @@ // Execution provenance (`JournalItemRow.ownerFence`): the generation whose execution produced an -// item, kept apart from each row's writer fence. Every writer states it; the fold prefers it. +// item, kept apart from each row's writer fence. Creation states it; every revision keeps it. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -74,9 +74,11 @@ afterEach(async () => { }) describe('execution provenance', () => { - it("records a provider observation's owner, a host revision keeps it, and a reopen reads it", async () => { + it("records the creating observation's owner, every revision keeps it, and a reopen reads it", async () => { const journal = await open() await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) + // A newer run's provider revising it (a start's context-usage report on the newest saved turn). + await journal.appendItem(TURN, RUNNING, { fence: 5, ownerFence: 5, turnScope: SCOPE }) // Host bookkeeping under a later writer fence states no owner of its own. await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE }) // A first write with no stated owner is its writer generation's. @@ -89,7 +91,7 @@ describe('execution provenance', () => { expect(journal.itemFence(TURN_ID)).toBe(3) expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6) // Every row states it: none leaves the reader to guess from the writer fence. - expect(storedOwnerFences()).toEqual([3, 3, 6]) + expect(storedOwnerFences()).toEqual([3, 3, 3, 6]) await journals.closeAll() expect((await open()).itemFence(TURN_ID)).toBe(3) }) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts index e78edbbac6c9..afb1df346d1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.ts @@ -1,20 +1,16 @@ // Execution provenance: the generation whose execution produced an item (`JournalItemRow.ownerFence`), -// kept apart from each row's writer fence. A provider's observation states it; host bookkeeping -// keeps the item's. +// kept apart from each row's writer fence. The write that creates an item states it; every revision +// keeps it. import type { JournalReducerState } from './journal-reducer' -/** The provenance a row states: the writer's own claim when it has one — a provider's observation, - * or history carried into a new epoch — else host bookkeeping's, which keeps the item's, and for - * an item it creates is the fence it writes at. Every item row states it. */ +/** An existing item keeps its owner whoever revises it: a newer run's start revises the newest saved + * turn. Creation states the writer's claim (an observation, carried history), else its fence. */ export function journalItemOwnerFence( state: Pick, resolvedItemId: string, write: { fence: number; ownerFence?: number } ): number { - if (write.ownerFence !== undefined) { - return write.ownerFence - } const kept = state.items.has(resolvedItemId) ? state.itemFences.get(resolvedItemId) : undefined - return kept ?? write.fence + return kept ?? write.ownerFence ?? write.fence } 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 9ae46af77acc..f906fadfbed4 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 @@ -81,7 +81,7 @@ export class JournalLifecycleBatchAppender { if (this.wasApplied(input.settlementId)) { return [] } - // A provider's batch states its generation on every item it writes. + // A provider's batch states its generation on every item it creates. const { ownerFence } = input const mutations = ownerFence === undefined 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 d375bc9cddd7..880a7dfa86b9 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 @@ -247,7 +247,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { mutations, // No row-level linkage: each mutation names its own (see the batch row builder). fence: bound.fence, - // The provider's own observation: its child's generation produced every item. + // The provider's own observation: its child's generation produced every item it creates. ownerFence: bound.fence }) }, 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 4aa4d0f646da..26bec39b190f 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 @@ -5,7 +5,7 @@ // the next change remembers. Lifecycle batches carry producers per mutation. // // The sink carries only the provider's own observations, bound at its child's fence: that fence is -// the generation whose execution produced each item (`JournalItemRow.ownerFence`). +// the generation whose execution produced each item it creates (`JournalItemRow.ownerFence`). import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' From 28edc8d90d5d2a78b65125749d3493147d53c124 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:20:31 -0700 Subject: [PATCH 12/13] test(native-chat): keep the provenance test below the production lines it covers --- .../agent-session-journal/journal-item-provenance.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts index 8d2f1123980d..8e94ef30ac9a 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts @@ -120,12 +120,7 @@ describe('execution provenance', () => { }) it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => { - const row = { - itemId: TURN_ID, - body: RUNNING, - observedAt: 1, - ownerFence: 2 - } + const row = { itemId: TURN_ID, body: RUNNING, observedAt: 1, ownerFence: 2 } expect(retainedRowReplacement(row).ownerFence).toBe(2) const { ownerFence: _stated, ...older } = row expect(retainedRowReplacement(older).ownerFence).toBe(0) From e495841afff31f2698be8b51ba98d8144686b0a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:27:30 -0700 Subject: [PATCH 13/13] fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation receipt and without the dismissal's failure handling, so a receipt write failing after it would answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's adapter refuses a fence with no live session outright). --- .../structured-agent-session-prompt-cancel.ts | 24 +++++++------ .../structured-agent-session-prompt-state.ts | 34 ------------------- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts index 81da15e90345..9fe28ba770aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts @@ -10,7 +10,6 @@ import { AgentSessionPromptUnavailableError } from './structured-agent-session-a import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' import { answerCancelOfSettledPrompt, - dismissEndedGenerationPrompt, isEndedGenerationPrompt, validatePendingPrompt, type PendingPromptValidation @@ -92,7 +91,7 @@ async function cancelEndedGenerationPrompt( expectedRevision: pending.item.revision }) if (still.ok) { - const dismissed = await dismissEndedGenerationPrompt(ctx, still) + const dismissed = await dismissPrompt(ctx, still, null) if (!dismissed.ok) { return dismissed } @@ -112,11 +111,12 @@ function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): } /** Records the card as cancelled by the caller, after every row the provider already sent (each - * landed at its call); `answer` also declines the provider's request. */ + * landed at its call); `answer` also declines the provider's request. Null: no live provider holds + * the card (an ended generation's), so only the journal records it. */ async function dismissPrompt( ctx: AgentSessionTurnContext, pending: PendingPrompt, - answer: boolean + answer: boolean | null ): Promise> { const { item, prompt } = pending const identity = parseAgentJournalItemKey(item.itemId) @@ -151,13 +151,15 @@ async function dismissPrompt( committed = true } try { - await ctx.adapter.dismissPrompt?.({ - sessionId: ctx.sessionId, - itemId: item.itemId, - fence: ctx.fence, - answer, - commit - }) + if (answer !== null) { + await ctx.adapter.dismissPrompt?.({ + sessionId: ctx.sessionId, + itemId: item.itemId, + fence: ctx.fence, + answer, + commit + }) + } } catch (error) { if (!committed && !(error instanceof AgentSessionPromptUnavailableError)) { return dismissalNotSaved(ctx, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 288e99521eed..c8abbbab8121 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -7,7 +7,6 @@ import { type AgentSessionCancelResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' @@ -102,36 +101,3 @@ export function isEndedGenerationPrompt( ): boolean { return !contextStructuredAgentSessionCurrentWork(ctx).isCurrentItem(pending.item.itemId) } - -/** Records an ended generation's prompt as cancelled by the caller, in the journal only. */ -export async function dismissEndedGenerationPrompt( - ctx: Pick, - pending: Extract -): Promise> { - const identity = parseAgentJournalItemKey(pending.item.itemId) - if (!identity) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'requestMalformed' }, - `Item id ${pending.item.itemId} is not a well-formed item key.` - ) - } - } - await ctx.journal.appendItem( - identity, - { - ...pending.prompt, - resolution: { - state: 'cancelled', - selectedOptionId: null, - resolvedBy: ctx.resolvedBy, - resolvedAt: ctx.now() - } - }, - // A revision: the prompt keeps the turn it was raised in. - { fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() } - ) - return { ok: true, value: null } -}