diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 9098a6f566db..d33348671932 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -196,5 +196,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-worker-at-rest.test.ts', 'src/main/ssh/ssh-remote-orca-cli.test.ts', 'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts', - 'tests/e2e/completed-worker-retirement-resume.unit.test.ts' + 'tests/e2e/completed-worker-retirement-resume.unit.test.ts', + 'tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts', + 'tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts' ] diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index ee5701d1159f..c949b6a85c6d 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -3,6 +3,7 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, + 'src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts', 'src/main/acp/acp-structured-sign-in.test.ts', 'src/main/acp/acp-structured-auth.test.ts', 'src/main/acp/acp-timeline-turn-failures.test.ts', @@ -124,6 +125,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ '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.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', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 97796a8a0e7f..b5a8e8b395c6 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -95,6 +95,30 @@ describe('MobileNativeChatMessage', () => { const textIn = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + it('renders the shared clear boundary as one muted divider with prior history retained', () => { + const message = projectStructuredItemToNativeChat({ + itemId: 'clear', + revision: 1, + sequence: 5, + observedAt: 5, + body: { + kind: 'status', + text: 'Context cleared', + presentation: 'context-cleared', + contextClear: { operationId: 'clear', afterFence: 3, clearedAt: 5 } + } + })! + const tree = render(message) + expect(textIn(tree.root)).toEqual(['Context cleared']) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + expect( + tree.root.findAll( + (node) => String(node.type) === 'View' && node.props.style === styles.contextBoundary + ) + ).toHaveLength(1) + expect(styles.contextBoundary.borderTopColor).toBe(colors.borderSubtle) + }) + it.each(['system', 'user'] as const)( 'renders a %s host notice as selectable muted text rather than a markdown answer', (role) => { diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 54607be26ac0..315b6aa7168b 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,3 +1,4 @@ +import { isAgentSessionProviderContextBoundary } from '../../../src/shared/agent-session-provider-context' import { MobileSelectableText as Text } from '../components/MobileSelectableText' import { memo, useCallback, useContext, useState } from 'react' import { Image, Text as NativeText, View } from 'react-native' @@ -57,6 +58,18 @@ function Prose({ onLongPress?: () => void }): React.JSX.Element | null { if (isTextBlock(block)) { + if (isAgentSessionProviderContextBoundary(block.contextClear)) { + return ( + + + {block.text} + + + ) + } if (isAgentSessionHostStatusPresentation(block.presentation)) { return ( { + const base = agentSessionRecordFixture() + const record = { + ...base, + provider: spec.agent, + accountHome: + 'accountLocatorKind' in spec.account.pin + ? { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } } + : { variable: spec.account.pin.accountHomeVariable, path: '/fixture/account' }, + providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 }, + providerHandleChain: [] + } + const account = + spec.agent === 'opencode' + ? openCodeAcpAccountBinding(() => ({ + list: () => { + throw new Error('launch must not change account selection') + }, + restoreOriginalEnvironment: () => {}, + environmentForAccount: () => { + throw new Error('unmanaged fixture must stay unmanaged') + } + })) + : spec.account + const resolve = createAcpStructuredLaunchResolver( + { ...spec, account }, + { + store: { getRecord: () => record }, + readJournal: () => { + throw new Error('pre-clear history must not be sampled') + }, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/test' }), + resolveCommand: () => `/fixture/${spec.command}`, + probeVersion: async () => true, + inheritedEnv: {} + } + ) + const launch = await resolve({ + identity: { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + hostId: record.location.executionHostId, + agent: spec.agent, + providerHandle: null + } + }) + expect(launch.resume).toBeNull() + expect(launch.spec.dialect).toBe(spec.dialect) + expect(launch.args).toEqual(spec.args({ fullAccess: false, pluginDir: null })) + } +) diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index 0e7af326a604..0305bd4712c8 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -96,6 +96,18 @@ describe('ACP launch resolution', () => { expect(searched[0]).toContain('/home/user/.grok-work/bin') }) + it('creates a new ACP session after clear instead of probing the old session', async () => { + const value = grokRecord({ + providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 }, + providerHandleChain: [] + }) + const readJournal = () => { + throw new Error('old history must not be sampled for resume') + } + const launch = await resolver(value, false, readJournal).resolve({ identity }) + expect(launch.resume).toBeNull() + }) + it('asks the agent to approve everything only under full access', async () => { const launch = await resolver(grokRecord(), true).resolve({ identity }) expect(launch.args).toContain('--always-approve') diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index dcdad5b868bf..2fe352fa343f 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -151,7 +151,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const resting = { child: null, params: { provider: 'claude' }, - journal: { threadGoal: () => null, contextUsage: () => null } + journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index f6d52ddb7ebe..5ada28257e16 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -155,6 +155,51 @@ describe('claude structured launch resolution', () => { expect(first.env).toMatchObject({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' }) }) + it.each([false, true])( + 'starts only the clear context, recovering its saved transcript: %s', + async (saved) => { + const boundary = { operationId: 'clear-one', afterFence: 2, clearedAt: 100 } + const value = record({ + providerContextBoundary: boundary, + providerHandleChain: [] + }) + const hasTranscript = vi.fn(async () => saved) + const launch = await resolverFor( + value, + undefined, + false, + undefined, + hasTranscript + )({ identity: identityAt('old-leaf') }) + const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId) + expect(launch.providerSessionId).toBe(expected) + expect(expected).not.toBe(claudeSessionIdForOrcaSession(SESSION_ID)) + expect(launch.continuesChain).toBe(false) + expect(launch.resumeLeafUuid).toBeNull() + expect(launch.resumesTranscript).toBe(saved) + expect(launch.options).toMatchObject(saved ? { resume: expected } : { sessionId: expected }) + expect(hasTranscript).toHaveBeenCalledWith( + expect.objectContaining({ providerSessionId: expected }) + ) + const retry = await resolverFor( + value, + undefined, + false, + undefined, + hasTranscript + )({ identity: IDENTITY }) + expect(retry.providerSessionId).toBe(expected) + const next = await resolverFor( + { ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } }, + undefined, + false, + undefined, + async () => false + )({ identity: IDENTITY }) + expect(next.providerSessionId).not.toBe(expected) + } + ) + it('resumes the durable chain head by session id and carries its leaf as bookkeeping', async () => { const launch = await resolverFor( record({ diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 7e52332fe54e..1eba82721d24 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,3 +1,4 @@ +import { activeProviderContext } from '../../shared/agent-session-provider-context' import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { @@ -5,10 +6,7 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { - agentSessionProviderHandleChainHead, - agentSessionProviderHandleRoot -} from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle' import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' @@ -256,7 +254,8 @@ export function createClaudeStructuredLaunchResolver( ) } // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. - const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null + const active = activeProviderContext(record) + const head = active.head?.handle ?? null if ( head && (!identity.providerHandle || @@ -267,7 +266,10 @@ export function createClaudeStructuredLaunchResolver( } const providerSessionId = head ? head.nativeId - : claudeSessionIdForOrcaSession(identity.sessionId) + : claudeSessionIdForOrcaSession( + identity.sessionId, + active.pendingClear ? record.providerContextBoundary?.operationId : undefined + ) const continuesChain = head !== null const cwd = await resolveAgentSessionLaunchDirectory(deps, record) const sources = await resolveClaudeChildEnvSources(deps) @@ -288,7 +290,7 @@ export function createClaudeStructuredLaunchResolver( // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null const resumes = async (claudeConfigDir: string): Promise => - head !== null && + (head !== null || active.pendingClear) && (await claudeLaunchResumesTranscript({ router, leafUuid, diff --git a/src/main/claude/claude-structured-session-id.ts b/src/main/claude/claude-structured-session-id.ts index abdda0721f8e..aaab087cc137 100644 --- a/src/main/claude/claude-structured-session-id.ts +++ b/src/main/claude/claude-structured-session-id.ts @@ -1,8 +1,14 @@ import { createHash } from 'node:crypto' /** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */ -export function claudeSessionIdForOrcaSession(sessionId: string): string { - const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) +export function claudeSessionIdForOrcaSession( + sessionId: string, + clearOperationId?: string +): string { + const identity = clearOperationId + ? `orca-claude:${sessionId}:context-clear:${clearOperationId}` + : `orca-claude:${sessionId}` + const bytes = createHash('sha256').update(identity).digest().subarray(0, 16) bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 const hex = bytes.toString('hex') diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index b47599113281..817e98defe79 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -105,6 +105,21 @@ describe('codex structured launch resolution', () => { } ) + it('starts a new thread after clear without resolving the old rollout', async () => { + const resolveRollout = vi.fn(async () => '/old/rollout.jsonl') + const value = record({ + providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 }, + providerHandleChain: [] + }) + const launch = await resolverFor( + value, + undefined, + resolveRollout + )({ identity: { ...IDENTITY, providerHandle: codexProviderHandle('old-thread') } }) + expect(launch.resumeThreadId).toBeNull() + expect(resolveRollout).not.toHaveBeenCalled() + }) + it('resumes a floating session in its pinned folder, not the current floating setting', async () => { const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-')) const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting') diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index 9401bf7e9a4a..ba187a1572f1 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -209,7 +209,7 @@ describe('claims written with the message', () => { ).toEqual([UPLOAD_A, UPLOAD_B].sort()) }) - it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => { + it("refuses a gone upload and keeps a waiting card's attachment claim across clear", async () => { const journal = await open() const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` }) const refused = await journal.queuedMessages @@ -233,18 +233,15 @@ describe('claims written with the message', () => { hostInstance: 'p', requireAttachments: true }) - const replacement = await open('session-d') - await replacement.queuedMessages.insert({ - messageId: 'draft-1', - body, - fingerprint: 'fp-d', - hostInstance: 'p', - carriedFrom: IDENTITY.sessionId - }) - expect(claims()).toEqual([ - { upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }, - { upload_id: UPLOAD_A, session_id: 'session-d' } - ]) + await journal.context.clear( + { operationId: 'clear', afterFence: 0, clearedAt: 1000 }, + { write: () => {}, committed: () => {} }, + 'caller-clear' + ) + await journals.closeAll() + const reopened = await open() + expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, carriedFrom: null }) + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) }) it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => { diff --git a/src/main/native-chat/agent-session-journal/journal-context-clear.ts b/src/main/native-chat/agent-session-journal/journal-context-clear.ts new file mode 100644 index 000000000000..8630fe4d704f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-context-clear.ts @@ -0,0 +1,68 @@ +import type { AgentSessionProviderContextBoundary } from '../../../shared/agent-session-provider-context' +import type { JournalReducerState } from './journal-reducer' +import { journalItemRowBuilder } from './journal-row-builders' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' +import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalTombstoneRow } from './journal-row-schema' +import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows' + +export function appendJournalContextClear(input: { + state: () => JournalReducerState + writer: JournalRowWriter + cards: JournalQueuedMessages + boundary: AgentSessionProviderContextBoundary + receipt: JournalOperationReceipt + settledByOp: string +}) { + const { boundary, state } = input + let commandIds: string[] = [] + return input.writer + .enqueueRows( + () => { + const cards = input.cards.list() + commandIds = cards + .filter( + (card) => card.body.command && (card.state === 'waiting' || card.state === 'returned') + ) + .map((card) => card.messageId) + const messageIds = cards + .filter((card) => card.state === 'waiting' && !card.body.command) + .map((card) => card.messageId) + return [ + journalItemRowBuilder( + state, + { provider: 'orca', clientMessageId: `context-clear:${boundary.operationId}` }, + { + kind: 'status', + text: 'Context cleared', + presentation: 'context-cleared', + contextClear: boundary + }, + { fence: boundary.afterFence, turnScope: { kind: 'thread' } } + ), + (seq: number, ts: number): JournalTombstoneRow => + buildJournalQueueClearRow({ + state: state(), + seq, + ts, + fence: boundary.afterFence, + clear: { operationId: boundary.operationId, messageIds } + }) + ] + }, + { + write: (db) => { + input.cards.withdrawInTransaction(db, { + messageIds: commandIds, + settledByOp: input.settledByOp + }) + input.receipt.write(db) + }, + committed: input.receipt.committed + } + ) + .then((rows) => ({ + epoch: state().epoch, + sequence: rows.at(-1)?.seq ?? state().lastSequence + })) +} diff --git a/src/main/native-chat/agent-session-journal/journal-context-controller.ts b/src/main/native-chat/agent-session-journal/journal-context-controller.ts new file mode 100644 index 000000000000..7403760e8e91 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-context-controller.ts @@ -0,0 +1,42 @@ +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderContextBoundary } from '../../../shared/agent-session-provider-context' +import { latestAgentSessionContextClearSequence } from '../../../shared/agent-session-context-clear' +import { appendJournalContextClear } from './journal-context-clear' +import { rewindJournalContext } from './journal-context-rewind' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' +import type { JournalReducerState } from './journal-reducer' +import type { JournalQueuedMessages } from './journal-queued-messages' + +export class JournalContextController { + constructor( + private readonly deps: { + state: () => JournalReducerState + writer: JournalRowWriter + cards: JournalQueuedMessages + } + ) {} + + floor(): AgentJournalCursor | null { + const state = this.deps.state() + const sequence = latestAgentSessionContextClearSequence(state.items.values()) + return sequence > 0 ? { epoch: state.epoch, sequence } : null + } + + clear( + boundary: AgentSessionProviderContextBoundary, + receipt: JournalOperationReceipt, + settledByOp: string + ) { + return appendJournalContextClear({ ...this.deps, boundary, receipt, settledByOp }) + } + + rewind( + floor: AgentJournalCursor, + fence: number, + items: readonly JournalReplacementItem[], + receipt: (cursor: AgentJournalCursor) => JournalOperationReceipt + ) { + return rewindJournalContext({ ...this.deps, floor, fence, items, receipt }) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts new file mode 100644 index 000000000000..dbaf544e3ef7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts @@ -0,0 +1,98 @@ +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import type { JournalReducerState } from './journal-reducer' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' +import type { JournalRowWriter, JournalOperationReceipt } from './journal-row-writer' +import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows' +import { journalQueuePauseRestatement } from './queued-message-pause' +import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalRow } from './journal-row-schema' + +export async function rewindJournalContext(input: { + state: () => JournalReducerState + writer: JournalRowWriter + cards: JournalQueuedMessages + floor: AgentJournalCursor + fence: number + items: readonly JournalReplacementItem[] + receipt: (cursor: AgentJournalCursor) => JournalOperationReceipt +}): Promise { + let receipt: JournalOperationReceipt | undefined + const rows = await input.writer.enqueueRows( + () => { + const state = input.state() + if (state.epoch !== input.floor.epoch || !state.queuePauseMarks.cleared) { + throw new Error('agent_session_rewind:stale-context') + } + const pause = journalQueuePauseRestatement( + state.queuePauseMarks, + state.latestAcceptedTurnSequence, + input.cards.pauses() + ).cleared! + const removed = new Map() + for (const item of state.items.values()) { + if (item.sequence > input.floor.sequence) { + removed.set(item.itemId, false) + } + } + for (const submission of state.submissions.values()) { + if ((submission.acceptedSequence ?? 0) > input.floor.sequence) { + removed.set(agentJournalSubmissionKey(submission.clientMessageId), true) + } + } + const revisions = new Map() + const plan: ((seq: number, ts: number) => JournalRow)[] = [] + for (const [itemId, retireSubmission] of removed) { + const row = buildJournalTombstoneRow({ state, itemId, seq: 0, ts: 0, fence: input.fence }) + revisions.set(itemId, row.revision) + plan.push((seq, ts) => ({ + ...row, + seq, + ts, + ...(retireSubmission ? { retireSubmission: true } : {}) + })) + } + for (const item of input.items) { + const itemId = agentJournalItemKey(item.identity) + const existing = state.items.get(itemId) + if (existing && existing.sequence <= input.floor.sequence) { + throw new Error('agent_session_rewind:proof-mismatch') + } + const revision = + Math.max( + revisions.get(itemId) ?? 0, + state.tombstones.get(itemId) ?? 0, + existing?.revision ?? 0 + ) + 1 + revisions.set(itemId, revision) + plan.push((seq, ts) => ({ + ...buildJournalItemRow({ + state, + ...item, + linkage: item, + seq, + ts: item.observedAt ?? ts, + fence: input.fence, + turnScope: item.turnScope ?? { kind: 'thread' } + }), + revision + })) + } + plan.push((seq, ts) => + buildJournalQueueClearRow({ state, seq, ts, fence: input.fence, clear: pause }) + ) + receipt = input.receipt({ epoch: state.epoch, sequence: state.lastSequence + plan.length }) + return plan + }, + { + write: (db) => receipt!.write(db), + committed: () => receipt!.committed() + } + ) + const last = rows.at(-1)! + return { epoch: last.epoch, sequence: last.seq } +} diff --git a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts index 0fbadede3bc7..cda0115447b8 100644 --- a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts +++ b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts @@ -16,6 +16,7 @@ import { type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isAgentSessionContextClear } from '../../../shared/agent-session-context-clear' export class JournalDerivedTurnScope { private openTurnItemId: string | null = null @@ -35,6 +36,10 @@ export class JournalDerivedTurnScope { before: AgentJournalItemBody | undefined, after: AgentJournalItemBody | undefined ): void { + if (isAgentSessionContextClear(after)) { + this.openTurnItemId = null + return + } const turn = readAgentJournalTurn(after) if (before === undefined && turn && root) { this.openTurnItemId = itemId 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 dd56f2ac0f75..67927b401588 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 @@ -17,6 +17,7 @@ import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { buildJournalQueueReopenRow, + buildJournalQueueClearRow, buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' @@ -95,6 +96,11 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (input.queuePause.cleared) { + const row = buildJournalQueueClearRow({ ...place(), clear: input.queuePause.cleared }) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 5b0e2580c45e..3337727689cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -47,6 +47,8 @@ import { import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' +import { QueuedMessageNotConsumableError } from './queued-message-consume-error' +export { QueuedMessageNotConsumableError } from './queued-message-consume-error' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = @@ -111,18 +113,13 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused; - * `holdReason` carries a hold of its own over with it. - * `requireAttachments`: a client's own draft, refused whole when an attachment it names is no - * longer stored; the host's own writes (the carry) claim best effort. - * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + /** Attachments and the send receipt commit with the card. */ insert( input: { messageId: string body: AgentJournalMessageItem fingerprint: string hostInstance: string - carriedFrom?: string requireAttachments?: true holdReason?: QueuedMessageHoldReason }, @@ -242,6 +239,21 @@ export class JournalQueuedMessages { ) } + /** Withdraw commands with the clear's divider and receipt on the same connection. */ + withdrawInTransaction( + db: Database.Database, + input: { messageIds: readonly string[]; settledByOp: string } + ): void { + if (this.deps.database().db !== db) { + throw new AgentSessionJournalError('journal_closed', 'withdraw crossed database handles') + } + this.changeRevision += withdrawQueuedMessages(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }).length + } + /** One standalone draft-table transaction on the journal's queue; one that * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ private transact( @@ -369,13 +381,3 @@ export class JournalQueuedMessages { ).then(() => undefined) } } - -export class QueuedMessageNotConsumableError extends Error { - constructor( - readonly messageId: string, - readonly expected: 'waiting' | 'returned' - ) { - super(`queued message ${messageId} is no longer ${expected}`) - this.name = 'QueuedMessageNotConsumableError' - } -} 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 264933b7fb33..3890b9a7f28f 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -31,6 +31,8 @@ import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './jour import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' import { projectJournalStopNote } from './journal-stop-note-projection' +import { retireRewoundJournalSubmission } from './journal-rewind-submission-retirement' +import { latestAgentSessionContextClearSequence } from '../../../shared/agent-session-context-clear' import { createJournalQueuePauseMarks, foldJournalQueuePauseMark, @@ -60,8 +62,7 @@ export type JournalReducerState = { appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope - /** The submission row of the latest turn the provider accepted, whoever sent it; 0 when none. - * Kept as it folds so the queue's pause reads it in O(1). */ + /** Latest actual acceptance in this epoch, even when its message is rewound away; 0 when none. */ latestAcceptedTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks @@ -116,6 +117,9 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } if (row.kind === 'tombstone') { removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) + if (row.retireSubmission === true) { + retireRewoundJournalSubmission(state, row.itemId) + } return } if (row.kind === 'lifecycle-batch') { @@ -204,6 +208,7 @@ export function journalEchoClaimant( return null } const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, body) + const contextSequence = latestAgentSessionContextClearSequence(state.items.values()) // Exact payload plus queue order preserves repeated identical sends one-for-one. // A submission an echo may not claim is one that says the message never reached // the provider, so an item resembling it is somebody else's. That is `rejected` @@ -214,6 +219,7 @@ export function journalEchoClaimant( .sort((left, right) => left.submittedAt - right.submittedAt) .find( (candidate) => + (contextSequence === 0 || (candidate.acceptedSequence ?? 0) > contextSequence) && candidate.dispatchState !== 'rejected' && !isWriteFailureSubmission(candidate) && candidate.payloadFingerprint === fingerprint && diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index d4f7e543df53..cc59c95893cd 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -10,6 +10,7 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. +import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { @@ -44,7 +45,11 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { } function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] { - const { items, submissions } = journal.snapshot() + const snapshot = journal.snapshot() + const { items, submissions } = agentSessionCurrentContextRows( + snapshot.items, + snapshot.submissions + ) const bodies = new Map(items.map((item) => [item.itemId, item.body])) return submissions.filter((submission) => { if ( diff --git a/src/main/native-chat/agent-session-journal/journal-rewind-submission-retirement.ts b/src/main/native-chat/agent-session-journal/journal-rewind-submission-retirement.ts new file mode 100644 index 000000000000..b6edd1aae214 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-rewind-submission-retirement.ts @@ -0,0 +1,16 @@ +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' + +export function retireRewoundJournalSubmission(state: JournalReducerState, itemId: string): void { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'orca') { + return + } + state.submissions.delete(identity.clientMessageId) + state.receipts.delete(identity.clientMessageId) + for (const [providerItemId, submissionItemId] of state.aliases) { + if (submissionItemId === itemId) { + state.aliases.delete(providerItemId) + } + } +} 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 1d226761ae25..70a1707172f0 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 @@ -101,6 +101,9 @@ export type JournalTombstoneRow = JournalRowBase & { /** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since * lifts it. Absent: the mark's own row. */ queueReopenSince?: number + queueClear?: { operationId: string; messageIds: string[]; lifted?: true } + /** A rewind retires the removed send's echo claimant as well as its visible item. */ + retireSubmission?: true } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, @@ -124,6 +127,7 @@ export type JournalStopOrResumeRow = JournalTombstoneRow & | { stopEvent: NonNullable } | { queueResume: NonNullable } | { queueReopen: NonNullable } + | { queueClear: NonNullable } ) /** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never @@ -131,7 +135,10 @@ export type JournalStopOrResumeRow = JournalTombstoneRow & export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { return ( row.kind === 'tombstone' && - (row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined) + (row.stopEvent !== undefined || + row.queueResume !== undefined || + row.queueReopen !== undefined || + row.queueClear !== undefined) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 60db6e0cbd1e..8decf82dd144 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -74,13 +74,17 @@ export class JournalRowWriter { /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is * durable unless all are, so no reader ever meets some without the rest. */ enqueueRows( - plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + plan: () => readonly ((seq: number, ts: number) => JournalRow)[], + receipt?: JournalOperationReceipt ): Promise { - return this.deps.serialize(() => this.writeRows(plan)) + return this.deps.serialize(() => this.writeRows(plan, receipt)) } /** `enqueueRows`' write, for a caller already running at its own turn in the queue. */ - writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] { + writeRows( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[], + receipt?: JournalOperationReceipt + ): JournalRow[] { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const first = this.deps.nextSequence() const ts = this.deps.now() @@ -97,11 +101,13 @@ export class JournalRowWriter { insertJournalRow(db, this.deps.sessionId, row) this.runBookkeeping(db, row) } + receipt?.write(db) }) } catch (error) { this.deps.rolledBack?.() throw error } + receipt?.committed() for (const row of rows) { this.deps.commit(row) } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index c4ddb98bc398..70fe813d8cbb 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -23,6 +23,18 @@ const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({ type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } +export function buildJournalQueueClearRow( + input: RowPlace & { clear: NonNullable } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-context-clear' }), + revision: 1, + queueClear: input.clear, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + export function buildJournalStopEventRow( input: RowPlace & { event: JournalStopEvent } ): JournalTombstoneRow { diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 0d6b9c07ee07..603d26a3b449 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -28,6 +28,7 @@ export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { case 'user-stop': case 'user-close': return true + case 'context-clear': case 'host-stop': case 'evict': return false 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 06bdfba25fea..dc456d282d1b 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -74,6 +74,7 @@ import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStepWriter } from './journal-step-writer' import type { JournalStopMarks } from './journal-stop-marks' +import { JournalContextController } from './journal-context-controller' export { AgentSessionJournalError } from './journal-write-guards' @@ -98,6 +99,7 @@ export class AgentSessionJournal { /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages readonly stopMarks: JournalStopMarks + readonly context: JournalContextController constructor(options: AgentSessionJournalOptions) { this.identity = options.identity @@ -138,6 +140,11 @@ export class AgentSessionJournal { this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore + this.context = new JournalContextController({ + state: () => this.state, + writer: this.rowWriter, + cards: this.queuedMessages + }) } get epoch(): string { diff --git a/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts b/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts new file mode 100644 index 000000000000..2c05b0a1d5b4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts @@ -0,0 +1,9 @@ +export class QueuedMessageNotConsumableError extends Error { + constructor( + readonly messageId: string, + readonly expected: 'waiting' | 'returned' + ) { + super(`queued message ${messageId} is no longer ${expected}`) + this.name = 'QueuedMessageNotConsumableError' + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts b/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts new file mode 100644 index 000000000000..04d113950d71 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { + createJournalQueuePauseMarks, + deriveQueuePauses, + journalQueuePauseRestatement, + queuePauseHolding, + nextSendableQueuedCard +} from './queued-message-pause' + +const card = (messageId: string) => ({ + messageId, + state: 'waiting', + holdReason: null, + queuedAt: { epoch: 'epoch', sequence: 2 } +}) +const marks = () => ({ + ...createJournalQueuePauseMarks(), + cleared: { sequence: 5, operationId: 'clear', messageIds: ['before-one', 'before-two'] } +}) +const pauses = (cards = [card('before-one'), card('before-two')], m = marks(), accepted = 0) => + deriveQueuePauses({ + epoch: 'epoch', + marks: m, + latestAcceptedTurnSequence: accepted, + cards, + reopenFloor: null + }) + +describe('exact waiting-card membership after context clear', () => { + it('holds only recorded waiting IDs and intersects them with cards still waiting', () => { + const after = card('after') + const current = [card('before-two'), after] + const held = pauses(current) + expect(held).toEqual([ + { reason: 'cleared', since: { epoch: 'epoch', sequence: 5 }, messageIds: ['before-two'] } + ]) + expect(queuePauseHolding(held, after)).toBeUndefined() + expect(nextSendableQueuedCard(held, current)).toBeNull() + expect(pauses([after])).toEqual([]) + expect(nextSendableQueuedCard(pauses([after]), [after])).toEqual(after) + }) + + it('a removed or returned member cannot strand the clear pause', () => { + const returned = { ...card('before-one'), state: 'returned' } + expect( + deriveQueuePauses({ + epoch: 'epoch', + marks: marks(), + latestAcceptedTurnSequence: 0, + cards: [returned], + reopenFloor: null + }) + ).toEqual([]) + expect(pauses([])).toEqual([]) + }) + + it('ends on a new accepted turn or Resume, while older acceptance does not lift it', () => { + expect(pauses(undefined, undefined, 4)).toHaveLength(1) + expect(pauses(undefined, undefined, 6)).toEqual([]) + expect(pauses(undefined, { ...marks(), resumedSequence: 6 })).toEqual([]) + }) + + it('restates an explicit lift when rewind retires the accepted turn', () => { + const prior = marks() + const cards = [card('before-one'), card('before-two')] + const restated = journalQueuePauseRestatement(prior, 6, pauses(cards, prior, 6)).cleared! + expect(restated.lifted).toBe(true) + expect(pauses(cards, { ...prior, cleared: { ...restated, sequence: 10 } }, 0)).toEqual([]) + }) + + it('restates only still-held IDs when rewind changes the journal suffix', () => { + const prior = marks() + const current = [card('before-two')] + expect(journalQueuePauseRestatement(prior, 0, pauses(current, prior)).cleared).toEqual({ + operationId: 'clear', + messageIds: ['before-two'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index dabc1d64335d..bedd7bd8351b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -1,6 +1,5 @@ // The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted -// turn is a row, and a /clear's carried card names its source. Nothing is stored beside -// them, so nothing has to retire. +// turn is a row, and /clear records the exact waiting cards. No separate pause state is stored. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -57,16 +56,23 @@ function open(): Promise { }) } -function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom?: string) { +function queueDraft(journal: AgentSessionJournal, messageId: string) { return journal.queuedMessages.insert({ messageId, body: message(messageId), fingerprint: `fp-${messageId}`, - hostInstance: HOST, - ...(carriedFrom ? { carriedFrom } : {}) + hostInstance: HOST }) } +function clearContext(journal: AgentSessionJournal) { + return journal.context.clear( + { operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock }, + { write: () => {}, committed: () => {} }, + `caller-clear-${clock}` + ) +} + /** A turn sent, then accepted by the provider. */ async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise { await journal.appendSubmission({ @@ -202,9 +208,10 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => { + it('a card kept by /clear stays paused until Resume in the same journal', async () => { let journal = await open() - await queueDraft(journal, 'carried', 'source-session') + await queueDraft(journal, 'carried') + await clearContext(journal) expect(reason(journal)).toBe('cleared') await journal.close() journal = await open() @@ -214,9 +221,10 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("any accepted turn on the replacement lifts 'cleared', a launch prompt Orca sent included", async () => { + it('any accepted turn after /clear lifts its pause, a launch prompt Orca sent included', async () => { const journal = await open() - await queueDraft(journal, 'carried', 'source-session') + await queueDraft(journal, 'carried') + await clearContext(journal) await turn(journal, 'launch', false) expect(reason(journal)).toBe('cleared') await acceptTurn(journal, 'launch') @@ -376,7 +384,8 @@ describe("the queue's pause, derived from the journal", () => { ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { const journal = await open() - await queueDraft(journal, 'carried', 'source-session') + await queueDraft(journal, 'carried') + await clearContext(journal) await lift(journal) expect(reason(journal)).toBeNull() await journal.replaceEpochItems('handle_forked', 0, []) @@ -385,7 +394,8 @@ describe("the queue's pause, derived from the journal", () => { it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { const journal = await open() - await queueDraft(journal, 'carried', 'source-session') + await queueDraft(journal, 'carried') + await clearContext(journal) await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) @@ -466,10 +476,11 @@ describe('which cards a pause holds', () => { expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') }) - it("'cleared' holds the carried cards, not one typed after them", async () => { + it("'cleared' holds the cards already queued, not one typed after it", async () => { const journal = await open() - await queueDraft(journal, 'carried-1', 'source-session') - await queueDraft(journal, 'carried-2', 'source-session') + await queueDraft(journal, 'carried-1') + await queueDraft(journal, 'carried-2') + await clearContext(journal) await queueDraft(journal, 'typed-here') expect(held(journal)).toEqual([ ['carried-1', true], @@ -638,19 +649,22 @@ describe('which cards the pauses in force hold', () => { function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null, carriedFrom: null } + const base = { state: 'waiting', holdReason: null } return { messageId, ...base, queuedAt, ...fields } } /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark * it (0: nothing to mark); no turn, Resume or reopen mark since. */ - function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) { + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0, clearedIds?: string[]) { return deriveQueuePauses({ epoch: 'epoch-1', marks: { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, resumedSequence: 0, - reopenedSequence: 0 + reopenedSequence: 0, + ...(clearedIds + ? { cleared: { sequence: 4, operationId: 'clear', messageIds: clearedIds } } + : {}) }, latestAcceptedTurnSequence: 0, cards, @@ -717,15 +731,15 @@ describe('which cards the pauses in force hold', () => { }) it("a /clear's pause that holds nothing never hides a reopen's", () => { - const cards = [ - card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), - card('typed', 2) - ] - expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect(holding(cards, 0, 4)).toEqual([ - ['carried', null], + const cards = [card('cleared', 1, { holdReason: 'send_failed' }), card('typed', 2)] + const pauses = pausesOver(cards, 0, 4, ['cleared']) + expect(pauses.map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect( + cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) + ).toEqual([ + ['cleared', null], ['typed', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() + expect(nextSendableQueuedCard(pauses, cards)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index a7a652d81b7c..688265f7c278 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -3,8 +3,7 @@ // - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row // and no turn sent after it and accepted. A later Stop of any reason supersedes it; only a // person's pauses. -// - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has -// happened here since. +// - 'cleared': a card named by the latest clear mark waits, with no later accepted turn or Resume. // - 'restarted': a card queued before this conversation last opened waits — Orca quit or // crashed, or the chat closed, while it waited — and no turn or Resume has happened since the // open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card @@ -38,6 +37,7 @@ export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } /** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark, * folded by the reducer. */ export type JournalQueuePauseMarks = { + cleared?: { sequence: number; operationId: string; messageIds: readonly string[]; lifted?: true } latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number @@ -47,15 +47,15 @@ export type JournalQueuePauseMarks = { export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for - * /clear's, which holds the cards it carried. */ + /** Where the pause began; clear holds its exact recorded message IDs. */ since: AgentJournalCursor | null + messageIds?: readonly string[] } type QueueCard = { + messageId?: string state: string holdReason: string | null - carriedFrom: string | null queuedAt: AgentJournalCursor | null } @@ -89,9 +89,27 @@ export function foldJournalQueuePauseMark( const since = row.queueReopenSince const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq marks.reopenedSequence = Math.max(marks.reopenedSequence, start) + } else if (isReadableQueueClear(row.queueClear)) { + marks.cleared = { ...row.queueClear, sequence: row.seq } } } +function isReadableQueueClear( + value: unknown +): value is NonNullable { + return ( + typeof value === 'object' && + value !== null && + 'operationId' in value && + typeof value.operationId === 'string' && + value.operationId.length > 0 && + 'messageIds' in value && + Array.isArray(value.messageIds) && + value.messageIds.every((id: unknown) => typeof id === 'string' && id.length > 0) && + (!('lifted' in value) || value.lifted === true) + ) +} + /** A person's Stop still pauses: it is the latest Stop, and no turn accepted since, and no * Resume, ended it. A later Stop for any other reason ends it without pausing itself. */ export function journalQueueStopHolds( @@ -119,6 +137,7 @@ export type JournalQueuePauseRestatement = { lifted: boolean liveStop: JournalStopEvent | null reopened: boolean + cleared?: { operationId: string; messageIds: string[]; lifted?: true } } export function journalQueuePauseRestatement( @@ -129,7 +148,19 @@ export function journalQueuePauseRestatement( return { lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null, - reopened: pauses.some((pause) => pause.reason === 'restarted') + reopened: pauses.some((pause) => pause.reason === 'restarted'), + ...(marks.cleared + ? { + cleared: { + operationId: marks.cleared.operationId, + messageIds: [...(pauses.find((pause) => pause.reason === 'cleared')?.messageIds ?? [])], + ...(marks.cleared.lifted || + Math.max(latestAcceptedTurnSequence, marks.resumedSequence) >= marks.cleared.sequence + ? { lifted: true as const } + : {}) + } + } + : {}) } } @@ -150,9 +181,19 @@ export function deriveQueuePauses(input: { pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') - const carried = waiting.filter((card) => card.carriedFrom !== null) - if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { - pauses.push({ reason: 'cleared', since: null }) + const cleared = marks.cleared + if ( + cleared && + !cleared.lifted && + cleared.sequence > Math.max(latestAcceptedTurnSequence, marks.resumedSequence) + ) { + const membership = new Set(cleared.messageIds) + const messageIds = waiting.flatMap((card) => + card.messageId && membership.has(card.messageId) ? [card.messageId] : [] + ) + if (messageIds.length > 0) { + pauses.push({ reason: 'cleared', since: { epoch, sequence: cleared.sequence }, messageIds }) + } } const reopened = reopenPause(input) if ( @@ -186,12 +227,10 @@ function reopenPause(input: { return { reason: 'restarted', since: { epoch, sequence } } } -/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card - * queued before its row; one from another epoch (before a rewind) or from a build that recorded - * no position counts as before. A withdrawn steer keeps its position, so is held. */ +/** Clear holds recorded IDs; other pauses compare queued positions within the journal epoch. */ function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { - return card.carriedFrom !== null + return pause.messageIds?.includes(card.messageId ?? '') ?? false } const { since } = pause return ( diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index aaaef6d4fa65..303ea060c633 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -56,8 +56,7 @@ export type QueuedMessageRow = { * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is * the submission's `queuedMessageId`. */ consumedAs: string | null - /** The conversation /clear carried this card from; null for a card written here. What the - * replacement's 'cleared' pause is derived from. */ + /** Inert historical column; current inserts write null. */ carriedFrom: string | null /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ @@ -75,7 +74,6 @@ export function insertQueuedMessage( body: AgentJournalMessageItem fingerprint: string hostInstance: string - carriedFrom?: string queuedAt: AgentJournalCursor now: number /** Absent: after every other card. */ @@ -100,7 +98,7 @@ export function insertQueuedMessage( input.now, input.hostInstance, input.holdReason ?? null, - input.carriedFrom ?? null, + null, input.queuedAt.epoch, input.queuedAt.sequence ) @@ -119,7 +117,7 @@ export function insertQueuedMessage( settledAt: null, settledByOp: null, consumedAs: null, - carriedFrom: input.carriedFrom ?? null, + carriedFrom: null, queuedAt: input.queuedAt } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 9106bafa107a..6a54478a4613 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -152,7 +152,7 @@ afterEach(async () => { }) describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { - it('after /clear, before the new chat has started', async () => { + it('after /clear, before the fresh agent context starts', async () => { const cleared = await host.conversationCommand(caller, { command: 'clear', envelope: { @@ -166,7 +166,8 @@ describe("a Claude chat whose Claude isn't running shows the `/` surface from it }) } }) - const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' + expect(cleared).toMatchObject({ ok: true }) + const replacement = SESSION const menu = await menuOf(replacement) expect(namesOf(menu, 'skill')).toEqual(['review-pr']) // Everything the menu offered before a list existed, then what the folders add. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index a4e5c0cfdd4b..9138fa20f8b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -1,3 +1,4 @@ +import { activeProviderContext } from '../../../shared/agent-session-provider-context' // Attach: reserve the session record, then open its journal. // // `create` and `ensure` are the same transition with a different starting @@ -40,7 +41,6 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -163,10 +163,13 @@ export function journalIdentityFor( record: AgentSessionRecord, params: AgentSessionAttachParams ): AgentSessionJournalIdentity { - const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const active = activeProviderContext(record) + const head = active.head const providerHandle = head?.handle ?? - (params.providerHandle ? agentSessionProviderHandleFromWire(params.providerHandle) : null) + (!active.pendingClear && params.providerHandle + ? agentSessionProviderHandleFromWire(params.providerHandle) + : null) return { sessionId: record.sessionId, workspaceId: params.location.workspaceId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts index 9228f6f0743f..69cf3e904a24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -86,7 +86,7 @@ describe('attachments at send admission', () => { expect(await rig.drafts()).toHaveLength(0) }) - it('claims a stored upload, and the /clear carry claims it for the replacement chat', async () => { + it('keeps a stored upload claimed by the same conversation after clear', async () => { const working = await rig.workingSend() const path = await storeUpload('notes.txt') expect(await clientSend(textWith(path), 'queue-if-active')).toMatchObject({ @@ -101,9 +101,8 @@ describe('attachments at send admission', () => { envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), ...fields }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - expect(replacementId).toBeTruthy() - expect(claimedSessions()).toEqual([SESSION, replacementId].sort()) + expect(cleared).toMatchObject({ ok: true }) + expect(claimedSessions()).toEqual([SESSION]) }) it('sends text that only mentions another store path, claiming nothing', async () => { 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 e9ac5b09bd9e..694268a1f8bf 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 @@ -139,7 +139,8 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< expected && exit.startupUnanswered && close?.cause !== 'host-stop' && - close?.cause !== 'user-stop' + close?.cause !== 'user-stop' && + close?.cause !== 'context-clear' ? close?.quit ? ('hostRestarted' as const) : ('chatClosed' as const) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts new file mode 100644 index 000000000000..95bbee548121 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) +afterEach(() => rig.dispose()) + +function command(name: 'clear' | 'compact') { + const fields = { + command: name, + ...(name === 'compact' ? { delivery: 'queue-if-active' as const } : {}) + } + return rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +it('clear refuses Working before releasing or cancelling its provider', async () => { + await rig.workingSend() + expect(await command('clear')).toMatchObject({ + ok: false, + refusal: { details: { reason: 'messagesUnsettled' } } + }) + expect(rig.closeSession).not.toHaveBeenCalled() + expect(rig.cancelTurn).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.providerContextBoundary).toBeUndefined() +}) + +it('compact arriving during clear waits for its lane and is refused without a card or send', async () => { + const working = await rig.workingSend() + await rig.settleAccepted(working, 'answer') + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + rig.closeSession.mockImplementationOnce(async () => { + await held + return true + }) + const clearing = command('clear') + await eventually(() => expect(rig.closeSession).toHaveBeenCalledOnce()) + let answered = false + const compacting = command('compact').then((result) => { + answered = true + return result + }) + try { + await Promise.resolve() + expect(answered).toBe(false) + } finally { + release() + } + expect(await clearing).toMatchObject({ ok: true }) + expect(await compacting).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + expect(await rig.drafts()).toEqual([]) + expect(rig.compact).not.toHaveBeenCalled() + expect(rig.dispatch).toHaveBeenCalledOnce() +}) + +it('clear removes command cards from history and connected and reconnecting client snapshots', async () => { + const working = await rig.workingSend() + const first = rig.send('first', 'queue-if-active') + await first.result + const compact = await command('compact') + if (!compact.ok || !compact.value.queued) { + throw new Error('expected a command card') + } + const last = rig.send('last', 'queue-if-active') + await last.result + await rig.stop() + await rig.settleAccepted(working, 'answer') + const connected: AgentSessionSubscribeEvent[] = [] + await rig.host.subscribe({ + id: 'connected', + sessionId: SESSION, + emit: (event) => connected.push(event) + }) + expect(await command('clear')).toMatchObject({ ok: true }) + const reconnecting: AgentSessionSubscribeEvent[] = [] + await rig.host.subscribe({ + id: 'reconnecting', + sessionId: SESSION, + emit: (event) => reconnecting.push(event) + }) + const expected = [first.id, last.id].map((messageId) => ({ messageId, state: 'waiting' })) + expect(await rig.drafts()).toEqual(expected) + expect(await rig.queuePause()).toBeNull() + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.pauses() + ).toContainEqual(expect.objectContaining({ reason: 'cleared', messageIds: [first.id, last.id] })) + for (const events of [connected, reconnecting]) { + await eventually(() => { + const cards = events + .flatMap((event) => + event.type !== 'end' && event.queuedMessages ? [event.queuedMessages] : [] + ) + .at(-1) + expect(cards?.map(({ messageId, state }) => ({ messageId, state }))).toEqual(expected) + }) + } + expect(await rig.handoff(compact.value.queued.messageId)).toBeUndefined() + expect(rig.compact).not.toHaveBeenCalled() + expect(rig.dispatch).toHaveBeenCalledOnce() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts new file mode 100644 index 000000000000..0a49400f0c51 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { activeProviderContext } from '../../../shared/agent-session-provider-context' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { recoverStructuredRewind } from './structured-rewind-recovery' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' +import { queuePauseHolding } from '../agent-session-journal/queued-message-pause' + +let rig: QueuedMessageTestRig +const rewind = vi.fn>() + +beforeEach(async () => { + rewind.mockReset().mockResolvedValue({ ok: true }) + rig = await createQueuedMessageTestRig({ restartable: true, rewind }) +}) +afterEach(() => rig.dispose()) + +const journal = () => rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal +const db = () => openTestJournalHostDatabase(rig.root).db +const rawPrefix = (sequence: number) => + db() + .prepare( + 'SELECT seq, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq <= ? ORDER BY seq' + ) + .all(SESSION, journal().cursor().epoch, sequence) + +async function clear() { + const fields = { command: 'clear' as const } + const result = await rig.host.conversationCommand(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + }) + expect(result).toMatchObject({ ok: true }) +} + +async function send(text: string, turn: string) { + const sent = rig.send(text) + expect(await sent.result).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(sent.id, turn) + return agentJournalSubmissionKey(sent.id) +} + +async function seed() { + const old = await send('before clear', 'old') + await clear() + const floor = journal().context.floor()! + const prefix = journal() + .snapshot() + .items.filter((item) => item.sequence <= floor.sequence) + const raw = rawPrefix(floor.sequence) + const kept = await send('kept after clear', 'kept') + const target = await send('rewound after clear', 'target') + const fields = { itemId: target, expectedEpoch: journal().cursor().epoch } + const params = { + ...fields, + envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId()) + } + return { old, kept, target, floor, prefix, raw, params } +} + +async function returnedCardAndWaitingCard() { + const working = await rig.workingSend() + const returned = rig.send('returned card', 'queue-if-active') + const waiting = rig.send('waiting card', 'queue-if-active') + expect(await returned.result).toMatchObject({ ok: true }) + expect(await waiting.result).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect((await rig.handoff(returned.id))?.handedOverAt).toBeDefined()) + await rig.stop() + await rig.settleRejected(await rig.handoffId(returned.id), 'provider refused the card') + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: returned.id, state: 'returned' }, + { messageId: waiting.id, state: 'waiting' } + ]) + ) + return { returned: returned.id, waiting: waiting.id } +} + +async function replayedJournal() { + const reopened = new AgentSessionJournal({ + database: openTestJournalHostDatabase(rig.root), + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle + } + }) + await reopened.open() + return reopened +} + +describe('rewind behind a clear boundary', () => { + it.each(['stop', 'reopen'] as const)( + 'keeps a lifted %s pause lifted after suffix rewind and journal replay', + async (pause) => { + await clear() + const { returned, waiting } = await returnedCardAndWaitingCard() + if (pause === 'reopen') { + await journal().appendQueueReopen(rig.store.getRecord(SESSION)!.lease.runtimeFence) + } + const target = await send('lifts the pauses', 'lift') + expect(structuredQueuePauses(journal())).toEqual([]) + const fields = { itemId: target, expectedEpoch: journal().cursor().epoch } + expect( + await rig.host.rewind(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + expect(structuredQueuePauses(journal())).toEqual([]) + expect((await replayedJournal()).queuedMessages.pauses()).toEqual([]) + expect(await rig.deleteQueued(returned)).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) + } + ) + + it('keeps a later Stop active without holding cards queued after that Stop', async () => { + await clear() + const { waiting } = await returnedCardAndWaitingCard() + await send('lifts the earlier stop', 'lift') + const later = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(later, 'stopped-later') + const target = agentJournalSubmissionKey(later) + const newer = await journal().queuedMessages.insert({ + messageId: 'queued-after-stop', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'newer card' }] }, + fingerprint: 'newer-card', + hostInstance: 'test' + }) + const originalPause = structuredQueuePauses(journal()).find( + (entry) => entry.reason === 'stopped' + ) + expect(originalPause).toBeDefined() + const fields = { itemId: target, expectedEpoch: journal().cursor().epoch } + expect( + await rig.host.rewind(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + expect(structuredQueuePauses(journal()).find((entry) => entry.reason === 'stopped')).toEqual( + originalPause + ) + expect(journal().queuedMessages.get(newer.messageId)?.queuedAt).toEqual(newer.queuedAt) + expect(queuePauseHolding(structuredQueuePauses(journal()), newer)).toBeUndefined() + const replayedPauses = (await replayedJournal()).queuedMessages.pauses() + expect(replayedPauses.find((entry) => entry.reason === 'stopped')).toEqual(originalPause) + expect(queuePauseHolding(replayedPauses, newer)).toBeUndefined() + expect(queuePauseHolding(replayedPauses, journal().queuedMessages.get(waiting)!)?.reason).toBe( + 'stopped' + ) + expect(await rig.handoff(waiting)).toBeUndefined() + }) + + it('rejects prefix and divider targets before acquiring or calling the provider', async () => { + const old = await send('earlier', 'old') + await clear() + const marker = journal().snapshot().items.at(-1)!.itemId + const starts = rig.starts.mock.calls.length + for (const itemId of [old, marker]) { + const fields = { itemId, expectedEpoch: journal().cursor().epoch } + expect( + await rig.host.rewind(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId()) + }) + ).toMatchObject({ ok: false, refusal: { rewindReason: 'invalid-target' } }) + } + expect(rig.starts).toHaveBeenCalledTimes(starts) + expect(rewind).not.toHaveBeenCalled() + }) + + it('rewrites only the suffix, retires its aliases, and keeps prefix bytes and epoch', async () => { + const { old, target, floor, prefix, raw, params } = await seed() + rewind.mockImplementationOnce(async () => { + const prepared = rig.store.getRecord(SESSION)!.rewind! + expect(prepared.contextClearOperationId).toBe( + rig.store.getRecord(SESSION)!.providerContextBoundary!.operationId + ) + expect(prepared.retained).toHaveLength(1) + expect(prepared.retained[0].body).toMatchObject({ + kind: 'message', + blocks: [{ text: 'kept after clear' }] + }) + return { ok: true } + }) + const result = await rig.host.rewind(CALLER, params) + expect(result).toMatchObject({ + ok: true, + value: { epoch: floor.epoch, sequence: journal().cursor().sequence } + }) + expect( + journal() + .snapshot() + .items.filter((item) => item.sequence <= floor.sequence) + ).toEqual(prefix) + expect(rawPrefix(floor.sequence)).toEqual(raw) + expect( + journal() + .snapshot() + .submissions.map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ).toEqual([old]) + expect( + journal().canonicalItemId(rig.store.getRecord(SESSION)!.rewind!.providerItemId!) + ).not.toBe(target) + const reopened = await replayedJournal() + expect(reopened.snapshot()).toEqual(journal().snapshot()) + }) + + it('rolls suffix changes and success back together, then recovers once without provider retry', async () => { + const { floor, raw, params } = await seed() + const before = journal().snapshot() + db().exec(`CREATE TEMP TRIGGER fail_rewind BEFORE UPDATE ON agent_session_records + WHEN instr(NEW.record_json, '"phase":"completed"') > 0 + BEGIN SELECT RAISE(ABORT, 'disk full'); END`) + await expect(rig.host.rewind(CALLER, params)).rejects.toThrow('disk full') + expect(journal().snapshot()).toEqual(before) + expect(rig.store.getRecord(SESSION)?.rewind?.phase).toBe('provider-succeeded') + expect( + rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status + ).toBe('pending') + db().exec('DROP TRIGGER fail_rewind') + await recoverStructuredRewind( + rig.host.deps, + SESSION, + journal(), + rig.store.getRecord(SESSION)!.lease.runtimeFence + ) + const recovered = journal().snapshot() + await recoverStructuredRewind( + rig.host.deps, + SESSION, + journal(), + rig.store.getRecord(SESSION)!.lease.runtimeFence + ) + expect(journal().snapshot()).toEqual(recovered) + expect(rawPrefix(floor.sequence)).toEqual(raw) + expect(rewind).toHaveBeenCalledOnce() + expect(await rig.host.rewind(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + }) + + it('replays a lost suffix acknowledgment without rewriting or repeating the provider effect', async () => { + const { params } = await seed() + const original = journal().context.rewind.bind(journal().context) + const lost = vi.spyOn(journal().context, 'rewind').mockImplementationOnce(async (...args) => { + await original(...args) + throw new Error('lost acknowledgement') + }) + await expect(rig.host.rewind(CALLER, params)).rejects.toThrow('lost acknowledgement') + lost.mockRestore() + const landed = journal().snapshot() + expect( + rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status + ).toBe('succeeded') + expect(await rig.host.rewind(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(journal().snapshot()).toEqual(landed) + expect(rewind).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts index 2ae9a10a4631..eb64f1b38709 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts @@ -21,6 +21,7 @@ const ENDED_CHILD_FAILURE = { 'user-stop': () => null, // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. 'user-close': () => null, + 'context-clear': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, 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 85629500a0b4..d9b8e068c37e 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 @@ -42,7 +42,13 @@ export async function stopEndsWork( ending: StructuredAgentSessionStopEnding ): Promise { const { child, journal } = session - if ('recorded' in ending || ending.quit || ending.resting || !child) { + if ( + 'recorded' in ending || + ending.quit || + ending.resting || + ending.cause === 'context-clear' || + !child + ) { return false } // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One 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 1a08095a82ec..251ccc88e32b 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 @@ -316,7 +316,6 @@ export class StructuredAgentSessionHost { conversationCommand = (...args: Parameters) => this.conversationCommands.run(...args) - conversationReplacements = () => this.conversationCommands.replacements() /** Undefined means unavailable; an empty array is an authoritative catalog. */ readCommands = (sessionId: string) => ({ commands: this.clientDelivery.readCommands(sessionId) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index f9297723804f..7d2a1e20067b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -43,6 +43,7 @@ import { } from './structured-agent-session-turns' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' export type MutationPlan = { method: string @@ -58,11 +59,10 @@ export type MutationPlan = { recoverUnknownFromDurableState?: boolean } & ( | { - /** Its success is the row its run writes, so it commits in that row's transaction - * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That - * success is fixed before the value exists, so it records no `settledOutcome`. */ + /** Commits success with its row; paths without a committed receipt use fallback settlement. */ settlesWithWrite: true - settledOutcome?: never + successReceipt?: () => JournalOperationReceipt + settledOutcome?: (value: TValue) => AgentSessionOperationOutcome } | { settlesWithWrite?: never diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index c6f21301ef4c..fa9a88029ec5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -28,10 +28,11 @@ export async function runSettledAgentSessionMutation(input: { ) => input.store.recordOperationOutcome({ ...operation, outcome }) const receipt = input.plan.settlesWithWrite ? observedReceipt( - input.store.operationOutcomeReceipt({ - ...operation, - outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } - }) + input.plan.successReceipt?.() ?? + input.store.operationOutcomeReceipt({ + ...operation, + outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } + }) ) : undefined const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 475f7bf091bd..64c203183db2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -35,7 +35,11 @@ describe('options at rest', () => { resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: probe } }) - const resting = { child: null, params: { provider: 'codex' } } + const resting = { + child: null, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { deps: { @@ -71,7 +75,7 @@ describe('live Codex option reads', () => { const live = { child, params: { provider: 'codex' }, - journal: { threadGoal: () => null, contextUsage: () => null } + journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { @@ -101,7 +105,11 @@ describe('live Codex option reads', () => { it('keeps a listing failure visible to the picker', async () => { const queue = new StructuredAgentSessionTaskQueue() - const live = { child: { fence: 7 }, params: { provider: 'codex' } } + const live = { + child: { fence: 7 }, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } const failure = new Error('model/list unavailable') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields. const context = { @@ -133,7 +141,11 @@ describe('live Codex option reads', () => { await listing.promise return apply }) - const live = { child: { fence: 7 }, params: { provider: 'codex' } } + const live = { + child: { fence: 7 }, + params: { provider: 'codex' }, + journal: { context: { floor: () => null } } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields. const context = { deps: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 48fb03b44c13..9bbc74d05cdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -148,6 +148,8 @@ export async function readStructuredAgentSessionOptions( const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider const capabilities = agents.capabilities(agent) + const floor = session.journal.context.floor() + const contextFloor = floor ? { contextFloor: floor } : {} return { ...options, rewind: @@ -158,9 +160,11 @@ export async function readStructuredAgentSessionOptions( reason: 'unsupported' }), conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], - ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), + ...(capabilities?.threadGoal + ? { threadGoal: { current: session.journal.threadGoal(), ...contextFloor } } + : {}), ...(capabilities?.contextUsage - ? { contextUsage: { current: session.journal.contextUsage() } } + ? { contextUsage: { current: session.journal.contextUsage(), ...contextFloor } } : {}) } } 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 d4050e8e39ad..0feaf809f2a5 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 @@ -538,8 +538,8 @@ describe("the queue's next card on a history page", () => { }) describe('where the host would refuse the send', () => { - /** An idle source a /clear replaced, still holding a card: a crash before the carry leaves it. */ - async function cardLeftOnAClearedSource() { + /** A new card in the same conversation after /clear inherits no earlier pause. */ + async function cardAddedAfterClear() { await rig.settleAccepted(await rig.workingSend(), 'a') const fields = { command: 'clear' as const } const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { @@ -549,12 +549,12 @@ describe('where the host would refuse the send', () => { expect(cleared).toMatchObject({ ok: true }) const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!journal) { - throw new Error('expected the source open') + throw new Error('expected the conversation open') } const card = 'left-behind' await journal.queuedMessages.insert({ messageId: card, - body: hostTestMessage('left on the source'), + body: hostTestMessage('queued after clear'), fingerprint: 'fp-left-behind', hostInstance: structuredAgentSessionHostInstance() }) @@ -565,17 +565,17 @@ describe('where the host would refuse the send', () => { return { card, journal, record, fence: record.lease.runtimeFence } } - it('a source a /clear replaced names no next card; the same idle state with no block names it', async () => { - const { card, journal, record, fence } = await cardLeftOnAClearedSource() + 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) - expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBeNull() + expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBe(card) const { conversationCommand: _cleared, ...unblocked } = record const next = readQueuePublication(journal, () => ({ record: unblocked, fence })) expect(next.nextQueuedMessageId).toBe(card) }) it('a rewind whose outcome is unknown names no next card', async () => { - const { journal, record, fence } = await cardLeftOnAClearedSource() + const { journal, record, fence } = await cardAddedAfterClear() const { conversationCommand: _cleared, ...unblocked } = record const rewind = { operationId: hostTestOperationId(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 7ab0fcfc1f9f..b4efad3404e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -1,11 +1,5 @@ -// Queued drafts across conversation commands: a /compact is a queued message -// and then a turn, so a capable send during it becomes a card that waits for it -// like any turn, while Delete and Send-now answer at once; a /clear in flight -// admits no draft onto the source it is superseding; and a draft /clear carries -// to its replacement is fingerprinted for the replacement, so the provider's -// echo folds into its sent bubble. - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { createQueuedMessageTestRig, @@ -14,7 +8,7 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { - HOST_TEST_THREAD as THREAD, + HOST_TEST_SESSION as SESSION, hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' @@ -97,122 +91,107 @@ describe('a /compact in flight', () => { }) describe('/clear', () => { - it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => { - const commit = rig.store.commitConversationClear + it('refuses a send arriving while clear is stopping the context', async () => { let release: (() => void) | undefined const released = new Promise((resolve) => { release = resolve }) - const committing = vi.fn() - const spy = vi - .spyOn(rig.store, 'commitConversationClear') - .mockImplementationOnce(async (clear) => { - committing() - await released - return commit(clear) - }) + const stopping = vi.fn() + const spy = rig.closeSession.mockImplementationOnce(async () => { + stopping() + await released + return true + }) try { const cleared = command('clear') - await eventually(() => expect(committing).toHaveBeenCalledOnce()) - // Judged on arrival, answered on its turn: behind the clear. + await eventually(() => expect(stopping).toHaveBeenCalledOnce()) const sent = rig.send('sent while clearing', 'queue-if-active').result release?.() expect(await sent).toEqual(WAIT_REFUSAL) - const done = await cleared - const replacementId = done.ok ? done.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } + expect(await cleared).toMatchObject({ ok: true, value: { command: 'clear' } }) expect(await rig.drafts()).toHaveLength(0) - expect(await rig.drafts(replacementId)).toHaveLength(0) } finally { - spy.mockRestore() + release?.() + spy.mockResolvedValue(true) } }) - it('carries drafts to a replacement no agent has started, before it answers; the first send starts the agent ahead of them', async () => { + it('keeps waiting card identities and starts a fresh context ahead of their drain', async () => { const working = await rig.workingSend() const firstId = await queuedId(rig.send('first draft', 'queue-if-active').result) const secondId = await queuedId(rig.send('second draft', 'queue-if-active').result) await rig.stop() await rig.settleAccepted(working, 'a') - const cleared = await command('clear') - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } - // The clear started nothing, yet the drafts had already moved when it answered. - expect(rig.store.getRecord(replacementId)).toMatchObject({ - providerHandleChain: [], - lease: { claimStatus: 'released' } - }) - expect(rig.host.collaboratorsForTests().sessions.get(replacementId)?.child ?? null).toBeNull() - expect(await rig.drafts()).toHaveLength(0) - expect(await rig.drafts(replacementId)).toEqual([ + expect(await command('clear')).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION)?.providerHandleChain).toEqual([]) + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child ?? null).toBeNull() + expect(await rig.drafts()).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - // Held, unshown: nothing runs in the fresh conversation. - expect(await rig.queuePause(replacementId)).toBeNull() - - const body = hostTestMessage('first in the new chat') - const fields = { body, delivery: 'queue-if-active' as const } - const sentId = hostTestOperationId() - expect( - await rig.host.send(CALLER, { - envelope: rig.envelope(fields, 'agentSession.send', sentId, replacementId), - ...fields - }) - ).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await rig.handoff(firstId)).toBeUndefined() + const sent = rig.send('first in the new context', 'queue-if-active') + expect(await sent.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(() => + expect(rig.store.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) + ) await eventually(() => - expect(rig.store.getRecord(replacementId)?.providerHandleChain).toHaveLength(1) + expect(activeProviderContext(rig.store.getRecord(SESSION)!).head).not.toBeNull() ) + const thread = activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle.nativeId await rig.host.settleLateDispatch({ - sessionId: replacementId, - clientMessageId: sentId, - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-first', ordinal: 0 } + sessionId: SESSION, + clientMessageId: sent.id, + providerIdentity: { provider: 'codex', threadId: thread, turnId: 'turn-first', ordinal: 0 } }) - await eventually(async () => - expect( - (await rig.host.journalSnapshot(replacementId)).submissions.map((entry) => - entry.clientMessageId === sentId ? 'sent' : entry.queuedMessageId - ) - ).toEqual(['sent', firstId]) + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + expect(await rig.queuePause()).toBeNull() + }) + + it('compacts the new context without removing clear ancestry or its divider', async () => { + expect(await command('clear')).toMatchObject({ ok: true }) + const boundary = rig.store.getRecord(SESSION)!.providerContextBoundary + expect(await command('compact')).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(activeProviderContext(rig.store.getRecord(SESSION)!).head?.replaces).toBeUndefined() + expect(rig.store.getRecord(SESSION)!.providerHandleChain).toHaveLength(1) + rig.finishCompact() + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + await eventually(() => + expect(rig.store.getRecord(SESSION)?.conversationCommand?.state).toBe('completed') ) - expect(await rig.queuePause(replacementId)).toBeNull() + expect(rig.store.getRecord(SESSION)!.providerContextBoundary).toEqual(boundary) + expect(journal.context.floor()).not.toBeNull() }) - it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => { + it('a waiting card sent after clear gets one provider echo bubble in that context', async () => { const working = await rig.workingSend() - const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result) + const draftId = await queuedId(rig.send('waiting text', 'queue-if-active').result) await rig.stop() await rig.settleAccepted(working, 'a') - const cleared = await command('clear') - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } - expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({ - ok: true, - value: { submission: expect.anything() } - }) - const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId) - if (!journal || !sent) { - throw new Error('expected the carried draft sent on the replacement') - } + expect(await command('clear')).toMatchObject({ ok: true }) + expect(await rig.sendNow(draftId, hostTestOperationId())).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const sent = journal.submissions().findLast((entry) => entry.queuedMessageId === draftId)! + await eventually(() => + expect(activeProviderContext(rig.store.getRecord(SESSION)!).head).not.toBeNull() + ) + const thread = activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle.nativeId await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 }, - hostTestMessage('carried text'), + { provider: 'codex', threadId: thread, turnId: 'turn-echo', ordinal: 0 }, + hostTestMessage('waiting text'), { fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - const snapshot = await rig.host.journalSnapshot(replacementId) - const userBubbles = snapshot.items.filter( - (item) => item.body.kind === 'message' && item.body.role === 'user' - ) - expect(userBubbles).toHaveLength(1) - expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({ - dispatchState: 'accepted' - }) + const floor = journal.context.floor()!.sequence + expect( + journal + .snapshot() + .items.filter( + (item) => + item.sequence > floor && item.body.kind === 'message' && item.body.role === 'user' + ) + ).toHaveLength(1) + expect(journal.submission(sent.clientMessageId)?.dispatchState).toBe('accepted') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index f619112a2f31..ee7a26af161e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -495,39 +495,47 @@ describe('a /compact that waits in line', () => { expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected' }) }) - it('a /clear drops a waiting command card instead of carrying it to the new chat', async () => { + it('a /clear withdraws a waiting command and keeps ordinary cards paused in order', async () => { const working = await rig.workingSend() - await queuedCompact() - const draft = await rig.send('carried draft', 'queue-if-active').result - if (!draft.ok || !('queued' in draft.value)) { - throw new Error('expected a queued receipt') - } + const first = rig.send('first draft', 'queue-if-active') + await first.result + const compactId = await queuedCompact() + const last = rig.send('last draft', 'queue-if-active') + await last.result await rig.stop() await rig.settleAccepted(working, 'a') - const cleared = await clear() - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } - expect(await rig.drafts()).toEqual([]) - expect(await rig.drafts(replacementId)).toEqual([ - { messageId: draft.value.queued.messageId, state: 'waiting' } + expect(await clear()).toMatchObject({ + ok: true, + value: { command: 'clear', state: 'completed' } + }) + expect(await rig.drafts()).toEqual([ + { messageId: first.id, state: 'waiting' }, + { messageId: last.id, state: 'waiting' } ]) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.get(compactId) + ).toMatchObject({ state: 'withdrawn' }) + expect(await rig.queuePause()).toBeNull() + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.pauses() + ).toContainEqual( + expect.objectContaining({ reason: 'cleared', messageIds: [first.id, last.id] }) + ) + expect(await rig.handoff(first.id)).toBeUndefined() + expect(await rig.handoff(last.id)).toBeUndefined() expect(rig.compact).not.toHaveBeenCalled() }) - it('a /clear with only a command card waiting opens no replacement conversation for it', async () => { + it('a /clear with only a waiting command keeps the same chat with no queue pause', async () => { const working = await rig.workingSend() await queuedCompact() await rig.stop() await rig.settleAccepted(working, 'a') - const cleared = await clear() - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } + expect(await clear()).toMatchObject({ ok: true }) expect(await rig.drafts()).toEqual([]) - expect(rig.host.collaboratorsForTests().sessions.has(replacementId)).toBe(false) + expect(await rig.queuePause()).toBeNull() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(rig.store.listRecords()).toHaveLength(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 83738cf91a4c..86fff5a7b973 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -3,6 +3,7 @@ import { vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -13,6 +14,8 @@ import { } from './structured-agent-session-host-test-data' export type QueuedRigProviderOptions = { + rewind?: NonNullable + recoverRewind?: NonNullable /** A child started for a chat whose chain already names a thread resumes it, so a chat whose * child closed or died can start another. */ restartable?: true @@ -62,9 +65,12 @@ export function createQueuedRigProvider( throw failure } events = sink - const resumes = - options.restartable === true && - (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 + const record = store.getRecord(identity.sessionId) + const context = record ? activeProviderContext(record) : null + const resumes = options.restartable === true && context?.head != null + const thread = context?.pendingClear + ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` + : (context?.head?.handle.nativeId ?? THREAD) return { process: { hostId: 'local', @@ -76,7 +82,7 @@ export function createQueuedRigProvider( ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), + handle: codexProviderHandle(thread), origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW @@ -91,7 +97,11 @@ export function createQueuedRigProvider( ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) + setOption: vi.fn(async () => undefined), + ...(options.rewind + ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } + : {}), + ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}) } /** What the provider's translator writes when a /compact's turn ends, as a success. */ 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 fb4cf151174d..ef7e36953bd3 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 @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, vi } from 'vitest' +import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -189,7 +190,7 @@ export async function createQueuedMessageTestRig( clientMessageId: id, providerIdentity: { provider: 'codex', - threadId: THREAD, + threadId: activeProviderContext(store.getRecord(SESSION)!).head?.handle.nativeId ?? THREAD, turnId: `turn-${itemId}`, ordinal: 0 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 51b4c2f61f3e..38fbd11f3146 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -1,8 +1,8 @@ // Mid-turn queueing against the real host, store and journal: a capable send // while the session owes work becomes a draft, the drain converts exactly one // draft when the work settles, Stop holds the queue (never withdrawing text) -// until a user send starts its turn and lifts the pause, /clear carries the -// cards to its replacement session, and a refused conversion comes back as a +// until a user send starts its turn and lifts the pause, /clear keeps cards in +// the same conversation, and a refused conversion comes back as a // returned card while a withdrawn one waits again. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -607,19 +607,15 @@ describe('/clear', () => { ).toBe(false) }) - it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => { + it('preserves waiting cards in place and pauses them until user action for every client', async () => { const [firstId, secondId] = await pausedDrafts() const operationId = hostTestOperationId() const cleared = await clear(operationId) expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } - // The source's cards are spent tombstones; the replacement shows them as plain waiting cards, - // held by the clear with no row (nothing runs there), until the user acts: Resume, or their - // next send starting its turn. - expect(await drafts()).toHaveLength(0) expect(await drafts(replacementId)).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } @@ -627,7 +623,11 @@ describe('/clear', () => { expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. await new Promise((resolve) => setTimeout(resolve, 250)) - expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) + expect( + (await host.journalSnapshot(replacementId)).submissions.filter( + (entry) => entry.queuedMessageId !== undefined + ) + ).toHaveLength(0) // A lost acknowledgement's replay re-runs nothing and duplicates nothing. const replayed = await clear(operationId) expect(replayed).toMatchObject({ ok: true, replayed: true }) @@ -639,7 +639,7 @@ describe('/clear', () => { }) }) - it("carries who each card is from on its body, fingerprinted for the replacement's echo", async () => { + it("preserves each card's sender and fingerprint across clear", async () => { const party = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null } const from: AgentMessageSource = { kind: 'agent', @@ -652,9 +652,9 @@ describe('/clear', () => { await stop() await settleAccepted(working, 'a') const cleared = await clear(hostTestOperationId()) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } const rows = host.collaboratorsForTests().sessions.get(replacementId)?.journal.queuedMessages.list() ?? [] @@ -665,12 +665,12 @@ describe('/clear', () => { ) }) - it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + it("the clear pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { @@ -692,29 +692,19 @@ describe('/clear', () => { ) }) - it('a carry whose insert fails leaves no pause over the empty replacement', async () => { - await pausedDrafts() - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const insert = vi - .spyOn(JournalQueuedMessages.prototype, 'insert') - .mockRejectedValueOnce(new Error('disk full')) - let replacementId: string | undefined + it('clears without copying or inserting any queued card', async () => { + const ids = await pausedDrafts() + const insert = vi.spyOn(JournalQueuedMessages.prototype, 'insert') try { - const cleared = await clear(hostTestOperationId()) - replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(await clear(hostTestOperationId())).toMatchObject({ ok: true }) + expect(insert).not.toHaveBeenCalled() + expect((await drafts()).map((card) => card.messageId)).toEqual(ids) } finally { insert.mockRestore() - warned.mockRestore() - } - if (!replacementId) { - throw new Error('expected a replacement session') } - expect(await drafts(replacementId)).toHaveLength(0) - const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal && structuredQueuePauses(journal)).toEqual([]) }) - it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { + it('preserves a returned card and its refusal after clear', async () => { const working = await workingSend() const queued = await send('refused then cleared', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -728,15 +718,12 @@ describe('/clear', () => { expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) const cleared = await clear(hostTestOperationId()) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } - // The refusal belonged to the source's submissions; on the replacement the - // text is simply a waiting draft again, behind the replacement's pause. - expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'returned' }]) expect(await rig.queuePause(replacementId)).toBeNull() - expect(await drafts()).toHaveLength(0) }) it('a clear an older build left prepared holds no draft: it drains when the turn settles', async () => { @@ -758,15 +745,15 @@ describe('/clear', () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a clear with no drafts carries nothing and answers exactly as before', async () => { + it('a clear with no drafts keeps the existing conversation open', async () => { const cleared = await clear(hostTestOperationId()) expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } - // Nothing to carry, so nothing opened the new conversation. - expect(host.hasSession(replacementId)).toBe(false) + // The journal remains open, with no child started. + expect(host.hasSession(replacementId)).toBe(true) expect(await drafts(replacementId)).toHaveLength(0) }) }) 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 f57723ed61c7..28be32c15759 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 @@ -1,5 +1,5 @@ // `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and -// /clear's carry of the source's drafts to its replacement session. Settling +// mutations of the conversation's queued drafts. Settling // operations stamp op-scoped tombstone receipts, so a lost acknowledgement // replays from the rows themselves — never from the operation ledger, which // records only that an operation happened. No mutation returns draft text: @@ -23,12 +23,10 @@ import { import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { structuredQueueHold } from './structured-agent-session-queued-messages' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { resumeStructuredQueue, structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' -import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext @@ -72,72 +70,6 @@ export async function withdrawQueuedMessagesForOperation( }) } -/** - * /clear's carry: the source's unsettled drafts become rows on the replacement - * session — the SAME for every client version, with no text on the wire — so the - * cards stay visible where the user now is. The replacement's queue starts - * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just - * discarded, so they wait for the user's next turn there, or Resume, rather than - * sending into the fresh context unasked. Each card records the conversation it - * came from, which IS that pause, so the drain never sees a carried card unpaused - * and no pause outlives the cards. Runs after the clear commits, opening the - * replacement's conversation only when there are drafts to carry; the source - * rows are then tombstoned. A command card (a waiting /compact) is tombstoned - * without a copy. Bookkeeping around the clear: a failure, or a crash - * before the carry, leaves the cards on the superseded source — whose - * supersession fence already blocks the drain — reported, never gating the - * clear. A crash between the copy and the tombstone leaves both, which the - * fence also makes harmless: nothing is lost and nothing runs. - */ -export async function carryQueuedMessagesToClearReplacement( - ctx: AgentSessionTurnContext, - input: { - replacementSessionId: string - openReplacementJournal: () => Promise - callerKey: string - operationId: string - } -): Promise { - try { - const rows = unsettledQueuedMessages(ctx.journal) - if (rows.length === 0) { - return - } - // A command belongs to the discarded context, so it is withdrawn instead of carried. - const carried = rows.filter((row) => !row.body.command) - if (carried.length > 0) { - const replacement = await input.openReplacementJournal() - if (!replacement) { - throw new Error('the replacement journal is not open') - } - for (const row of carried) { - // A returned card carries over as a plain waiting draft — its refusal - // belonged to the source's submissions. The fingerprint is re-scoped to the - // replacement, or its echo could never alias the sent bubble. - await replacement.queuedMessages.insert({ - messageId: row.messageId, - body: row.body, - fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), - hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId - }) - } - } - await withdrawQueuedMessagesForOperation(ctx.journal, { - sessionId: ctx.sessionId, - messageIds: rows.map((row) => row.messageId), - callerKey: input.callerKey, - operationId: input.operationId - }) - } catch (error) { - ctx.logger.warn("carrying queued drafts to /clear's replacement failed", { - scope: 'clear-queued-carry', - sessionId: ctx.sessionId, - replacementSessionId: input.replacementSessionId, - error - }) - } -} /** Draft actions run like any mutation: admitted on the session's lane, the * conversation opened for the write. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 2b0aae62f487..e6ecad009d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -242,13 +242,14 @@ describe("a /clear's carried cards", () => { await rig.stop() await rig.settleAccepted(working, 'stopped') const cleared = await clear() - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined if (!replacementId) { - throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) + throw new Error(`expected clear to succeed: ${JSON.stringify(cleared)}`) } // Held there, unshown: nothing runs in the fresh conversation. expect(await rig.queuePause(replacementId)).toBeNull() expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ + 'stopped', 'cleared' ]) // Idle there, so the person's send goes straight out rather than queueing. @@ -276,7 +277,12 @@ describe("a /clear's carried cards", () => { await rig.host.settleLateDispatch({ sessionId: replacementId, clientMessageId: sent.value.clientMessageId, - providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 } + providerIdentity: { + provider: 'codex', + threadId: rig.store.getRecord(replacementId)!.providerHandleChain.at(-1)!.handle.nativeId, + turnId: 'turn-hi', + ordinal: 0 + } }) expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) expect(structuredQueuePauses(journal(replacementId))).toEqual([]) @@ -302,10 +308,10 @@ describe('no stored pause', () => { envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), ...fields }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined expect( replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) - ).toEqual(['cleared']) + ).toEqual(['stopped', 'cleared']) expect(pauseTables()).toBe(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 96f1a390155f..76e4942ffb6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -71,7 +71,7 @@ function readAtRest(agents: StructuredAgentRegistry) { const resting = { child: null, params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null } + journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts new file mode 100644 index 000000000000..09e48d3a3ef2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-context-clear.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' +import { + structuredAgentSessionWorkingAtStop, + structuredAgentSessionWorkInFlight +} from './structured-agent-session-working-at-teardown' +import { + record, + marker, + journal, + turnItem, + submission, + NOW, + SESSION, + TEARDOWN_CURRENT +} from './structured-agent-session-restart-resume-test-harness' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' + +const boundary = { operationId: 'clear', afterFence: 1, clearedAt: NOW } +const clear: AgentJournalRenderItem = { + itemId: 'clear', + sequence: 5, + revision: 1, + observedAt: NOW, + body: { kind: 'status', text: 'Context cleared', contextClear: boundary } +} +const value = (): ReturnType & { providerContextBoundary: typeof boundary } => ({ + ...record(), + providerHandleChain: [], + providerContextBoundary: boundary +}) +const set = (m = marker(), r = value()) => + structuredAgentSessionResumableSet({ + markers: [m], + getRecord: () => r, + supportsRecord: () => true, + latestPrompt: () => 'prior prompt', + movedOn: () => false, + savedByNewerOrca: () => false + }) + +describe('restart offers after context clear', () => { + it('retires the old-context offer before any new provider handle exists', () => { + expect(set()).toEqual({ candidates: [], superseded: [marker()] }) + expect(set({ ...marker(), contextClearOperationId: 'previous-clear' })).toEqual({ + candidates: [], + superseded: [{ ...marker(), contextClearOperationId: 'previous-clear' }] + }) + }) + + it('does not treat an old unknown send as new-context work during teardown', () => { + const unknown = { ...submission('old-send', 'unknown'), acceptedSequence: 2 } + expect( + structuredAgentSessionWorkInFlight([turnItem('old', 'running'), clear], [unknown]) + ).toBeNull() + expect( + structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { + journal: journal([turnItem('old', 'running'), clear], [unknown]), + child: { fence: 2 } + }, + getRecord: value, + childWork: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + ).toBeNull() + }) + + it('stamps a current-context offer and keeps it eligible after restart', () => { + const proved = { ...record().providerHandleChain[0], linkId: 'new', mintedAtFence: 2 } + const currentRecord = { + ...value(), + providerHandleChain: [proved] + } + const turn = { ...turnItem('new-turn', 'running'), sequence: 6 } + const offer = structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal([clear, turn]), child: { fence: 2 } }, + getRecord: () => currentRecord, + childWork: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + })! + expect(offer.contextClearOperationId).toBe('clear') + expect(set(offer, currentRecord).candidates).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 6c1dbde9fcd8..65927cc02c0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -113,6 +113,10 @@ export function structuredAgentSessionResumableSet( if (!record || !input.supportsRecord(record)) { continue } + if (record.providerContextBoundary?.operationId !== marker.contextClearOperationId) { + superseded.push(marker) + continue + } // The lease must be free and adjudicated. A contested or still-reconciling record is somebody // else's to resolve, and resuming into it is how a session gets two writers. if (input.leaseState !== 'may-be-held' && !isResumableStructuredAgentSessionRecord(record)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts new file mode 100644 index 000000000000..dedb69eedae9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts @@ -0,0 +1,68 @@ +import { expect, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data' + +type RewindReplayFixture = { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore + rewind: Mock> + recoverRewind: Mock> + seed: () => Promise + params: (itemId: string) => Promise[1]> +} +const caller = { callerKey: 'desktop' } + +export async function expectSupersededRewindReplay({ + host, + store, + rewind, + recoverRewind, + seed, + params +}: RewindReplayFixture) { + const target = await seed() + const firstRequest = await params(target) + const first = await host.rewind(caller, firstRequest) + expect(first).toMatchObject({ ok: true }) + const secondRequest = await params( + agentJournalItemKey({ + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: 'kept', + ordinal: 0 + }) + ) + recoverRewind.mockResolvedValueOnce({ ok: true, items: [] }) + expect(await host.rewind(caller, secondRequest)).toMatchObject({ ok: true }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.operationId).toBe( + secondRequest.envelope.clientOperationId + ) + const effects = rewind.mock.calls.length + const replay = await host.rewind(caller, firstRequest) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (first.ok && replay.ok) { + expect(replay.value).toEqual(first.value) + } + expect(rewind).toHaveBeenCalledTimes(effects) +} + +export async function expectRetainedPrefixRewindReplay({ + host, + rewind, + seed, + params +}: Pick) { + const target = await seed() + const request = await params(target) + const result = await host.rewind(caller, request) + expect(result).toMatchObject({ ok: true }) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( + request.expectedEpoch + ) + expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) + expect(rewind).toHaveBeenCalledTimes(1) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index a5991fa2f1fc..1bbb02777243 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,7 @@ +import { + expectSupersededRewindReplay, + expectRetainedPrefixRewindReplay +} from './structured-agent-session-rewind-replay.test-fixture' import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -190,6 +194,9 @@ async function params(itemId: string, epoch?: string) { const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 } describe('host rewind', () => { + it('replays the original ordinary rewind after another rewind replaces its record', () => + expectSupersededRewindReplay({ host, store, rewind, recoverRewind, seed, params })) + it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => { await expectRawStopNoteRewindRecovery({ host, store, rewind }) }) @@ -266,18 +273,8 @@ describe('host rewind', () => { expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) expect(rewind).toHaveBeenCalledTimes(1) }) - it('replaces the epoch with the retained prefix and replays without another provider call', async () => { - const target = await seed() - const request = await params(target) - const result = await host.rewind(caller, request) - expect(result).toMatchObject({ ok: true }) - expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) - expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( - request.expectedEpoch - ) - expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) - expect(rewind).toHaveBeenCalledTimes(1) - }) + it('replaces the epoch with the retained prefix and replays without another provider call', () => + expectRetainedPrefixRewindReplay({ host, rewind, seed, params })) it('keeps when each kept message was first seen, not when the rewind re-read it', async () => { const target = await seed() const kept = (await host.journalSnapshot(HOST_TEST_SESSION)).items[0]! 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 58003abee526..bd819d09f2ca 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 @@ -1,3 +1,4 @@ +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { @@ -5,14 +6,13 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRewindParams, AgentSessionRewindRecord, AgentSessionRewindResult } from '../../../shared/agent-session-rewind' import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { openWithAgent } from './structured-agent-session-send-preparation' @@ -21,6 +21,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' 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' @@ -33,6 +34,7 @@ export async function rewindStructuredAgentSession( const { sessionId, clientOperationId } = params.envelope const store = context.deps.store return context.serialize(sessionId, async () => { + let completionReceipt: JournalOperationReceipt | undefined const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, @@ -41,7 +43,20 @@ export async function rewindStructuredAgentSession( callerKey: caller.callerKey, envelope: params.envelope, // Only the provider can do this, so an agent at rest is started first. - prepareSession: openWithAgent(context, params.envelope), + prepareSession: openWithAgent(context, params.envelope, () => { + const journal = context.sessions.get(sessionId)?.journal + const floor = journal?.context.floor() + if (!journal || !floor) { + return { ok: true } + } + if (journal.cursor().epoch !== params.expectedEpoch) { + return rewindRefusal('stale-epoch') + } + const target = journal.snapshot().items.find((item) => item.itemId === params.itemId) + return !target || target.sequence <= floor.sequence + ? rewindRefusal('invalid-target') + : { ok: true } + }), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), now: context.now, @@ -49,7 +64,16 @@ export async function rewindStructuredAgentSession( method: 'agentSession.rewind', fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch }, recoverUnknownFromDurableState: true, - settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }), + settledOutcome: (rewind: AgentSessionRewindResult) => ({ + status: 'succeeded' as const, + sessionId, + rewind + }), + settlesWithWrite: true, + successReceipt: () => ({ + write: (db) => completionReceipt!.write(db), + committed: () => completionReceipt!.committed() + }), replay: (_ctx, outcome) => { if (outcome.status === 'succeeded' && outcome.rewind) { return outcome.rewind @@ -59,7 +83,11 @@ export async function rewindStructuredAgentSession( prior.callerKey === caller.callerKey && prior.phase === 'completed' && prior.epoch - ? { itemId: prior.itemId, epoch: prior.epoch } + ? { + itemId: prior.itemId, + epoch: prior.epoch, + ...(prior.sequence !== undefined ? { sequence: prior.sequence } : {}) + } : null }, run: async (ctx) => { @@ -79,6 +107,10 @@ export async function rewindStructuredAgentSession( return rewindRefusal('busy') } const snapshot = ctx.journal.snapshot() + const contextFloor = ctx.journal.context.floor() + const contextStart = contextFloor + ? snapshot.items.findIndex((item) => item.sequence > contextFloor.sequence) + : 0 const providerKeys = new Map( snapshot.submissions.flatMap((submission) => submission.dispatchState === 'accepted' && submission.providerItemId @@ -96,7 +128,11 @@ export async function rewindStructuredAgentSession( return rewindRefusal('stale-epoch') } const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId) - const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId)) + const key = + selected === -1 || + (contextFloor && snapshot.items[selected].sequence <= contextFloor.sequence) + ? null + : parseAgentJournalItemKey(providerKey(params.itemId)) // The head belongs to the record's provider: the record store refuses any other. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle if (!key || !head || key.provider !== record.provider) { @@ -108,6 +144,9 @@ export async function rewindStructuredAgentSession( return rewindRefusal('invalid-target') } boundary = snapshot.items.findIndex((item) => { + if (contextFloor && item.sequence <= contextFloor.sequence) { + return false + } const identity = parseAgentJournalItemKey(providerKey(item.itemId)) return ( (identity?.provider === 'codex' && @@ -120,7 +159,7 @@ export async function rewindStructuredAgentSession( return rewindRefusal('invalid-target') } const retained = snapshot.items - .slice(0, boundary) + .slice(Math.max(0, contextStart), boundary) .map(({ itemId, observedAt, turnScope, ...linkage }) => { const body = ctx.journal.itemBody(itemId) if (!body) { @@ -128,7 +167,7 @@ export async function rewindStructuredAgentSession( } return { itemId: providerKey(itemId), - body: withRenamedTurnOpener(body, providerKey), + body: renameRewindTurnOpener(body, providerKey), observedAt, ...(turnScope ? { turnScope } : {}), ...agentJournalLinkageFields(linkage) @@ -148,6 +187,12 @@ export async function rewindStructuredAgentSession( providerItemId: providerKey(params.itemId), expectedEpoch: params.expectedEpoch, phase: 'prepared', + ...(contextFloor && record.providerContextBoundary + ? { + contextClearOperationId: record.providerContextBoundary.operationId, + contextClearSequence: contextFloor.sequence + } + : {}), retained } await persistRewindRecord(store, sessionId, ctx.fence, prepared) @@ -204,8 +249,9 @@ export async function rewindStructuredAgentSession( ) : prepared.retained if ( + confirmed.length > 10_000 || Buffer.byteLength(JSON.stringify(confirmed), 'utf8') > - AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES ) { throw new Error('agent_session_rewind:history-limit') } @@ -217,9 +263,32 @@ export async function rewindStructuredAgentSession( }) const journal = context.sessions.get(sessionId)!.journal await attachContext.runtimeState.flushEventSink(sessionId) - await recoverStructuredRewind(context.deps, sessionId, journal, fence) + await recoverStructuredRewind( + context.deps, + sessionId, + journal, + fence, + undefined, + ctx.now, + (rewind, currentFence, cursor) => { + completionReceipt = store.conversationReceipts.rewind( + sessionId, + currentFence, + rewind, + cursor + ) + return ctx.operationReceipt! + } + ) context.publish(sessionId, journal) - return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } } + return { + ok: true, + value: { + itemId: params.itemId, + epoch: journal.cursor().epoch, + ...(contextFloor ? { sequence: journal.cursor().sequence } : {}) + } + } } } }) @@ -232,18 +301,3 @@ export async function rewindStructuredAgentSession( : result }) } - -/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the - * turn it opened must name it by that key too, or the turn anchors on nothing. */ -function withRenamedTurnOpener( - body: AgentJournalItemBody, - rename: (itemId: string) => string -): AgentJournalItemBody { - if (body.kind === 'turn' && body.userItemId !== undefined) { - return { ...body, userItemId: rename(body.userItemId) } - } - const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined - return body.kind === 'status' && lifecycle?.userItemId !== undefined - ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } - : body -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index bcfeadb67271..95b034969554 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -29,7 +29,7 @@ describe('a send refused by the conversation command it follows', () => { ).toBeNull() }) - it('says a committed /clear cleared the conversation', () => { + it('allows a send to an independently retained old-build clear source', () => { const blocked = structuredAgentSessionSendBlock( withCommand({ ...COMMAND, @@ -40,10 +40,7 @@ describe('a send refused by the conversation command it follows', () => { }) ) - expect(blocked?.refusal).toMatchObject({ - code: 'agent_session_operation_invalid', - details: { reason: 'conversationCleared' } - }) + expect(blocked).toBeNull() }) it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f1bbd673cd62..4ceae6b6c047 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -199,7 +199,7 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() }) - it('does not restart an owner for a send the session refuses anyway', async () => { + it('restarts an independently retained old-build clear source', async () => { await loseOwner() await store.transitionHandoff(SESSION, (current) => ({ ...current, @@ -213,12 +213,10 @@ describe('a send with no live owner', () => { } })) - await expect(host.send(CALLER, sendParams('into a cleared chat'))).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } + await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ + ok: true }) - - expect(acquire).not.toHaveBeenCalled() + await eventually(() => expect(acquire).toHaveBeenCalledOnce()) }) it('restarts an owner that exited while the session stayed readable', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 8221d257017a..4aac5417b656 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -1,14 +1,13 @@ // What a send or a Stop needs from the session before the ledger places its row: the // conversation open. Nothing here needs an owner — a send is accepted into the conversation and // the delivery loop makes the session ready — so a refusal before acceptance is only one the -// conversation itself makes: a rewind in doubt, a cleared conversation, or a journal that cannot be +// conversation itself makes: a rewind in doubt or a journal that cannot be // opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - refuse, - type AgentSessionMutationEnvelope, - type AgentSessionWireRefusal +import type { + AgentSessionMutationEnvelope, + AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { isTuiAgent } from '../../../shared/tui-agent-config' @@ -43,23 +42,6 @@ export function structuredAgentSessionSendBlock( if (rewindInDoubt(record)) { return rewindRefusal('outcome-unknown') } - const command = record?.conversationCommand - // Only a committed clear: one that never committed changed nothing, and an older build's - // unconfirmed record is one of those. - if ( - command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId - ) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCleared' }, - 'This conversation has been cleared. Use the current conversation.' - ) - } - } return null } @@ -117,15 +99,20 @@ export function openForProviderWrite( /** For an operation only the provider can perform: the conversation, then its agent. */ export function openWithAgent( context: Pick, - envelope: AgentSessionMutationEnvelope -): () => Promise { - return async () => { + envelope: AgentSessionMutationEnvelope, + beforeAgent?: () => AgentSessionMutationSessionPreparation +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { const opened = await openConversationForWrite( context.openConversation, envelope, context.deps.logger ) - return opened.ok ? context.ensureAgent(envelope.sessionId) : opened + if (!opened.ok || ledger === 'replay') { + return opened + } + const checked = beforeAgent?.() + return checked && !checked.ok ? checked : context.ensureAgent(envelope.sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 36002750cd57..bb7316eb08ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -2,8 +2,9 @@ * its Stop event's reason (`JournalStopEvent`); the others are in memory only. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' - /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ + /** The user closed this chat or its launch. */ | 'user-close' + | 'context-clear' | 'host-stop' | 'exit' | 'attach-failed' @@ -13,5 +14,5 @@ export type StructuredAgentSessionChildEndCause = * is what a turn's end reads, so never rename an arm. */ export type StructuredAgentSessionStopCause = Extract< StructuredAgentSessionChildEndCause, - 'user-stop' | 'user-close' | 'host-stop' | 'evict' + 'user-stop' | 'user-close' | 'context-clear' | 'host-stop' | 'evict' > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 3f33335fcf78..872d08c1da40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -627,7 +627,7 @@ describe('what is not kept', () => { }) describe('/clear carries a kept card', () => { - it('the first turn in the new conversation releases it, after that turn', async () => { + it('the first turn after clear releases the same kept card after that turn', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) await quitRestart() expect(await rig.drafts()).toHaveLength(1) @@ -636,9 +636,9 @@ describe('/clear carries a kept card', () => { envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), ...fields }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + const replacementId = cleared.ok ? SESSION : undefined if (!replacementId) { - throw new Error('expected a replacement session') + throw new Error('expected clear to succeed') } expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) // Nothing runs in the fresh conversation, so no paused row. @@ -649,7 +649,7 @@ describe('/clear carries a kept card', () => { envelope: { sessionId: replacementId, clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, + expectedRuntimeFence: rig.store.getRecord(replacementId)!.lease.runtimeFence, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: replacementId, @@ -670,7 +670,12 @@ describe('/clear carries a kept card', () => { await rig.host.settleLateDispatch({ sessionId: replacementId, clientMessageId: turnId, - providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } + providerIdentity: { + provider: 'codex', + threadId: rig.store.getRecord(replacementId)!.providerHandleChain.at(-1)!.handle.nativeId, + turnId: 'turn-x', + ordinal: 0 + } }) await eventually(() => expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear']) 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 8279cf83d183..aae177bbd650 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 @@ -1,3 +1,4 @@ +import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear' // Whether a session was genuinely working when this process stopped it, and what it was doing. // // Read off the LIVE host state, never off a persisted status field. That distinction is the whole @@ -75,11 +76,12 @@ export function structuredAgentSessionWorkInFlight( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] ): AgentSessionResumeWork | null { - const turnId = activeStructuredAgentSessionTurnId(items) + const current = agentSessionCurrentContextRows(items, submissions) + const turnId = activeStructuredAgentSessionTurnId(current.items) if (turnId) { return { kind: 'turn', id: turnId } } - const submission = pendingSubmissionInFlight(submissions) + const submission = pendingSubmissionInFlight(current.submissions) return submission ? { kind: 'submission', id: submission.clientMessageId } : null } @@ -185,27 +187,27 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() + const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. const unanswered = input.startAnswered?.(sessionId) === false - const handedOver = snapshot.submissions.filter( + const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && !(unanswered && isUnansweredHandedOverSubmission(submission)) ) const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( - { items: snapshot.items, submissions: handedOver }, + { items: current.items, submissions: handedOver }, childWork, session.child.fence ) if (status.state === 'done') { return null } - const work = structuredAgentSessionResumeWork(snapshot.items, handedOver) - const head = agentSessionProviderHandleChainHead( - input.getRecord(sessionId)?.providerHandleChain ?? [] - ) + const work = structuredAgentSessionResumeWork(current.items, handedOver) + const record = input.getRecord(sessionId) + const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null if (!work || !head) { return null } @@ -213,17 +215,20 @@ 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(snapshot.items), + prompts: pendingPrompts(current.items), tasks: liveTasks(childWork) } return { sessionId, work, - latestUserItemId: latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null, + latestUserItemId: latestStructuredAgentSessionUserItem(current.items)?.itemId ?? null, recordedAt: input.now, trigger: input.trigger, teardownId: input.teardownId, journalCursor: snapshot.cursor, + ...(record?.providerContextBoundary + ? { contextClearOperationId: record.providerContextBoundary.operationId } + : {}), // Root, not key: the close path advances Claude's leaf moments after this runs, and a key // comparison would then refuse the session forever. providerHandleRoot: agentSessionProviderHandleRoot(head.handle), 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 94fe8f4120b2..50ddcbc08e9e 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 @@ -166,7 +166,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) - it('refuses on a committed clear', () => { + it('does not permanently refuse an old-build clear source', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. const record = { lease: {}, @@ -177,10 +177,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => replacementSessionId: 'clear-replacement' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(undefined), record, [])).toMatchObject({ - code: 'agent_session_operation_invalid', - details: { reason: 'conversationCleared' } - }) + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) it('at handover, lets the command itself and messages queued behind it wait', () => { 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 d20dcd45e7a0..7d81e4a77180 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 @@ -2,12 +2,12 @@ import { agentChildWorkViewOffersStop, type AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' -import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' 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 type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -35,26 +35,6 @@ function blocked( return refuse('agent_session_operation_invalid', { reason }, message) } -/** - * The /clear this caller committed on a conversation whose tab has since moved to its replacement. - * A /clear it presses there again asks for what that one already did; any other caller, or a - * cleared conversation opened again from history, reads `conversationCleared` instead. - */ -export function committedClearOfCaller( - record: AgentSessionRecord | null, - callerKey: string, - tabId: string | null -): AgentSessionConversationCommandRecord | null { - const command = record?.conversationCommand - return command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId && - command.callerKey === callerKey && - tabId === null - ? command - : null -} - export function conversationCommandInFlight(): AgentSessionWireRefusal { return blocked('conversationCommandInFlight', 'Wait for the conversation operation to finish.') } @@ -74,20 +54,13 @@ export function conversationCommandBlocked( childWork: readonly AgentChildWorkView[] | undefined, admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { - const items = ctx.journal.snapshot().items + const { items, submissions } = agentSessionCurrentContextRows( + ctx.journal.snapshot().items, + ctx.journal.submissions() + ) if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown') } - if ( - record.conversationCommand?.command === 'clear' && - record.conversationCommand.phase === 'committed' && - record.conversationCommand.replacementSessionId - ) { - return blocked( - 'conversationCleared', - 'This conversation has been cleared. Open the current conversation to continue.' - ) - } if (record.lease.handoffStage || record.lease.handoffOperationId) { return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } @@ -118,13 +91,11 @@ export function conversationCommandBlocked( // The Working indicator's own rule, so "unsettled" is exactly what the chat shows as working. // At handover the queued messages are those behind this command, waiting for it. if ( - ctx.journal - .submissions() - .some( - (entry) => - !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && - isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence) - ) + submissions.some( + (entry) => + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && + isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence) + ) ) { return blocked( 'messagesUnsettled', diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 338bb494a4cc..a8c40239a179 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -10,7 +10,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import type { StructuredAgentSessionHost } from './structured-agent-session-host' export class StructuredConversationCommandController { - /** Held only by a clear, which replaces the conversation a send would land in. A compaction is + /** Held only while a clear resets the context a send would land in. A compaction is * a queued message, and sends accepted behind it wait for it in the queue. */ readonly pending = new Map() constructor( @@ -29,7 +29,9 @@ export class StructuredConversationCommandController { run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { if (params.command === 'compact') { - return runStructuredCompaction(this.context(), this.host, caller, params) + return runStructuredCompaction(this.context(), this.host, caller, params, { + clearInFlight: this.pending.has(params.envelope.sessionId) + }) } const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId]) const pending = this.pending.get(params.envelope.sessionId) @@ -48,49 +50,4 @@ export class StructuredConversationCommandController { this.context().wakeQueuedDrain?.(params.envelope.sessionId) }) } - - replacements = () => { - const store = this.context().deps.store - const records = store.listRecords() - const visible = new Set(store.listVisibleSessionIds()) - const byId = new Map(records.map((record) => [record.sessionId, record])) - const destinations = new Map() - const destination = (source: string): string | null => { - const path = new Set() - let current = source - while (!destinations.has(current) && !path.has(current)) { - path.add(current) - const command = byId.get(current)?.conversationCommand - if ( - command?.command !== 'clear' || - command.phase !== 'committed' || - !command.replacementSessionId - ) { - destinations.set(current, current) - break - } - current = command.replacementSessionId - } - const target = destinations.get(current) ?? null - for (const id of path) { - destinations.set(id, target) - } - return target - } - return records.flatMap((record) => { - const target = destination(record.sessionId) - const sessionId = target !== record.sessionId ? target : null - // Explicit history reveals remain readable; closed replacements stay closed. - return sessionId && visible.has(sessionId) && !visible.has(record.sessionId) - ? [ - { - sourceSessionId: record.sessionId, - sessionId, - workspaceId: record.location.workspaceId, - agent: record.provider - } - ] - : [] - }) - } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index a578f87b6ce2..a2597bfd0a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -1,4 +1,5 @@ -import { createHash } from 'node:crypto' +import { listStructuredProviderSessionOwnership } from './structured-provider-session-ownership' +import { findConflictingStructuredAdoption } from '../structured-agent-session-history-adoption' import { cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,10 +11,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { - AgentSessionAcquisitionRefusal, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { HOST_TEST_NOW, @@ -153,595 +151,176 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -/** Every record but the first is one a committed /clear points at: nothing is left orphaned. */ -function expectEveryReplacementPointedAt(): void { - const records = store.listRecords() - const pointedAt = new Set( - records.map((record) => record.conversationCommand?.replacementSessionId) +const journal = () => host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal +const clearRows = async () => + (await host.journalSnapshot(HOST_TEST_SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.contextClear ) - expect( - records.filter( - (record) => - record.sessionId !== HOST_TEST_SESSION && - !pointedAt.has(record.sessionId) && - !notFromAClear.has(record.sessionId) - ) - ).toEqual([]) -} -const notFromAClear = new Set() - -afterEach(() => { - expectEveryReplacementPointedAt() - notFromAClear.clear() -}) - -function startsFor(sessionId: string): number { - return vi - .mocked(adapter.acquire) - .mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length -} - -function sendTo(sessionId: string, text: string) { - const body = hostTestMessage(text) - return host.send(caller, { - body, - envelope: { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } - }) -} -async function clearCommits( - params = commandParams('clear'), - who: { callerKey: string } = caller -): Promise { +async function clearCommits(params = commandParams('clear'), who = caller) { const result = await host.conversationCommand(who, params) - expect(result).toMatchObject({ - ok: true, - value: { - command: 'clear', - phase: 'committed', - state: 'completed', - replacementSessionId: expect.any(String) - } - }) - expect(result.ok && 'error' in result.value).toBe(false) - return result.ok ? result.value.replacementSessionId! : '' -} - -/** The idle sweep stops the source's agent, leaving the chat at rest. */ -async function sourceAtRest(): Promise { - clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 - await host.collaboratorsForTests().lifetime.idleSweep.tick() - expect(host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null).toBeNull() - expect(store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('released') -} - -async function submissionOf(sessionId: string, clientMessageId: string) { - return (await host.journalSnapshot(sessionId)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -async function errorRows(sessionId: string): Promise { - return (await host.journalSnapshot(sessionId)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) + expect(result).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + if (!result.ok) { + throw new Error('clear refused') + } + expect(result.value.replacementSessionId).toBeUndefined() + expect(result.fence).toBe(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + return result } -describe('/clear starts nothing', () => { - it('commits the marker and an at-rest replacement founded from the source, and starts no agent', async () => { - await store.replaceSessionOptions({ - sessionId: HOST_TEST_SESSION, - fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, - options: { model: 'test-model', effort: 'low' }, - now: HOST_TEST_NOW - }) +describe('same-conversation clear', () => { + it('keeps the record, journal, tab, options and earlier messages while starting nothing', async () => { const source = store.getRecord(HOST_TEST_SESSION)! - const params = commandParams('clear') - const replacement = await clearCommits(params) - expect(adapter.acquire).toHaveBeenCalledTimes(1) - expect(replacement).toMatch(/^clear-[0-9a-f]{40}$/) - expect(store.getRecord(replacement)).toMatchObject({ - sessionId: replacement, - location: source.location, - provider: source.provider, - accountHome: source.accountHome, - options: { model: 'test-model', effort: 'low' }, - // Never started: nothing to resume, so its first start is a fresh conversation. - providerHandleChain: [], - lease: { - runtimeFence: 1, - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - reservedSpawnToken: null, - unreconciled: false, - claimKeyId: 'key' - } - }) - // Written at the fence the old agent's stop released the lease at. - expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: source.lease.runtimeFence + 1 - }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toEqual({ - command: 'clear', - phase: 'committed', - state: 'completed', - runtimeFence: source.lease.runtimeFence + 1, - operationId: params.envelope.clientOperationId, - callerKey: caller.callerKey, - replacementSessionId: replacement - }) - expect(store.listVisibleSessionIds()).toEqual([replacement]) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) - expect(host.collaboratorsForTests().sessions.get(replacement)?.child ?? null).toBeNull() - expect((await host.history({ sessionId: replacement, direction: 'tail' })).page.items).toEqual( - [] - ) - // The old conversation stays readable and takes no more messages. - expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'conversationCleared' } } - }) - expect(adapter.dispatch).not.toHaveBeenCalled() - }) - - it('copies the launch arguments the source was pinned to', async () => { - const pinned = 'session-pinned' - expect( - await host.attach( - caller, - hostTestAttachParams(null, { - envelope: { - sessionId: pinned, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - launchArgs: ['--flag'] - }) - ) - ).toMatchObject({ ok: true }) - const clear = { ...commandParams('clear') } - clear.envelope = { - sessionId: pinned, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(pinned)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: pinned, - fields: { command: 'clear' } - }) - } - notFromAClear.add(pinned) - const replacement = await clearCommits(clear) - expect(store.getRecord(replacement)?.launchArgs).toEqual(['--flag']) - }) - - it("does not start an at-rest source's agent", async () => { - await sourceAtRest() - const starts = vi.mocked(adapter.acquire).mock.calls.length - const replacement = await clearCommits() - expect(adapter.acquire).toHaveBeenCalledTimes(starts) - expect(startsFor(HOST_TEST_SESSION)).toBe(1) - expect(startsFor(replacement)).toBe(0) - }) - - it("stops a running source's agent before it writes the marker", async () => { - const commit = store.commitConversationClear - let atCommit: { child: unknown; claim: string | undefined } | undefined - vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => { - atCommit = { - child: host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null, - claim: store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus + const tab = store.getSessionTabId(HOST_TEST_SESSION) + await journal().appendItem( + { provider: 'orca', clientMessageId: 'earlier' }, + hostTestMessage('earlier'), + { + fence: source.lease.runtimeFence, + turnScope: { kind: 'thread' } } - return commit(clear) - }) - await clearCommits() - expect(atCommit).toEqual({ child: null, claim: 'released' }) - }) - - // Its own cause, never the reason of whatever Stop the journal holds last. - it("ends a running source's agent as the user closing the chat", async () => { - const session = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! - await session.journal.appendStopEvent( - { reason: 'host-stop' }, - store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence ) - const commit = store.commitConversationClear - let endedAs: string | undefined - vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => { - endedAs = session.lastEndedChild?.cause - return commit(clear) - }) - - await clearCommits() - - expect(endedAs).toBe('user-close') - }) - - it('founds one record per /clear through a chain of clears, starting neither', async () => { - const first = await clearCommits() - const second = await host.conversationCommand(caller, { - command: 'clear', - envelope: { - sessionId: first, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(first)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: first, - fields: { command: 'clear' } - }) - } - }) - expect(second).toMatchObject({ ok: true, value: { replacementSessionId: expect.any(String) } }) - const last = second.ok ? second.value.replacementSessionId! : '' - expect(last).not.toBe(first) - expect(store.listRecords().map((record) => record.sessionId)).toEqual( - expect.arrayContaining([HOST_TEST_SESSION, first, last]) - ) - expect(store.listRecords()).toHaveLength(3) - expect(startsFor(first) + startsFor(last)).toBe(0) - expect(store.listVisibleSessionIds()).toEqual([last]) - expect(host.conversationReplacements()).toEqual([ - expect.objectContaining({ sourceSessionId: HOST_TEST_SESSION, sessionId: last }), - expect.objectContaining({ sourceSessionId: first, sessionId: last }) - ]) - }) - - it('writes nothing when its one write fails, leaving the source usable', async () => { - adapter.rewindSupport = () => ({ supported: true }) - vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full')) - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( - 'disk full' - ) - expect(store.listRecords().map((record) => record.sessionId)).toEqual([HOST_TEST_SESSION]) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toBeUndefined() + const before = await host.journalSnapshot(HOST_TEST_SESSION) + const done = await clearCommits() + const after = await host.journalSnapshot(HOST_TEST_SESSION) + const cleared = store.getRecord(HOST_TEST_SESSION)! + expect(store.listRecords()).toHaveLength(1) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) - // Past every conversation check: only the stale epoch it names stops it. + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(tab) + expect(cleared.providerHandleChain).toEqual([]) + const ownership = listStructuredProviderSessionOwnership(store.listRecords()) + expect(ownership).toEqual([]) expect( - await host.rewind(caller, { - envelope: envelope('agentSession.rewind', { - itemId: 'item-1', - expectedEpoch: 'stale-epoch' - }), - itemId: 'item-1', - expectedEpoch: 'stale-epoch' + findConflictingStructuredAdoption({ + agent: 'codex', + providerSessionId: source.providerHandleChain.at(-1)!.handle.nativeId, + selfSessionId: 'another-conversation', + ownership }) - ).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true + ).toBeNull() + expect(cleared.options).toEqual(source.options) + expect(cleared.providerContextBoundary).toMatchObject({ + afterFence: done.fence, + clearedAt: clock }) - expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true }) + expect(cleared.lease.claimStatus).toBe('released') + expect(after.cursor.epoch).toBe(before.cursor.epoch) + expect(after.items.slice(0, before.items.length)).toEqual(before.items) + expect(await clearRows()).toHaveLength(1) + expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect( + host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.lastEndedChild?.cause + ).toBe('context-clear') }) - it('reruns under the same operation id after its write failed, and commits once', async () => { - vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full')) - const params = commandParams('clear') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('disk full') - const replacement = await clearCommits(params) - expect(store.listRecords()).toHaveLength(2) - expect(store.listVisibleSessionIds()).toEqual([replacement]) + it('starts a fresh provider context on the first send using the clear answer fence', async () => { + const previous = store.getRecord(HOST_TEST_SESSION)!.providerHandleChain.at(-1)!.handle.nativeId + const done = await clearCommits() + const params = sendParams('after clear') + params.envelope.expectedRuntimeFence = done.fence + expect(await host.send(caller, params)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) + const last = vi.mocked(adapter.acquire).mock.calls.at(-1)![0] + expect(last.identity.providerHandle).toBeNull() + const head = store.getRecord(HOST_TEST_SESSION)!.providerHandleChain.at(-1)! + expect(head.handle.nativeId).not.toBe(previous) + expect(head).toMatchObject({ origin: 'created' }) + expect(head.replaces).toBeUndefined() + expect(store.getRecord(HOST_TEST_SESSION)!.providerHandleChain).toHaveLength(1) }) - it('replays a same-id resend with the one replacement it committed', async () => { + it('replays the same operation and makes a new boundary for a new operation', async () => { const params = commandParams('clear') - const replacement = await clearCommits(params) + await clearCommits(params) + const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, - replayed: true, - value: { replacementSessionId: replacement } + replayed: true }) - expect(store.listRecords()).toHaveLength(2) + expect(await clearRows()).toHaveLength(1) + await clearCommits() + expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first) + expect(await clearRows()).toHaveLength(2) + expect(store.listRecords()).toHaveLength(1) + expect(adapter.acquire).toHaveBeenCalledTimes(1) }) - it('reconstructs a committed replacement after the ledger settlement is lost', async () => { - const persist = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { - throw new Error('crash') - } - return persist(input) - }) + it('gives different callers distinct boundaries even when their operation IDs match', async () => { const params = commandParams('clear') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('crash') - const [replacement] = store - .listRecords() - .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'completed', replacementSessionId: replacement } - }) - expect(store.listRecords()).toHaveLength(2) - expect(acquisitions).toBe(1) + await clearCommits(params) + const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary + await clearCommits(params, { callerKey: 'mobile' }) + expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first) + expect(await clearRows()).toHaveLength(2) }) - it("answers another window's /clear after the commit with the cleared refusal, never an in-flight one", async () => { - await clearCommits() + it('rolls back the divider, boundary and ledger success together when storage fails', async () => { + const db = openTestJournalHostDatabase(generationRoot()).db + db.exec( + "CREATE TEMP TRIGGER fail_clear BEFORE UPDATE ON agent_session_records WHEN instr(NEW.record_json, 'providerContextBoundary') > 0 BEGIN SELECT RAISE(ABORT, 'disk full'); END" + ) + const params = commandParams('clear') + const before = await host.journalSnapshot(HOST_TEST_SESSION) + await expect(host.conversationCommand(caller, params)).rejects.toThrow('disk full') + expect(await clearRows()).toHaveLength(0) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).toBe(before.cursor.epoch) + expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).toBeUndefined() expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false, refusal: { details: { reason: 'conversationCleared' } } }) - expect(store.listRecords()).toHaveLength(2) - }) - - it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { - const replacement = await clearCommits() - expect(host.conversationReplacements()).toHaveLength(1) - await host.setSessionTabVisibility(HOST_TEST_SESSION, true) - expect(host.conversationReplacements()).toEqual([]) - await host.setSessionTabVisibility(HOST_TEST_SESSION, false) - await host.setSessionTabVisibility(replacement, false) - expect(host.conversationReplacements()).toEqual([]) - }) - - it('runs a command whose fence the client has not caught up to', async () => { - const params = commandParams('compact') - params.envelope.expectedRuntimeFence++ - expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - }) -}) - -describe("the replacement's first send", () => { - it('starts a fresh conversation and delivers the message', async () => { - const replacement = await clearCommits() - const sent = await sendTo(replacement, 'first message') - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) - expect(startsFor(replacement)).toBe(1) - const [start] = vi - .mocked(adapter.acquire) - .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) - // No provider conversation to resume: the source's thread is not carried over. - expect(start?.identity.providerHandle).toBeNull() - expect(start?.fence).toBe(2) - expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'live' }) + store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status + ).toBe('pending') + db.exec('DROP TRIGGER fail_clear') + await clearCommits(params) + expect(await clearRows()).toHaveLength(1) + expect( + store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status + ).toBe('succeeded') }) - it('starts with the options the user chose, and adopts what the started child reports', async () => { - await store.replaceSessionOptions({ - sessionId: HOST_TEST_SESSION, - fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, - options: { model: 'test-model', effort: 'low' }, - now: HOST_TEST_NOW + it('replays a lost acknowledgment after the atomic commit without another divider', async () => { + const target = journal() + const append = target.context.clear.bind(target.context) + vi.spyOn(target.context, 'clear').mockImplementationOnce(async (...args) => { + await append(...args) + throw new Error('connection lost') }) - adapter.readOptions = async () => ({ - models: [], - current: { model: 'test-model', effort: 'low', fastMode: false } + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost') + expect( + store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status + ).toBe('succeeded') + expect(await host.conversationCommand(caller, params)).toMatchObject({ + ok: true, + replayed: true }) - const replacement = await clearCommits() - expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(startsFor(replacement)).toBe(1)) - const [start] = vi - .mocked(adapter.acquire) - .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) - expect(start?.options).toEqual({ model: 'test-model', effort: 'low' }) - await vi.waitFor(() => - expect(store.getRecord(replacement)?.options).toEqual({ - model: 'test-model', - effort: 'low', - fastMode: 'false' - }) - ) + expect(await clearRows()).toHaveLength(1) }) - it('applies an option picked on the replacement before its first send', async () => { - const replacement = await clearCommits() - const picked = await host.setOption(caller, { - envelope: { - sessionId: replacement, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(replacement)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: replacement, - fields: { key: 'model', value: 'picked-model' } - }) - }, - key: 'model', - value: 'picked-model' - }) - expect(picked).toMatchObject({ ok: true }) - expect(startsFor(replacement)).toBe(0) - expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(startsFor(replacement)).toBe(1)) - const [start] = vi - .mocked(adapter.acquire) - .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) - expect(start?.options).toMatchObject({ model: 'picked-model' }) + it('does not complete when the previous process exit is unverifiable', async () => { + vi.mocked(adapter.closeSession!).mockResolvedValueOnce(false) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).toBeUndefined() + expect(await clearRows()).toHaveLength(0) + expect(adapter.acquire).toHaveBeenCalledTimes(1) }) - it.each([ - { - name: 'not signed in', - error: () => new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn'), - words: "Codex isn't signed in. Run `codex login`.", - kind: 'notSignedIn' - }, - { - name: 'a generic refusal', - error: () => new Error('spawn codex ENOENT'), - words: "Codex couldn't start.", - kind: 'startFailed' - } - ])( - 'fails on the message when the start fails ($name): one row, the message kept as not sent, and Retry starts it', - async ({ error, words, kind }) => { - const replacement = await clearCommits() - vi.mocked(adapter.acquire).mockRejectedValueOnce(error()) - const sent = await sendTo(replacement, 'first message') - expect(sent).toMatchObject({ ok: true }) - const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - await vi.waitFor(async () => - expect(await submissionOf(replacement, clientMessageId)).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind } - }) - ) - const rows = await errorRows(replacement) - expect(rows).toHaveLength(1) - expect(rows[0]).toContain(words) - expect(rows.join(' ')).not.toContain('/clear') - expect((await submissionOf(replacement, clientMessageId))?.reason).not.toContain('/clear') - expect(adapter.dispatch).not.toHaveBeenCalled() - - // Retry resends the same words, which starts the agent and delivers them. - expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) - expect(startsFor(replacement)).toBe(2) - } - ) - - it('starts after a restart between the /clear and the first send', async () => { - const replacement = await clearCommits() + it('starts fresh after a restart between clear and first send', async () => { + await clearCommits() await restartHost() await host.restoreReadableSessions(store.listVisibleSessionIds()) - expect(store.getRecord(replacement)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 1 - }) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) - expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + expect(await host.send(caller, sendParams('after restart'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) - expect(startsFor(replacement)).toBe(1) - }) -}) - -describe('what an older build left', () => { - /** What an older build left when its clear's outcome was lost: gated every write until now. */ - async function restartOverAnOlderBuildsUnconfirmedClear() { - const record = store.getRecord(HOST_TEST_SESSION)! - await store.setConversationCommand(HOST_TEST_SESSION, record.lease.runtimeFence, { - command: 'clear', - runtimeFence: record.lease.runtimeFence, - operationId: hostTestOperationId(), - callerKey: caller.callerKey, - phase: 'prepared', - state: 'unknown', - replacementSessionId: 'clear-from-an-older-build' - }) - await restartHost() - } - - it("accepts a send on a restarted host holding an older build's unconfirmed clear", async () => { - await restartOverAnOlderBuildsUnconfirmedClear() - expect(await host.send(caller, sendParams('after the restart'))).toMatchObject({ ok: true }) + expect(vi.mocked(adapter.acquire).mock.calls.at(-1)![0].identity.providerHandle).toBeNull() + expect(store.getRecord(HOST_TEST_SESSION)!.providerHandleChain).toHaveLength(1) }) - it("clears on a restarted host holding an older build's unconfirmed clear", async () => { - await restartOverAnOlderBuildsUnconfirmedClear() + it('does not start an idle-released provider to clear it', async () => { + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() await clearCommits() + expect(adapter.acquire).toHaveBeenCalledTimes(1) }) - // The build before this one started the replacement before committing, so a crash between the - // two left a started record nothing points at, and its start in the ledger. - it('founds a fresh replacement beside the orphan an older build started for the same operation id', async () => { - const params = commandParams('clear') - const { clientOperationId } = params.envelope - // The older build derived both ids from the operation, then crashed between start and commit. - const digest = createHash('sha256') - .update(JSON.stringify([HOST_TEST_SESSION, caller.callerKey, clientOperationId])) - .digest('hex') - const orphanStart = hostTestAttachParams(null, { - envelope: { - sessionId: `clear-${digest.slice(0, 40)}`, - clientOperationId: `${HOST_TEST_NOW}-${digest.slice(0, 32)}`, - expectedRuntimeFence: null, - payloadFingerprint: '' - } - }) - const orphan = orphanStart.envelope.sessionId - notFromAClear.add(orphan) - expect( - await store.admitMutationOperation({ - callerKey: caller.callerKey, - envelope: params.envelope, - hostFingerprint: params.envelope.payloadFingerprint, - now: clock - }) - ).toMatchObject({ admission: { decision: 'admit' } }) - expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true }) - await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) - - const replacement = await clearCommits(params) - expect(replacement).not.toBe(orphan) - expect(store.listVisibleSessionIds()).toEqual([replacement]) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) - expect(host.collaboratorsForTests().sessions.has(orphan)).toBe(false) - expect(startsFor(orphan)).toBe(1) - expect(startsFor(replacement)).toBe(0) - }) -}) - -// Every press carries its own operation id, so a /clear pressed after one committed is a new call. -describe('a /clear pressed again after it committed', () => { - function replacementsOtherThanTheSource(): string[] { - return store - .listRecords() - .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) - } - - function startsForSource(): number { - return vi - .mocked(adapter.acquire) - .mock.calls.filter(([input]) => input.identity.sessionId === HOST_TEST_SESSION).length - } - - async function expectAnsweredWithTheCommittedClear(replacement: string): Promise { - const starts = startsForSource() - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { phase: 'committed', state: 'completed', replacementSessionId: replacement } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(replacementsOtherThanTheSource()).toEqual([replacement]) - // The cleared conversation's agent is not started to answer it. - expect(startsForSource()).toBe(starts) - expect(store.listVisibleSessionIds()).toEqual([replacement]) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) - } - - it('answers a double press with the clear the first press committed', async () => { - const first = await host.conversationCommand(caller, commandParams('clear')) - const replacement = first.ok ? first.value.replacementSessionId! : '' - expect(replacement).toBeTruthy() - // What the RPC handler does once the first answer is out. - await host.close(HOST_TEST_SESSION, 'user-close') - await expectAnsweredWithTheCommittedClear(replacement) - }) - - it('answers a retyped /clear whose committed answer was lost with that clear', async () => { - const persist = store.recordOperationOutcome.bind(store) - let lost = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (!lost && input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { - lost = true - throw new Error('connection lost') - } - return persist(input) - }) - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( - 'connection lost' - ) - const [replacement] = replacementsOtherThanTheSource() - await expectAnsweredWithTheCommittedClear(replacement!) - }) - - it('refuses a second /clear pressed under a new id while the first is still running', async () => { + it('refuses another clear while the first is in flight', async () => { const [first, second] = await Promise.all([ host.conversationCommand(caller, commandParams('clear')), host.conversationCommand(caller, commandParams('clear')) @@ -751,25 +330,21 @@ describe('a /clear pressed again after it committed', () => { ok: false, refusal: { details: { reason: 'conversationCommandInFlight' } } }) - expect(replacementsOtherThanTheSource()).toHaveLength(1) - }) - - it('still tells another window the conversation was cleared', async () => { - expect((await host.conversationCommand(caller, commandParams('clear'))).ok).toBe(true) - expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false, refusal: { details: { reason: 'conversationCleared' } } }) - expect(replacementsOtherThanTheSource()).toHaveLength(1) + expect(await clearRows()).toHaveLength(1) }) - // Opened again from history on purpose, so that tab stays on the cleared conversation. - it('still tells a cleared conversation opened from history that it was cleared', async () => { - expect((await host.conversationCommand(caller, commandParams('clear'))).ok).toBe(true) - await host.setSessionTabVisibility(HOST_TEST_SESSION, true) - expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'conversationCleared' } } + it('allows independently sending a record with an old-build replacement pointer', async () => { + const source = store.getRecord(HOST_TEST_SESSION)! + await store.setConversationCommand(HOST_TEST_SESSION, source.lease.runtimeFence, { + command: 'clear', + phase: 'committed', + state: 'completed', + operationId: hostTestOperationId(), + callerKey: caller.callerKey, + replacementSessionId: 'old-clear-destination' + }) + expect(await host.send(caller, sendParams('still this conversation'))).toMatchObject({ + ok: true }) - expect(replacementsOtherThanTheSource()).toHaveLength(1) }) }) 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 95bdad666e0a..ab6df634d13f 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 @@ -1,4 +1,3 @@ -import { randomBytes } from 'node:crypto' import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult @@ -8,21 +7,20 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' 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 { - committedClearOfCaller, - conversationCommandBlocked -} from './structured-conversation-command-admission' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' -import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { + providerContextBoundaryForClear, + type AgentSessionConversationClear +} from '../../runtime/agent-session-conversation-command-record' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -44,50 +42,7 @@ export type ConversationCommandParams = { /** Host-local, set by the client-facing command RPC as for an ordinary send. */ userSend?: true } -export type ConversationReplacement = { - sourceSessionId: string - sessionId: string - workspaceId: string - agent: StructuredAgentId -} - -const clearFingerprintOf = (sessionId: string) => - computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId, - fields: { command: 'clear' } - }) - -/** This caller's committed /clear, for a /clear it presses again on the conversation that one - * cleared. Answered before admission, which would refuse it as cleared. */ -async function answerFromCommittedClear( - context: StructuredAgentSessionMutationContext, - caller: StructuredAgentSessionCaller, - { envelope, command }: ConversationCommandParams -): Promise | null> { - const { store } = context.deps - const record = store.getRecord(envelope.sessionId) - const committed = - command === 'clear' && envelope.payloadFingerprint === clearFingerprintOf(envelope.sessionId) - ? committedClearOfCaller(record, caller.callerKey, store.getSessionTabId(envelope.sessionId)) - : null - const session = - committed && (await context.openConversation(envelope.sessionId).catch(() => null)) - return record && committed && session - ? { - ok: true, - replayed: true, - fence: record.lease.runtimeFence, - cursor: session.journal.cursor(), - value: committed - } - : null -} - -/** - * `/clear`: one write that points this conversation at a new, at-rest one and moves its tab there. - * The new conversation's first send starts its agent. - */ +/** Stop the provider and record a fresh-context boundary in the same conversation. */ export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, @@ -103,11 +58,8 @@ export function runStructuredConversationCommand( : null } return context.serialize(sessionId, async () => { - const committed = await answerFromCommittedClear(context, caller, params) - if (committed) { - return committed - } - return admitAndRunAgentSessionMutation({ + let clear: AgentSessionConversationClear | null = null + const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, agents: context.deps.agents, @@ -125,7 +77,17 @@ export function runStructuredConversationCommand( // Written to the conversation, not the agent, so whoever owns the agent does not matter. conversationWrite: true, recoverUnknownFromDurableState: true, - settledOutcome: (value) => ({ status: 'succeeded', sessionId, conversationCommand: value }), + settlesWithWrite: true, + successReceipt: () => + store.conversationReceipts.clear( + () => { + if (!clear) { + throw new Error('agent_session_clear_not_completed') + } + return clear + }, + { callerKey: caller.callerKey, operationId: clientOperationId } + ), replay: (_ctx, outcome) => { if (outcome.status === 'succeeded' && outcome.conversationCommand) { return outcome.conversationCommand @@ -146,42 +108,43 @@ export function runStructuredConversationCommand( if (blocked) { return { ok: false, refusal: blocked } } - // Stopped before the marker, so nothing the old agent does can land after the clear. The - // stop releases the lease, which moves its fence: the marker is written at the new one. - // A /clear replaces this chat: the user closing it. - await context.stopAgent(sessionId, { cause: 'user-close' }) - const fence = store.getRecord(sessionId)!.lease.runtimeFence - const completed = { - command, + await context.stopAgent(sessionId, { cause: 'context-clear' }) + const stopped = store.getRecord(sessionId)! + const fence = stopped.lease.runtimeFence + const rechecked = conversationCommandBlocked( + { ...ctx, fence }, + stopped, + context.readChildWork(sessionId), + 'at-rest' + ) + if (rechecked) { + return { ok: false, refusal: rechecked } + } + const completed: AgentSessionConversationClear['command'] = { + command: 'clear', runtimeFence: fence, operationId: clientOperationId, callerKey: caller.callerKey, - // Only has to be new: the marker is what points at it, and a same-id resend replays it. - replacementSessionId: `clear-${randomBytes(20).toString('hex')}`, - phase: 'committed' as const, - state: 'completed' as const + phase: 'committed', + state: 'completed' } - await store.commitConversationClear({ - sessionId, - fence, - command: completed, - claimKeyId: context.deps.claimKeyId, - now: context.now() - }) - // Carry the source's drafts to the replacement, the same for every client version: - // the cards stay visible where the user now is, and no text rides the wire. - // Bookkeeping — a failure is reported and never fails the clear. - await carryQueuedMessagesToClearReplacement(ctx, { - replacementSessionId: completed.replacementSessionId, - // Opened under its own lock, as every open is. - openReplacementJournal: async () => - (await context.conversation(completed.replacementSessionId)).journal, - callerKey: caller.callerKey, - operationId: clientOperationId - }) + clear = { sessionId, fence, command: completed, now: context.now() } + if (!ctx.operationReceipt) { + throw new Error('agent_session_clear_receipt_missing') + } + await ctx.journal.context.clear( + providerContextBoundaryForClear(clear), + ctx.operationReceipt, + agentSessionOperationKey(caller.callerKey, clientOperationId) + ) return { ok: true, value: completed } } } }) + return result.ok && + 'runtimeFence' in result.value && + typeof result.value.runtimeFence === 'number' + ? { ...result, fence: result.value.runtimeFence } + : result }) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index c12f3d413692..cf43da7e5269 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -44,7 +44,8 @@ export async function runStructuredCompaction( context: StructuredAgentSessionMutationContext, host: Pick, caller: StructuredAgentSessionCaller, - params: ConversationCommandParams + params: ConversationCommandParams, + arrival: { clearInFlight?: boolean } = {} ): Promise> { const { sessionId, clientOperationId } = params.envelope // An older build ran this operation id and recorded it on the session: answered, never rerun. @@ -61,7 +62,13 @@ export async function runStructuredCompaction( ...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed')) } } - const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord) + const accepted = await acceptStructuredConversationCommand( + context, + caller, + params, + priorRecord, + arrival + ) if (!accepted.ok) { return accepted } @@ -131,7 +138,8 @@ function acceptStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: ConversationCommandParams, - priorRecord: () => AgentSessionConversationCommandResult | null + priorRecord: () => AgentSessionConversationCommandResult | null, + arrival: { clearInFlight?: boolean } ): Promise> { const plan = conversationCommandPlan({ envelope: params.envelope, @@ -189,6 +197,6 @@ function acceptStructuredConversationCommand( return accepted } }, - sendPreparation(context, params.envelope) + sendPreparation(context, params.envelope, arrival) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts deleted file mode 100644 index 3179e994fdb3..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { StructuredConversationCommandController } from './structured-conversation-command-controller' - -function replacements(records: AgentSessionRecord[], visible: string[]) { - const store = { - listRecords: () => records, - listVisibleSessionIds: () => visible, - getRecord: (id: string) => records.find((record) => record.sessionId === id) - } - const controller = new StructuredConversationCommandController( - () => ({ deps: { store } }) as never, - {} as never - ) - return controller.replacements() -} - -function record(id: string, next?: string): AgentSessionRecord { - return { - sessionId: id, - provider: 'codex', - location: { workspaceId: 'folder' }, - conversationCommand: next - ? { command: 'clear', phase: 'committed', replacementSessionId: next } - : undefined - } as AgentSessionRecord -} - -describe('conversation replacement projection', () => { - it('visits a long clear chain only once per snapshot', () => { - const reads = vi.fn() - const records = Array.from({ length: 200 }, (_, index) => { - const entry = record(String(index), index < 199 ? String(index + 1) : undefined) - const command = entry.conversationCommand - Object.defineProperty(entry, 'conversationCommand', { - get: () => { - reads() - return command - } - }) - return entry - }) - const result = replacements(records, ['199']) - expect(result).toHaveLength(199) - expect(result.every((entry) => entry.sessionId === '199')).toBe(true) - expect(reads.mock.calls.length).toBeLessThanOrEqual(records.length * 2) - }) - - it('keeps revealed history and closed chains out, and ignores cycles', () => { - const records = [ - record('a', 'b'), - record('b', 'c'), - record('c'), - record('x', 'y'), - record('y', 'x') - ] - expect(replacements(records, ['b', 'c', 'x'])).toEqual([ - { sourceSessionId: 'a', sessionId: 'c', workspaceId: 'folder', agent: 'codex' } - ]) - expect(replacements(records, [])).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index f20ed4879c61..38fecdd5f012 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -115,3 +115,16 @@ function withKnownTurnOutcome( const { outcome: _outcome, ...rest } = lifecycle return { ...body, turnLifecycle: rest } } + +export function renameRewindTurnOpener( + body: AgentJournalItemBody, + rename: (itemId: string) => string +): AgentJournalItemBody { + if (body.kind === 'turn' && body.userItemId !== undefined) { + return { ...body, userItemId: rename(body.userItemId) } + } + const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined + return body.kind === 'status' && lifecycle?.userItemId !== undefined + ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } + : body +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 6c9dfaf47823..b0320ffdcbcf 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -14,6 +14,8 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import { rewindRefusal } from './structured-rewind-refusal' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' type RewindRecoveryDeps = { store: AgentSessionRecordStore; logger: StructuredAgentSessionLogger } @@ -71,7 +73,12 @@ export async function recoverStructuredRewind( journal: AgentSessionJournal, fence: number, adapter?: StructuredAgentSessionAdapter, - now: () => number = Date.now + now: () => number = Date.now, + receipt?: ( + rewind: AgentSessionRewindRecord, + fence: number, + cursor: AgentJournalCursor + ) => JournalOperationReceipt ): Promise { const { store } = deps let rewind = store.getRecord(sessionId)?.rewind @@ -147,6 +154,26 @@ export async function recoverStructuredRewind( return } const replacement = rewind.retained.map(retainedRowReplacement) + if (rewind.contextClearOperationId && rewind.contextClearSequence) { + if ( + store.getRecord(sessionId)?.providerContextBoundary?.operationId !== + rewind.contextClearOperationId || + journal.cursor().epoch !== rewind.expectedEpoch || + journal.context.floor()?.sequence !== rewind.contextClearSequence + ) { + throw new Error('agent_session_rewind:stale-context') + } + const completed = rewind + await journal.context.rewind( + { epoch: completed.expectedEpoch, sequence: completed.contextClearSequence! }, + fence, + replacement, + (cursor) => + receipt?.(completed, fence, cursor) ?? + store.conversationReceipts.rewind(sessionId, fence, completed, cursor) + ) + return + } // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch if ( diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts index 38132a8d13b9..460e65239dad 100644 --- a/src/main/pi/rpc-launch-resolution.test.ts +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -117,6 +117,26 @@ describe('Pi host launch resolution', () => { }) }) + it('starts a fresh session after clear without reading the previous file', async () => { + const h = await setup() + const old = join(root, 'old-malformed.jsonl') + await writeFile(old, 'not a Pi session header') + vi.spyOn(h.store, 'getRecord').mockReturnValue({ + ...h.record, + providerHandleChain: [], + providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 } + }) + const launch = await h.resolver({ ...identity, providerHandle: h.prior(old).handle }) + expect(launch.previous).toBeNull() + expect(launch.sessionFile).toBeUndefined() + expect(launch.forkFile).toBeUndefined() + expect(launch.replacement).toBeUndefined() + expect(buildPiRpcLaunch(launch).args).toEqual(['--mode', 'rpc']) + expect(piRpcProviderLink(launch, join(root, 'fresh.jsonl'), 3, 'fresh', 101).origin).toBe( + 'created' + ) + }) + it('spawns the binary the Command setting names, and refuses one that is not runnable', async () => { const h = await setup() h.settings.agentCmdOverrides = { pi: `"${process.execPath}"` } diff --git a/src/main/runtime/agent-session-conversation-clear-commit.test.ts b/src/main/runtime/agent-session-conversation-clear-commit.test.ts index 3e7c63a41c34..8d7d24d97687 100644 --- a/src/main/runtime/agent-session-conversation-clear-commit.test.ts +++ b/src/main/runtime/agent-session-conversation-clear-commit.test.ts @@ -1,18 +1,22 @@ -// A /clear's one store write: the conversation it continues in, founded at rest, and the marker -// on the cleared record that points there. Either both land, on disk too, or neither does. - 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 type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + providerContextBoundaryForClear, + type AgentSessionConversationClear +} from './agent-session-conversation-command-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SOURCE = 'session-alpha' -const REPLACEMENT = `clear-${'a'.repeat(40)}` const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } let directory: string @@ -53,110 +57,157 @@ async function storeWithSource(): Promise { }, now: NOW }) + const fence = store.getRecord(SOURCE)!.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SOURCE, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + await store.proveOwner({ + sessionId: SOURCE, + fence, + link: { + linkId: 'pre-clear-proof', + origin: 'created', + mintedAtFence: fence, + observedAt: NOW, + handle: claudeProviderHandle('pre-clear-provider-context', null) + }, + now: NOW + }) await store.setSessionTabVisibility(SOURCE, true, 'tab-alpha') return store } -function marker(fence: number, replacementSessionId = REPLACEMENT) { +function clear(fence: number): AgentSessionConversationClear { return { - command: 'clear' as const, - state: 'completed' as const, - phase: 'committed' as const, - runtimeFence: fence, - operationId: `${NOW}-${'2'.repeat(32)}`, - callerKey: 'client-1', - replacementSessionId + sessionId: SOURCE, + fence, + now: NOW + 5, + command: { + command: 'clear', + state: 'completed', + phase: 'committed', + runtimeFence: fence, + operationId: `${NOW}-${'2'.repeat(32)}`, + callerKey: 'client-1' + } } } -describe("a /clear's commit", () => { - it('founds the replacement at rest and points the cleared record at it, on disk', async () => { - const store = await storeWithSource() - const fence = store.getRecord(SOURCE)!.lease.runtimeFence - await store.commitConversationClear({ +async function prepare(released = true) { + const store = await storeWithSource() + if (released) { + await store.evictProvenDeadOwner({ + sessionId: SOURCE, + expectedFence: store.getRecord(SOURCE)!.lease.runtimeFence, + probe: { outcome: 'exit-observed' }, + now: NOW + 1 + }) + } + const journal = new AgentSessionJournal({ + database: openTestJournalHostDatabase(directory), + identity: { sessionId: SOURCE, - fence, - command: marker(fence), - claimKeyId: 'key-1', - now: NOW + 5 + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: null + }, + now: () => NOW + 5 + }) + await journal.open() + const completed = clear(store.getRecord(SOURCE)!.lease.runtimeFence) + const operation = { + callerKey: completed.command.callerKey, + operationId: completed.command.operationId + } + await store.admitOperation({ ...operation, fingerprint: 'clear', now: NOW }) + const append = (input = completed) => + journal.context.clear( + providerContextBoundaryForClear(input), + store.conversationReceipts.clear(() => input, operation), + agentSessionOperationKey(operation.callerKey, operation.operationId) + ) + return { store, journal, completed, operation, append } +} + +async function queueClearCards(journal: AgentSessionJournal) { + for (const messageId of ['first', 'command', 'last']) { + await journal.queuedMessages.insert({ + messageId, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: messageId }], + ...(messageId === 'command' ? { command: { name: 'compact' } } : {}) + }, + fingerprint: messageId, + hostInstance: 'host-1' }) + } +} +describe('clear transaction', () => { + it('publishes one boundary and receipt while preserving the record and tab on disk', async () => { + const { store, journal, completed, operation, append } = await prepare() + const before = store.getRecord(SOURCE)! + expect(before.providerHandleChain).toHaveLength(1) + await queueClearCards(journal) + const epoch = journal.cursor().epoch + await append() const reopened = await open() - const source = reopened.getRecord(SOURCE)! - expect(source.conversationCommand).toEqual(marker(fence)) - expect(reopened.getRecord(REPLACEMENT)).toEqual({ - schemaVersion: source.schemaVersion, - sessionId: REPLACEMENT, - location: source.location, - provider: 'claude', - accountHome: source.accountHome, - options: { model: 'sonnet' }, - launchArgs: ['--flag'], + expect(reopened.listRecords()).toHaveLength(1) + expect(reopened.getRecord(SOURCE)).toMatchObject({ + providerContextBoundary: providerContextBoundaryForClear(completed), providerHandleChain: [], - createdAt: NOW + 5, - updatedAt: NOW + 5, - lease: { - sessionId: REPLACEMENT, - runtimeKind: 'native', - runtimeFence: 1, - handoffStage: null, - provenHandleLinkId: null, - ownerProcess: null, - reservedSpawnToken: null, - leaseDeadlineAt: NOW + 5, - lastRenewedAt: NOW + 5, - handoffOperationId: null, - journalCheckpoint: null, - claimKeyId: 'key-1', - claimStatus: 'released', - // Loading marks every lease for this host's adjudication, as it does any record's. - unreconciled: true, - deathEvidence: null - } + lease: { provenHandleLinkId: null }, + accountHome: before.accountHome, + options: before.options, + conversationCommand: completed.command + }) + expect(reopened.getSessionTabId(SOURCE)).toBe('tab-alpha') + expect(JSON.stringify(reopened.getRecord(SOURCE))).not.toContain('pre-clear-provider-context') + expect(journal.cursor().epoch).toBe(epoch) + expect(journal.queuedMessages.get('command')).toMatchObject({ + state: 'withdrawn', + settledByOp: agentSessionOperationKey(operation.callerKey, operation.operationId) }) - expect(reopened.getSessionTabId(REPLACEMENT)).toBe('tab-alpha') - expect(reopened.getSessionTabId(SOURCE)).toBeNull() + expect(journal.queuedMessages.pauses()).toMatchObject([{ messageIds: ['first', 'last'] }]) + expect( + reopened.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status + ).toBe('succeeded') }) - it('writes neither the replacement nor the marker when the commit is refused', async () => { - const store = await storeWithSource() - const fence = store.getRecord(SOURCE)!.lease.runtimeFence - // The source's lease moved after the clear read it: the marker's fenced write refuses. - const refused = await store - .commitConversationClear({ - sessionId: SOURCE, - fence: fence + 1, - command: marker(fence + 1), - claimKeyId: 'key-1', - now: NOW - }) - .catch((error: unknown) => error) + it('rolls the divider and receipt back when the fence moved', async () => { + const { store, journal, completed, operation, append } = await prepare() + await queueClearCards(journal) + const before = journal.cursor() + const previousChain = store.getRecord(SOURCE)!.providerHandleChain + const refused = await append({ ...completed, fence: completed.fence + 1 }).catch( + (error: unknown) => error + ) expect(isAgentSessionRefusalError(refused)).toBe(true) - - for (const each of [store, await open()]) { - expect(each.getRecord(REPLACEMENT)).toBeNull() - expect(each.getRecord(SOURCE)?.conversationCommand).toBeUndefined() - expect(each.getSessionTabId(SOURCE)).toBe('tab-alpha') - expect(each.listRecords().map((record) => record.sessionId)).toEqual([SOURCE]) - } + expect(journal.cursor()).toEqual(before) + expect( + journal.queuedMessages.list().map(({ messageId, state }) => ({ messageId, state })) + ).toEqual(['first', 'command', 'last'].map((messageId) => ({ messageId, state: 'waiting' }))) + expect(journal.queuedMessages.pauses()).toEqual([]) + expect((await open()).getRecord(SOURCE)?.providerContextBoundary).toBeUndefined() + expect((await open()).getRecord(SOURCE)?.providerHandleChain).toEqual(previousChain) + expect(store.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status).toBe( + 'pending' + ) }) - it('never overwrites a record already under the replacement id', async () => { - const store = await storeWithSource() - const fence = store.getRecord(SOURCE)!.lease.runtimeFence - const before = store.getRecord(SOURCE)! - const refused = await store - .commitConversationClear({ - sessionId: SOURCE, - fence, - command: marker(fence, SOURCE), - claimKeyId: 'key-1', - now: NOW - }) - .catch((error: unknown) => error) - expect(refused).toMatchObject({ - refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } } + it('refuses a boundary while ownership is still reserved', async () => { + const { store, journal, append } = await prepare(false) + const before = journal.cursor() + await expect(append()).rejects.toMatchObject({ + refusal: { code: 'agent_session_ownership_unknown' } }) - expect(store.getRecord(SOURCE)).toEqual(before) + expect(journal.cursor()).toEqual(before) + expect(store.getRecord(SOURCE)?.providerContextBoundary).toBeUndefined() }) }) diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 2cf5ee773767..c9f1ba52a26d 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,7 +1,7 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { createHash } from 'node:crypto' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' import type { AgentSessionStoreState } from './agent-session-store-state' -import { AgentSessionTabTable } from './agent-session-tab-table' -import { foundAgentSessionRecord } from './agent-session-record-founding' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' export function commitConversationCommandRecord( @@ -15,44 +15,47 @@ export function commitConversationCommandRecord( throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } state.records.set(sessionId, { ...record, conversationCommand: command }) - if ( - command.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId - ) { - if (!state.records.has(command.replacementSessionId)) { - throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) - } - state.sessionTabs ??= new AgentSessionTabTable() - state.sessionTabs.move(sessionId, command.replacementSessionId) - } } export type AgentSessionConversationClear = { sessionId: string fence: number - command: AgentSessionConversationCommandRecord & { replacementSessionId: string } - claimKeyId: string + command: AgentSessionConversationCommandRecord & { + command: 'clear' + phase: 'committed' + state: 'completed' + } now: number } -/** A /clear as one write: the conversation it continues in, founded from the source's identity, - * and the marker pointing there. Neither can land without the other. */ +export function providerContextBoundaryForClear(clear: AgentSessionConversationClear) { + return { + operationId: createHash('sha256') + .update(agentSessionOperationKey(clear.command.callerKey, clear.command.operationId)) + .digest('hex'), + afterFence: clear.fence, + clearedAt: clear.now + } +} + +/** The record owns the provider boundary; the journal transaction publishes its divider. */ export function commitConversationClearRecord( state: AgentSessionStoreState, clear: AgentSessionConversationClear ): void { const source = state.records.get(clear.sessionId) - const replacementId = clear.command.replacementSessionId - if (!source) { + if (!source || source.lease.runtimeFence !== clear.fence) { throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } - if (state.records.has(replacementId) || state.unreadableRecords.has(replacementId)) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + if (source.lease.ownerProcess !== null || source.lease.claimStatus !== 'released') { + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } - state.records.set( - replacementId, - foundAgentSessionRecord({ ...source, sessionId: replacementId }, clear) - ) - commitConversationCommandRecord(state, clear.sessionId, clear.fence, clear.command) + state.records.set(clear.sessionId, { + ...source, + providerHandleChain: [], + lease: { ...source.lease, provenHandleLinkId: null }, + conversationCommand: clear.command, + providerContextBoundary: providerContextBoundaryForClear(clear), + updatedAt: clear.now + }) } diff --git a/src/main/runtime/agent-session-conversation-receipts.ts b/src/main/runtime/agent-session-conversation-receipts.ts new file mode 100644 index 000000000000..6935f8bb0836 --- /dev/null +++ b/src/main/runtime/agent-session-conversation-receipts.ts @@ -0,0 +1,35 @@ +import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { + commitConversationClearRecord, + type AgentSessionConversationClear +} from './agent-session-conversation-command-record' +import { settleAgentSessionOperationInto } from './agent-session-operation-admission' +import { commitAgentSessionRewindCompletion } from './agent-session-rewind-completion' + +export function createAgentSessionConversationReceipts( + transactions: Pick +) { + return { + clear: ( + clear: () => AgentSessionConversationClear, + operation: { callerKey: string; operationId: string } + ) => + transactions.receipt((draft) => { + const completed = clear() + commitConversationClearRecord(draft, completed) + settleAgentSessionOperationInto(draft, { + ...operation, + outcome: { + status: 'succeeded', + sessionId: completed.sessionId, + conversationCommand: completed.command + } + }) + }), + rewind: ( + ...args: Parameters extends [unknown, ...infer A] + ? A + : never + ) => transactions.receipt((draft) => commitAgentSessionRewindCompletion(draft, ...args)) + } +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index e58827d2fd54..d88ba0c83202 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -6,6 +6,7 @@ import { import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' +import { activeProviderContext } from '../../shared/agent-session-provider-context' import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint @@ -54,6 +55,43 @@ describe('recordAgentSessionProviderHandle', () => { expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' }) expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) }) + + it('rejects delayed old-child proof and records fresh creation behind the clear boundary', () => { + const previous = agentSessionRecordFixture() + const record = { + ...previous, + providerHandleChain: [], + lease: { ...previous.lease, runtimeFence: 8 }, + providerContextBoundary: { operationId: 'clear', afterFence: 7, clearedAt: 4500 } + } + expect(() => + recordAgentSessionProviderHandle({ + record, + fence: 7, + link: resumedLink(7), + now: 5000 + }) + ).toThrow('agent_session_stale_fence') + expect(activeProviderContext(record).head).toBeNull() + const fresh = recordAgentSessionProviderHandle({ + record, + fence: 8, + now: 5000, + link: { + linkId: 'fresh', + origin: 'created', + mintedAtFence: 8, + observedAt: 5000, + handle: claudeProviderHandle('fresh-context', null) + } + }) + expect(fresh.providerHandleChain).toHaveLength(1) + expect(activeProviderContext(fresh).head).toMatchObject({ + linkId: 'fresh', + origin: 'created' + }) + expect(fresh.providerHandleChain[0]?.replaces).toBeUndefined() + }) }) describe('reviseAgentSessionProviderResumePoint', () => { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index c78a433415dc..1b4dd7bd9d60 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -2,11 +2,8 @@ * journal database. */ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { - commitConversationClearRecord, - commitConversationCommandRecord, - type AgentSessionConversationClear -} from './agent-session-conversation-command-record' +import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts' +import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory' import { agentSessionOperationKey, @@ -90,11 +87,14 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() private readonly firstRecordListeners = new Set<() => void>() + readonly conversationReceipts: ReturnType private constructor( private readonly transactions: AgentSessionStoreTransactions, readonly hostId: string - ) {} + ) { + this.conversationReceipts = createAgentSessionConversationReceipts(transactions) + } /** Reads every structurally valid row, independently of which agents this host can start. */ static open(args: { @@ -164,10 +164,6 @@ export class AgentSessionRecordStore { ) } - /** A committed /clear and the at-rest conversation it continues in, in one write. */ - commitConversationClear = (clear: AgentSessionConversationClear): Promise => - this.transact((draft) => commitConversationClearRecord(draft, clear)) - /** Unfenced on purpose: the name is a durable note, so writing it never contends with the * writer lease. `null` clears it. */ setConversationName = (sessionId: string, name: string | null): Promise => diff --git a/src/main/runtime/agent-session-rewind-completion.ts b/src/main/runtime/agent-session-rewind-completion.ts new file mode 100644 index 000000000000..1f027feddfa7 --- /dev/null +++ b/src/main/runtime/agent-session-rewind-completion.ts @@ -0,0 +1,36 @@ +import type { AgentJournalCursor } from '../../shared/agent-session-journal-types' +import type { AgentSessionRewindRecord } from '../../shared/agent-session-rewind' +import type { AgentSessionStoreState } from './agent-session-store-state' +import { settleAgentSessionOperationInto } from './agent-session-operation-admission' + +export function commitAgentSessionRewindCompletion( + draft: AgentSessionStoreState, + sessionId: string, + fence: number, + rewind: AgentSessionRewindRecord, + cursor: AgentJournalCursor +): void { + const record = draft.records.get(sessionId) + if ( + !record || + record.lease.runtimeFence !== fence || + record.providerContextBoundary?.operationId !== rewind.contextClearOperationId + ) { + throw new Error('agent_session_rewind:stale-context') + } + draft.records.set(sessionId, { + ...record, + rewind: { + ...rewind, + phase: 'completed', + epoch: cursor.epoch, + sequence: cursor.sequence, + retained: [] + } + }) + settleAgentSessionOperationInto(draft, { + callerKey: rewind.callerKey, + operationId: rewind.operationId, + outcome: { status: 'succeeded', sessionId, rewind: { itemId: rewind.itemId, ...cursor } } + }) +} diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 3f8965c62f0b..8dd5d46e2028 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -6,8 +6,7 @@ import type { AgentSessionStoreState } from './agent-session-store-state' * Which conversation each structured chat tab shows, keyed by the host tab id. * * Membership is visibility: a session with an entry has a chat tab, and the key is that tab's id. - * A /clear moves the tab to the replacement conversation rather than copying its id, so an id names - * one conversation by construction and a session maps back to at most one tab. + * An id names one conversation, and a session maps back to at most one tab. */ export class AgentSessionTabTable { private readonly sessionByTab = new Map() @@ -38,15 +37,14 @@ export class AgentSessionTabTable { /** * Gives a session a tab unless it already has one. Without a reserved id it gets the id clients - * derive for it, unless a cleared conversation's tab kept that id. + * derive for it. */ show(sessionId: string, tabId?: string): void { if (this.tabBySession.has(sessionId)) { return } const derived = structuredAgentSessionTabId(sessionId) - const held = (candidate: string): boolean => this.sessionByTab.has(candidate) - this.put(tabId ?? (held(derived) ? reopenedTabId(sessionId, held) : derived), sessionId) + this.put(tabId ?? derived, sessionId) } /** Returns the id the session's tab had, if it had one. */ @@ -59,19 +57,6 @@ export class AgentSessionTabTable { return tabId } - /** A /clear: the tab that showed `fromSessionId` shows `toSessionId`, keeping its id and place. */ - move(fromSessionId: string, toSessionId: string): void { - const tabId = this.tabBySession.get(fromSessionId) - this.hide(toSessionId) - if (tabId === undefined) { - this.show(toSessionId) - return - } - this.tabBySession.delete(fromSessionId) - this.sessionByTab.set(tabId, toSessionId) - this.tabBySession.set(toSessionId, tabId) - } - clone(): AgentSessionTabTable { return new AgentSessionTabTable(this.sessionByTab) } @@ -99,19 +84,6 @@ export class AgentSessionTabTable { } } -/** - * The id a cleared conversation reopened from history takes while the tab now showing its - * replacement holds its own. Deterministic, so a table seeded again gives it the same id. - */ -function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): string { - const base = `${structuredAgentSessionTabId(sessionId)}-reopened` - let tabId = base - for (let suffix = 2; held(tabId); suffix++) { - tabId = `${base}-${suffix}` - } - return tabId -} - /** The sessions whose chat tab is shown, in tab order, skipping any without a record. */ export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] { return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index e431658a078e..f4d63c020040 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -157,8 +157,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => { - const sessions = [SESSION] - claude = createScriptedClaudeRuntime(sessions) + claude = createScriptedClaudeRuntime([SESSION]) const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -202,31 +201,32 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) expect(cleared, JSON.stringify(cleared)).toMatchObject({ ok: true, - value: { replacementSessionId: expect.any(String) } + value: { command: 'clear', state: 'completed' } }) - const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' - sessions.push(replacement) - claude.behave(replacement, LIVE_START) + expect(cleared.ok && cleared.value.replacementSessionId).toBeUndefined() + const boundary = record(host)?.providerContextBoundary + expect(boundary).toBeDefined() + const freshProviderSessionId = claudeSessionIdForOrcaSession(SESSION, boundary?.operationId) + expect(freshProviderSessionId).not.toBe(claudeSessionIdForOrcaSession(SESSION)) + claude.behave(SESSION, LIVE_START) // The cleared chat starts nothing until its first message. - expect(claude.children(replacement)).toEqual([]) - await sendTo(host, replacement, 'first message') - // Started fresh under its own derived id, launched with the saved model rather than the one - // the turn reported. - await vi.waitFor(() => expect(claude.children(replacement)).toHaveLength(1)) - expect(claude.child(replacement).launch.options).toMatchObject({ - sessionId: claudeSessionIdForOrcaSession(replacement), + expect(claude.children(SESSION)).toHaveLength(1) + expect(claude.child(SESSION).connection.closed).toBe(true) + await send(host, 'first message') + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + expect(claude.child(SESSION).launch.options).toMatchObject({ + sessionId: freshProviderSessionId, model: 'sonnet', permissionMode: 'plan' }) - expect(claude.child(replacement).launch.options.resume).toBeUndefined() + expect(claude.child(SESSION).launch.options.resume).toBeUndefined() await vi.waitFor(() => - expect(host.deps.store.getRecord(replacement)?.options).toEqual({ + expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high', permissionMode: 'plan' }) ) - expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' }) }) it('replaces the launched saved model with the one the user then sets', async () => { diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index a1a4f820a486..7e8c67b2fffd 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -24,7 +24,6 @@ import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-bui import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import { structuredWorkerHandleAgentStatus } from './orchestration/structured-worker-group-addressing' import { retitleStructuredConversationTab, @@ -81,9 +80,6 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime protected toMobileSessionTabsResult( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsResult { - for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) { - snapshot = replaceConversationInSnapshot(snapshot, replacement) - } const host = getStructuredAgentSessionHost() snapshot = titleStructuredConversationTabs(snapshot, (sessionId) => { const record = host?.deps?.store?.getRecord(sessionId) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 7872e4f23da8..dc8354f08744 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -2,8 +2,6 @@ import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' -import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -30,27 +28,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { - /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit - * moved in the same write. */ - replaceStructuredAgentSessionTab(replacement: ConversationReplacement): void { - const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId) - const next = prior ? replaceConversationInSnapshot(prior, replacement) : null - if (next && next !== prior) { - const stored = this.storeMobileSessionSnapshot(replacement.workspaceId, next) - this.emitMobileSessionTabsSnapshot(stored) - } else if ( - !prior?.tabs.some( - (tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sessionId - ) - ) { - this.projectStructuredAgentSessionTab({ - ...replacement, - replacesSessionId: replacement.sourceSessionId, - activate: false - }) - } - } - protected async restoreStructuredAgentSessionTabsOnce(): Promise { await this.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() @@ -84,9 +61,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu restored.map((session) => session.sessionId) ) // Past the seed, projecting records nothing. - for (const replacement of host?.conversationReplacements?.() ?? []) { - this.replaceStructuredAgentSessionTab(replacement) - } for (const session of restored) { this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } @@ -106,7 +80,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu agent: StructuredAgentId activate: boolean notify?: boolean - replacesSessionId?: string /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string }): Promise { @@ -134,7 +107,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu agent: StructuredAgentId activate: boolean notify?: boolean - replacesSessionId?: string }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` @@ -172,7 +144,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu id, title: defaultAgentChatLabel(input.agent), sessionId: input.sessionId, - ...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}), agent: input.agent, isActive: input.activate } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index c872401f04ba..dbbfdcc97f06 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -1,8 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgent } from '../../shared/tui-agent-config' import type { RuntimeStore } from './runtime-store-contract' @@ -107,9 +105,6 @@ export class OrcaRuntimeWithRuntimeId { worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot { - for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) { - snapshot = replaceConversationInSnapshot(snapshot, replacement) - } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) snapshot = preserveTerminalRetirementProofs(snapshot, existing) const snapshotVersion = existing diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 9852c83d17bd..059487711750 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -7,8 +7,8 @@ afterEach(() => setStructuredAgentSessionHost(null)) describe('structured session cold restoration', () => { it('publishes a versioned title change and derives listAll titles from the record', async () => { const runtime = new OrcaRuntimeService() - runtime.replaceStructuredAgentSessionTab({ - sourceSessionId: 'source-session', + runtime.projectStructuredAgentSessionTab({ + activate: false, sessionId: 'session-1', workspaceId: 'workspace-1', agent: 'claude' @@ -408,30 +408,6 @@ describe('structured session cold restoration', () => { ) }) - // The /clear commit moved the tab in the store already, so replacing it only projects. - it('replaces a cleared conversation tab without a store write', async () => { - const runtime = new OrcaRuntimeService() - const setSessionTabVisibility = vi.fn(async () => undefined) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replacing a tab reaches the host only through setSessionTabVisibility, which must stay uncalled. - setStructuredAgentSessionHost({ setSessionTabVisibility } as never) - - runtime.replaceStructuredAgentSessionTab({ - sourceSessionId: 'cleared-session', - sessionId: 'replacement-session', - workspaceId: 'workspace-1', - agent: 'codex' - }) - - const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') - expect(snapshot.tabs).toEqual([ - expect.objectContaining({ - id: 'agent-session:replacement-session', - replacesSessionId: 'cleared-session' - }) - ]) - expect(setSessionTabVisibility).not.toHaveBeenCalled() - }) - it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index 627b841e09dc..96f41571c427 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -132,6 +132,7 @@ async function renderChatPointer(mailbox: string): Promise { host: { readSessionFacts: async () => ({ submissions: [] }), currentFence: () => 1, + currentContextClearOperationId: () => undefined, send: async (input) => { for (const block of input.body.blocks) { texts.push(block.type === 'text' ? block.text : '') diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index f8170c3dc301..76c2c964f611 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -54,6 +54,9 @@ function harness(options: { : { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) } ) const sendMock = vi.mocked(send) + const readContext = vi.fn( + () => undefined + ) const stored = new Map() const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), @@ -77,6 +80,7 @@ function harness(options: { host: { readSessionFacts: async () => (attached ? { submissions } : null), currentFence: () => 4, + currentContextClearOperationId: readContext, send } }) @@ -84,6 +88,7 @@ function harness(options: { delivery, markAsDelivered, send: sendMock, + readContext, stored, setAttached: (next: boolean) => { attached = next @@ -100,6 +105,35 @@ function harness(options: { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) describe('structured mailbox pointer delivery', () => { + it.each(['rejected', 'unknown', 'throws'] as const)( + 'attempts an unchanged record context once when its journal has no marker and send is %s', + async (outcome) => { + const { delivery, send, readContext, stored, markAsDelivered } = harness({}) + let contextReads = 0 + readContext.mockImplementation(() => { + if (++contextReads > 8) { + throw new Error('hard regression-test cap on context reads') + } + return 'record-clear-1' + }) + send.mockImplementation(async () => { + if (outcome === 'throws') { + throw new Error('send failed') + } + return { kind: 'sent', state: outcome } + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(contextReads).toBeLessThan(8) + expect(markAsDelivered).not.toHaveBeenCalled() + expect(stored.get('dispatch:d1')?.batch_fingerprint).toBe( + structuredPointerBatchFingerprint(IDENTITY.sessionId, ['m1'], 'record-clear-1') + ) + } + ) + it('claims only mailboxes whose assignee is a structured worker', () => { const { delivery } = harness({}) expect(delivery.deliverForHandle('dispatch:d1')).toBe(true) @@ -133,6 +167,20 @@ describe('structured mailbox pointer delivery', () => { expect(markAsDelivered).toHaveBeenCalledWith(['m1']) }) + it('releases its in-flight lane when reading the context fails', async () => { + const { delivery, send, readContext, markAsDelivered } = harness({}) + readContext.mockImplementationOnce(() => { + throw new Error('record unavailable') + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledOnce() + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + }) + it('retains mail when the dispatch settles unknown', async () => { const { delivery, markAsDelivered } = harness({ dispatchState: 'unknown' @@ -427,6 +475,7 @@ describe('forgetting one settled worker', () => { host: { readSessionFacts: async () => (attached ? { submissions: [] } : null), currentFence: () => 4, + currentContextClearOperationId: () => undefined, send } }) @@ -485,7 +534,8 @@ describe('forgetting one settled worker', () => { }) describe('a mailbox a /clear moves while its nudge is in flight', () => { - function clearingHarness() { + function clearingHarness(inPlace = false) { + let contextClearOperationId: string | undefined let target = 'session-1' const mail = [ { id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' } @@ -503,10 +553,11 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => { deleteStructuredPointerOperation: (key: string) => stored.delete(key) } let settleHeld: ((state: 'accepted' | 'rejected' | 'throws') => void) | null = null + let sendAttempts = 0 const send = vi.fn( - ({ sessionId }: { sessionId: string }) => + (_input: Parameters[0]) => new Promise<{ kind: 'sent'; state: 'accepted' | 'rejected' }>((resolve, reject) => { - if (sessionId === 'session-1') { + if (++sendAttempts === 1) { settleHeld = (state) => state === 'throws' ? reject(new Error('the operation-admission write failed')) @@ -525,6 +576,7 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => { host: { readSessionFacts: async () => ({ submissions: [] }), currentFence: () => 4, + currentContextClearOperationId: () => contextClearOperationId, send } }) @@ -533,7 +585,11 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => { send, pointed, clear: () => { - target = 'clear-successor' + if (inPlace) { + contextClearOperationId = 'clear-1' + } else { + target = 'clear-successor' + } }, settle: (state: 'accepted' | 'rejected' | 'throws') => settleHeld?.(state) } @@ -561,6 +617,25 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => { } ) + it.each(['rejected', 'throws'] as const)( + 'delivers once to the fresh context when the in-flight old send is %s', + async (outcome) => { + const { delivery, send, pointed, clear, settle } = clearingHarness(true) + delivery.deliverForHandle('dispatch:d1') + await flush() + clear() + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + settle(outcome) + await flush() + await flush() + expect(send.mock.calls.map(([input]) => input.sessionId)).toEqual(['session-1', 'session-1']) + expect(send.mock.calls[1]![0].operationId).not.toBe(send.mock.calls[0]![0].operationId) + expect(pointed.has('m1')).toBe(true) + } + ) + it('keeps an unchanged rejected nudge parked for the next edge', async () => { const { delivery, send, settle } = clearingHarness() delivery.deliverForHandle('dispatch:d1') diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index d82d93e5c9fd..7376e9d63366 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -56,7 +56,7 @@ export type StructuredPointerSendOutcome = | { kind: 'unattached' } export type StructuredPointerSessionFacts = { - /** Every send the session recorded, oldest first: what the lane's own sends settled as. */ + /** Current-context sends, oldest first: what the lane's own sends settled as. */ submissions: readonly StructuredPointerSubmission[] } @@ -73,6 +73,7 @@ export type StructuredMailboxPointerHost = { }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null + currentContextClearOperationId: (sessionId: string) => string | undefined } type StructuredPointerDeliveryDependencies = { @@ -165,7 +166,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, reservedTypes?: ReadonlySet, - attemptedSessions = new Set() + attemptedContexts = new Set() ): Promise { const db = this.deps.getDb() if (!db || this.inFlight.has(mailboxHandle)) { @@ -189,37 +190,63 @@ export class OrchestrationStructuredMailboxPointerDelivery< return } this.inFlight.add(mailboxHandle) + let contextKey: string | undefined try { - await this.attempt(db, mailboxHandle, target, unread, reservedTypes) + const session = await this.deps.host.readSessionFacts(target.sessionId) + const contextClearOperationId = this.deps.host.currentContextClearOperationId( + target.sessionId + ) + contextKey = JSON.stringify([target.sessionId, contextClearOperationId]) + if (attemptedContexts.has(contextKey)) { + return + } + attemptedContexts.add(contextKey) + await this.attempt( + db, + mailboxHandle, + target, + unread, + reservedTypes, + session, + contextClearOperationId + ) } finally { this.inFlight.delete(mailboxHandle) // A thrown attempt follows too; its own failure is what still propagates. - await this.followMovedTarget(mailboxHandle, target, reservedTypes, attemptedSessions).catch( - () => undefined - ) + if (contextKey !== undefined) { + await this.followChangedContext( + mailboxHandle, + target, + reservedTypes, + attemptedContexts + ).catch(() => undefined) + } } } - /** - * A `/clear` while the attempt was in flight moved the mailbox to a successor, whose idle edge - * found it in flight and was dropped; nothing else retries it. Only an actual move retries, so - * an unchanged rejected or unknown send keeps its suppression, and each session is tried once. - */ - private async followMovedTarget( + /** Clear's idle edge can land during a send; retry only a changed context, once per context. */ + private async followChangedContext( mailboxHandle: string, attempted: StructuredPointerTarget, reservedTypes: ReadonlySet | undefined, - attemptedSessions: Set + attemptedContexts: Set ): Promise { - attemptedSessions.add(attempted.sessionId) const current = this.deps.resolveStructuredTarget(mailboxHandle) - if (!current || attemptedSessions.has(current.sessionId)) { + if ( + !current || + attemptedContexts.has( + JSON.stringify([ + current.sessionId, + this.deps.host.currentContextClearOperationId(current.sessionId) + ]) + ) + ) { return } if (this.parkedUntilJournalEdge.get(mailboxHandle)?.sessionId === attempted.sessionId) { this.parkedUntilJournalEdge.delete(mailboxHandle) } - await this.deliver(mailboxHandle, current, reservedTypes, attemptedSessions) + await this.deliver(mailboxHandle, current, reservedTypes, attemptedContexts) } // A session whose agent is not running needs nothing first: an accepted send starts it. @@ -228,10 +255,11 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly MessageRow[], - reservedTypes: ReadonlySet | undefined + reservedTypes: ReadonlySet | undefined, + session: StructuredPointerSessionFacts | null, + contextClearOperationId: string | undefined ): Promise { const sessionId = target.sessionId - const session = await this.deps.host.readSessionFacts(sessionId) if (!session) { this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) return @@ -265,6 +293,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< sessionId, messageIds: staged, submissions: session.submissions, + contextClearOperationId, sentByThisProcess: this.sentOperationIds.get(mailboxHandle) }) if (operation.kind === 'stamp') { 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 dfef7cd05776..d3bf795a19c6 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' const hostRef: { current: unknown } = { current: null } @@ -35,6 +38,24 @@ function transcript(count: number): AgentJournalRenderItem[] { ) } +function pointerSubmission( + clientMessageId: string, + fence: number, + acceptedSequence: number +): AgentJournalSubmission { + return { + clientMessageId, + acceptedSequence, + submittedAt: 1_000, + fence, + payloadFingerprint: 'fp', + dispatchState: 'pending', + providerItemId: null, + reason: null, + resolvedAt: null + } +} + const NOTICE_SOURCE: AgentMessageSource = { kind: 'agent', senders: [], @@ -61,12 +82,61 @@ describe('structured mailbox pointer host', () => { it("reads what the session's sends settled as", async () => { const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] - hostRef.current = { journalSnapshot: () => ({ items: [], submissions }) } + hostRef.current = { + deps: { store: { getRecord: () => null } }, + journalSnapshot: () => ({ items: [], submissions }) + } expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toEqual({ submissions }) }) + it('scopes settlements to the record fence when the journal marker disagrees', async () => { + const items: AgentJournalRenderItem[] = [ + { + itemId: 'clear-marker', + revision: 1, + sequence: 5, + observedAt: 1_000, + body: { + kind: 'status', + text: 'Context cleared', + contextClear: { operationId: 'clear-old', afterFence: 1, clearedAt: 1_000 } + } + } + ] + const current = pointerSubmission('current', 3, 4) + hostRef.current = { + deps: { + store: { + getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } }) + } + }, + journalSnapshot: () => ({ items, submissions: [pointerSubmission('earlier', 2, 6), current] }) + } + const host = createStructuredMailboxPointerHost() + expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] }) + expect(host.currentContextClearOperationId('s1')).toBe('clear-1') + }) + + it('keeps the record context when an older rewind removed its journal marker', async () => { + const current = pointerSubmission('current', 3, 1) + hostRef.current = { + deps: { + store: { + getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } }) + } + }, + journalSnapshot: () => ({ + items: [], + submissions: [pointerSubmission('earlier', 2, 2), current] + }) + } + const host = createStructuredMailboxPointerHost() + expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] }) + expect(host.currentContextClearOperationId('s1')).toBe('clear-1') + }) + it('answers null rather than nothing recorded when the session cannot be read', async () => { // Null retains the pointer; an empty answer would send into a session this runtime cannot see. expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull() diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 94b54389a559..e51734b5b85e 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -55,7 +55,16 @@ async function readPointerSessionFacts( sessionId: string ): Promise { const snapshot = await readSessionJournal(sessionId) - return snapshot ? { submissions: snapshot.submissions } : null + if (!snapshot) { + return null + } + const boundary = + getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.providerContextBoundary + return { + submissions: boundary + ? snapshot.submissions.filter((submission) => submission.fence > boundary.afterFence) + : snapshot.submissions + } } async function readSessionJournal(sessionId: string): Promise { @@ -87,6 +96,11 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo ) }, + currentContextClearOperationId(sessionId) { + return getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) + ?.providerContextBoundary?.operationId + }, + async send(input) { const host = getStructuredAgentSessionHost() if (!host) { diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts index f828409aeffe..3790d366a59f 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts @@ -67,6 +67,32 @@ describe('structured pointer operation id', () => { expect(second.operationId).toBe(first.operationId) }) + it('re-mints an unresolved batch once for a fresh context in the same conversation', () => { + const db = fakeDb() + const input = { + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + messageIds: ['m1'], + now: 1_000 + } + const before = resolveId(input) + const cleared = { + ...input, + contextClearOperationId: 'clear-1', + submissions: [ + { + clientMessageId: before.operationId, + dispatchState: 'pending' as const, + submittedAt: 1_000 + } + ] + } + const fresh = resolveId(cleared) + expect(fresh.operationId).not.toBe(before.operationId) + expect(resolveId(cleared).operationId).toBe(fresh.operationId) + }) + it('re-mints when the batch grows', () => { const db = fakeDb() const first = resolveId({ diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.ts index 26dc951edcbf..c5f524161063 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.ts @@ -82,10 +82,17 @@ export function mintAgentSessionOperationId(now: number): string { /** Batch identity, and the only thing reuse may be keyed on. */ export function structuredPointerBatchFingerprint( sessionId: string, - messageIds: readonly string[] + messageIds: readonly string[], + contextClearOperationId?: string ): string { return createHash('sha256') - .update(JSON.stringify([sessionId, messageIds])) + .update( + JSON.stringify( + contextClearOperationId + ? [sessionId, messageIds, contextClearOperationId] + : [sessionId, messageIds] + ) + ) .digest('base64url') } @@ -100,13 +107,18 @@ export function resolveStructuredPointerOperation(args: { sessionId: string /** The rows this nudge stands for; batch identity, not the body, decides reuse. */ messageIds: readonly string[] + contextClearOperationId?: string submissions: readonly StructuredPointerSubmission[] /** The operation id this process last sent for this mailbox, if any. */ sentByThisProcess: string | undefined now?: number }): StructuredPointerOperation { const now = args.now ?? Date.now() - const batchFingerprint = structuredPointerBatchFingerprint(args.sessionId, args.messageIds) + const batchFingerprint = structuredPointerBatchFingerprint( + args.sessionId, + args.messageIds, + args.contextClearOperationId + ) const stored = args.db.getStructuredPointerOperation(args.mailboxHandle) const attempt = decideStructuredPointerAttempt({ row: stored, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 17dbdd49a0ae..4d22e9ff2a26 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -118,17 +118,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ await ensureHostInstalled(ctx) const host = requireSessionHost(ctx, params.envelope.sessionId) await host.revealSession(params.envelope.sessionId) - const result = await host.conversationCommand(callerFor(ctx), { ...params, userSend: true }) - if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) { - const replacement = host - .conversationReplacements() - .find((entry) => entry.sourceSessionId === params.envelope.sessionId) - if (replacement) { - ctx.runtime.replaceStructuredAgentSessionTab(replacement) - } - await host.close(params.envelope.sessionId, 'user-close') - } - return result + return host.conversationCommand(callerFor(ctx), { ...params, userSend: true }) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts new file mode 100644 index 000000000000..8e7bdf86c223 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts @@ -0,0 +1,206 @@ +import '../unused-default-rpc-methods.test-fixture' +/** + * A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk, + * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { SESSION_TAB_METHODS } from './session-tabs' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' + +export const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` +export const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` +export const caller = { callerKey: 'trusted-local:runtime' } + +export let directory: string +export let store: AgentSessionRecordStore +export let host: StructuredAgentSessionHost +export let runtime: OrcaRuntimeService +export let dispatcher: RpcDispatcher +export let acquisitions = 0 +let acquireFails = false + +export function setAcquireFailure(fails: boolean): void { + acquireFails = fails +} +export let closeSession: ReturnType Promise>> + +function providerAdapter(): StructuredAgentSessionAdapter { + return { + supportsLocation: (location) => + location.executionHostId === 'local' && location.wslDistro === null, + acquire: vi.fn(async (input) => { + if (acquireFails) { + throw new Error('provider failed to start') + } + acquisitions++ + return { + process: { + hostId: 'local', + pid: 4000 + acquisitions, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: `link-${acquisitions}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: 'created' as const, + handle: codexProviderHandle( + `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + ) + } + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: async () => {}, + setOption: async () => {}, + releaseAcquisition: async () => true, + closeSession, + readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) + } +} + +export async function openHost(): Promise { + store = await openTestAgentSessionRecordStore(directory) + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: providerAdapter(), + journalDatabase: openTestJournalHostDatabase(directory), + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + mintSpawnToken: () => `spawn-${acquisitions}` + }) + setStructuredAgentSessionHost(host) +} + +type CallResponse = { + ok: boolean + result?: { ok?: boolean; value?: { replacementSessionId?: string } } +} + +export async function call( + method: string, + params: unknown, + context: RpcDispatchStreamingOptions = {} +): Promise { + const response = await dispatcher.dispatch( + { id: 'request', authToken: 'token', method, params }, + context + ) + return JSON.parse(JSON.stringify(response)) +} + +export async function createChat(sessionId: string, tabId?: string) { + return commitStructuredAgentSessionCreate({ + runtime, + caller, + activate: true, + prepared: { + host, + attachParams: hostTestAttachParams(null, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + ...(tabId ? { surfaceTabId: tabId } : {}) + }), + tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' } + } + }) +} + +export function envelopeFor(method: string, sessionId: string, fields: Record) { + return { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) + } +} + +export async function clear( + sessionId: string, + context: RpcDispatchStreamingOptions = {} +): Promise { + const response = await call( + 'agentSession.conversationCommand', + { + command: 'clear', + envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' }) + }, + context + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + const replacement = response.result?.value?.replacementSessionId + expect(replacement).toBeUndefined() + return sessionId +} + +export async function send(sessionId: string, text: string) { + const body = hostTestMessage(text) + return call('agentSession.send', { + body, + envelope: envelopeFor('agentSession.send', sessionId, { body }) + }) +} + +export async function snapshot() { + return runtime.listMobileSessionTabs(WORKTREE) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquisitions = 0 + acquireFails = false + closeSession = vi.fn(async () => true) + directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-')) + runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths. + { experimentalNativeChat: true } as ReturnType + ) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS] + }) + await openHost() +}) + +afterEach(async () => { + vi.restoreAllMocks() + await host?.flushAllStreamedEvents() + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) +}) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 97feae48ff06..26308b2028cb 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -1,425 +1,228 @@ -import '../unused-default-rpc-methods.test-fixture' -/** - * A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk, - * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. - */ - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' -import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { describe, expect, it, vi } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { - HOST_TEST_LOCATION, - HOST_TEST_NOW, HOST_TEST_SESSION, hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds + hostTestOperationId } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { - openTestAgentSessionRecordStore, - seedTestAgentSessionStoreFromNewerBuild -} from '../../agent-session-record-store-test-harness' -import { OrcaRuntimeService } from '../../orca-runtime' -import { RpcDispatcher } from '../dispatcher' -import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { seedTestAgentSessionStoreFromNewerBuild } from '../../agent-session-record-store-test-harness' +import { OrcaRuntimeRpcServer } from '../../runtime-rpc' +import { DeviceRegistry } from '../../device-registry' +import WebSocket from 'ws' import { SESSION_TAB_METHODS } from './session-tabs' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' - -const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` -const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` -const caller = { callerKey: 'trusted-local:runtime' } - -let directory: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let runtime: OrcaRuntimeService -let dispatcher: RpcDispatcher -let acquisitions = 0 -let acquireFails = false -let closeSession: ReturnType Promise>> - -function providerAdapter(): StructuredAgentSessionAdapter { - return { - supportsLocation: (location) => - location.executionHostId === 'local' && location.wslDistro === null, - acquire: vi.fn(async (input) => { - if (acquireFails) { - throw new Error('provider failed to start') - } - acquisitions++ - return { - process: { - hostId: 'local', - pid: 4000 + acquisitions, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `link-${acquisitions}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: 'created' as const, - handle: codexProviderHandle( - `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` - ) - } - } - }), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: async () => {}, - setOption: async () => {}, - releaseAcquisition: async () => true, - closeSession, - readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) - } -} - -async function openHost(): Promise { - store = await openTestAgentSessionRecordStore(directory) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: providerAdapter(), - journalDatabase: openTestJournalHostDatabase(directory), - claimKeyId: 'key', - now: () => HOST_TEST_NOW, - mintSpawnToken: () => `spawn-${acquisitions}` - }) - setStructuredAgentSessionHost(host) -} - -type CallResponse = { - ok: boolean - result?: { ok?: boolean; value?: { replacementSessionId?: string } } -} - -async function call( - method: string, - params: unknown, - context: RpcDispatchStreamingOptions = {} -): Promise { - const response = await dispatcher.dispatch( - { id: 'request', authToken: 'token', method, params }, - context - ) - return JSON.parse(JSON.stringify(response)) -} - -async function createChat(sessionId: string, tabId?: string) { - return commitStructuredAgentSessionCreate({ - runtime, - caller, - activate: true, - prepared: { - host, - attachParams: hostTestAttachParams(null, { - envelope: { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - ...(tabId ? { surfaceTabId: tabId } : {}) - }), - tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' } - } - }) -} - -function envelopeFor(method: string, sessionId: string, fields: Record) { - return { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) - } -} - -async function clear(sessionId: string): Promise { - const response = await call('agentSession.conversationCommand', { - command: 'clear', - envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' }) - }) - expect(response).toMatchObject({ ok: true, result: { ok: true } }) - const replacement = response.result?.value?.replacementSessionId - expect(replacement).toBeDefined() - return replacement! -} - -async function send(sessionId: string, text: string) { - const body = hostTestMessage(text) - return call('agentSession.send', { - body, - envelope: envelopeFor('agentSession.send', sessionId, { body }) - }) -} - -async function snapshot() { - return runtime.listMobileSessionTabs(WORKTREE) -} - -beforeEach(async () => { - resetHostTestOperationIds() - acquisitions = 0 - acquireFails = false - closeSession = vi.fn(async () => true) - directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-')) - runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'getClientSettings').mockReturnValue( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths. - { experimentalNativeChat: true } as ReturnType - ) - dispatcher = new RpcDispatcher({ - runtime, - methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS] - }) - await openHost() -}) - -afterEach(async () => { - vi.restoreAllMocks() - await host?.flushAllStreamedEvents() - setStructuredAgentSessionHost(null) - await rm(directory, { recursive: true, force: true }) -}) +import { + WORKTREE, + SOURCE_TAB, + caller, + directory, + store, + host, + runtime, + dispatcher, + acquisitions, + closeSession, + openHost, + call, + createChat, + envelopeFor, + clear, + send, + snapshot, + setAcquireFailure +} from './structured-chat-tab-table.test-fixture' describe('a chat tab across /clear', () => { - it('keeps sending through a second and third /clear, the tab following each replacement', async () => { + it('keeps one conversation and published tab through repeated clears and a new send', async () => { expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) - let current = HOST_TEST_SESSION - // A pending send blocks /clear by design, so the clears run back to back and the chat sends after. + const before = await snapshot() for (let round = 0; round < 3; round++) { - const replacement = await clear(current) - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - expect(store.getSessionTabId(current)).toBeNull() - current = replacement + expect(await clear(HOST_TEST_SESSION)).toBe(HOST_TEST_SESSION) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + const published = await snapshot() + expect(published.tabs.map((tab) => tab.id)).toEqual(before.tabs.map((tab) => tab.id)) + expect(published.tabGroups).toEqual(before.tabGroups) + expect(published.activeTabId).toBe(before.activeTabId) } - expect(await send(current, 'after three clears')).toMatchObject({ + expect(await send(HOST_TEST_SESSION, 'after three clears')).toMatchObject({ ok: true, result: { ok: true } }) - const tabs = (await snapshot()).tabs - expect(tabs).toHaveLength(1) - expect(tabs[0]).toMatchObject({ type: 'agent-session', sessionId: current }) - expect(store.listVisibleSessionIds()).toEqual([current]) + expect((await snapshot()).tabs).toHaveLength(1) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + expect(store.listRecords()).toHaveLength(1) }) - // Each press carries its own operation id, so a later /clear is a new call the host answers. - it('answers a /clear pressed again after it committed with that clear, starting nothing', async () => { + it('records another clear while idle without starting or renaming the chat', async () => { await createChat(HOST_TEST_SESSION) - const replacement = await clear(HOST_TEST_SESSION) + await clear(HOST_TEST_SESSION) + const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary const started = acquisitions - expect(await clear(HOST_TEST_SESSION)).toBe(replacement) + await clear(HOST_TEST_SESSION) + expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first) expect(acquisitions).toBe(started) - expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`]) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${HOST_TEST_SESSION}` + ]) }) - it('answers a retyped /clear whose answer was lost with that clear, the tab on its replacement', async () => { + it('replays a lost answer with the same tab and one divider', async () => { await createChat(HOST_TEST_SESSION) - const persist = store.recordOperationOutcome.bind(store) - let lost = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (!lost && input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { - lost = true - throw new Error('connection lost') - } - return persist(input) + const journal = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal + const original = journal.context.clear.bind(journal.context) + const lost = vi.spyOn(journal.context, 'clear').mockImplementationOnce(async (...args) => { + await original(...args) + throw new Error('lost acknowledgement') }) - const lostAnswer = await call('agentSession.conversationCommand', { + const params = { command: 'clear', envelope: envelopeFor('agentSession.conversationCommand', HOST_TEST_SESSION, { command: 'clear' }) - }) - expect(lostAnswer.result?.ok).not.toBe(true) - const started = acquisitions - const replacement = await clear(HOST_TEST_SESSION) - expect(acquisitions).toBe(started) - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`]) - expect(await send(replacement, 'after the retry')).toMatchObject({ + } + expect((await call('agentSession.conversationCommand', params)).result?.ok).not.toBe(true) + lost.mockRestore() + const landed = journal.snapshot() + expect(await call('agentSession.conversationCommand', params)).toMatchObject({ ok: true, result: { ok: true } }) + expect(journal.snapshot()).toEqual(landed) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${HOST_TEST_SESSION}` + ]) }) - it('reveals a cleared conversation in its own tab without activating the current chat', async () => { + it('reveals the same tab from history instead of opening another conversation', async () => { await createChat(HOST_TEST_SESSION) - const replacement = await clear(HOST_TEST_SESSION) - + await clear(HOST_TEST_SESSION) expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ ok: true }) - - const revealedTabId = store.getSessionTabId(HOST_TEST_SESSION) - expect(revealedTabId).not.toBeNull() - expect(revealedTabId).not.toBe(SOURCE_TAB) - expect(revealedTabId).not.toContain(':') - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - const published = await snapshot() - expect(published.tabs.map((tab) => tab.id)).toEqual([ - `agent-session:${replacement}`, + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([ `agent-session:${HOST_TEST_SESSION}` ]) - expect(published.activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) - }) - - it('closes the cleared conversation and leaves the current chat and its tab', async () => { - await createChat(HOST_TEST_SESSION) - const replacement = await clear(HOST_TEST_SESSION) - await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) - - expect( - await call('session.tabs.close', { - worktree: WORKTREE, - tabId: `agent-session:${HOST_TEST_SESSION}`, - reason: 'user' - }) - ).toMatchObject({ ok: true }) - - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`]) - expect(await send(replacement, 'still here')).toMatchObject({ ok: true, result: { ok: true } }) }) - // A worktree delete closes every chat in it; one that never started must not ask to be forced. - it('closes a replacement that never started as settled', async () => { + it('closes the same cleared chat as settled before its next provider start', async () => { await createChat(HOST_TEST_SESSION) - const replacement = await clear(HOST_TEST_SESSION) - expect(await closeStructuredAgentSessionChild(replacement)).toEqual({ + await clear(HOST_TEST_SESSION) + expect(await closeStructuredAgentSessionChild(HOST_TEST_SESSION)).toEqual({ stopped: true, closeAttempted: true }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() }) - it('puts a cleared chat back under the tab id it had when its close does not land', async () => { + it('keeps the original tab when closing the new context is unverifiable', async () => { await createChat(HOST_TEST_SESSION) - const replacement = await clear(HOST_TEST_SESSION) - // Its agent starts on its first message; only a running agent has a close that can fail. - expect(await send(replacement, 'first message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(store.getRecord(replacement)?.lease.claimStatus).toBe('live')) - closeSession.mockResolvedValue(false) - - const outcome = await closeStructuredAgentSessionChild(replacement) - expect(outcome).toMatchObject({ stopped: false }) - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - closeSession.mockResolvedValue(true) + await clear(HOST_TEST_SESSION) + expect(await send(HOST_TEST_SESSION, 'new context')).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('live') + ) + closeSession.mockResolvedValueOnce(false) + expect(await closeStructuredAgentSessionChild(HOST_TEST_SESSION)).toMatchObject({ + stopped: false + }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) }) - it('keeps the tab id and its pointer across a restart', async () => { + it('keeps the same tab and conversation across restart', async () => { await createChat(HOST_TEST_SESSION) - const replacement = await clear(await clear(HOST_TEST_SESSION)) + await clear(HOST_TEST_SESSION) await host.flushAllStreamedEvents() - await openHost() - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) expect(host.getPersistedVisibleSessionTabIndex()).toEqual({ present: true, - sessionIds: [replacement] + sessionIds: [HOST_TEST_SESSION] }) }) }) -describe('other clients after a /clear from this window', () => { - const STRUCTURED = [ +describe('other clients after a clear', () => { + const capabilities = [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] - const clients = { - window: { clientKind: 'runtime' as const, clientCapabilities: STRUCTURED }, - phone: { clientKind: 'mobile' as const, clientCapabilities: STRUCTURED } - } - - /** The chat tabs a client lists, as that client is sent them. */ - async function listedChats(context: RpcDispatchStreamingOptions) { - const response = await dispatcher.dispatch( + it('clears through the authenticated mobile WebSocket allowlist without replacing the tab', async () => { + await createChat(HOST_TEST_SESSION) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: directory, + enableWebSocket: false, + methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS] + }) + server['deviceRegistry'] = new DeviceRegistry(directory) + const phone = server['deviceRegistry'].addDevice('phone', 'mobile') + const socket = WebSocket.prototype + const replies: unknown[] = [] + const before = await snapshot() + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'mobile-clear', + method: 'agentSession.conversationCommand', + deviceToken: phone.token, + params: { + command: 'clear', + envelope: envelopeFor('agentSession.conversationCommand', HOST_TEST_SESSION, { + command: 'clear' + }) + } + }), + (response) => replies.push(JSON.parse(response)), + () => undefined, + undefined, + undefined, + phone.token, { - id: 'list', - authToken: 'token', - method: 'session.tabs.list', - params: { worktree: WORKTREE } - }, - context + ws: socket, + connectionId: 'phone-connection', + device: { deviceId: phone.deviceId, deviceToken: phone.token, scope: 'mobile' }, + clientCapabilities: capabilities, + transport: { transport: 'direct' } + } ) - const listed: { result?: { tabs?: { type: string }[] } } = JSON.parse(JSON.stringify(response)) - return (listed.result?.tabs ?? []).filter((tab) => tab.type === 'agent-session') - } + expect(replies).toMatchObject([ + { ok: true, result: { ok: true, value: { command: 'clear', state: 'completed' } } } + ]) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual(before.tabs.map((tab) => tab.id)) + expect(store.getRecord(HOST_TEST_SESSION)?.providerContextBoundary).toBeDefined() + }) - /** The session list each client keeps: the latest status the host published per session. */ - function sessionList(id: string): Map { - const latest = new Map() - host.subscribeStatus({ - id, - emit: (event) => { - if (event.type === 'snapshot') { - event.sessions.forEach((session) => latest.set(session.sessionId, session)) - } else if (event.type === 'status') { - latest.set(event.session.sessionId, event.session) + it.each(['runtime', 'mobile'] as const)( + 'keeps the published identity for a %s caller and a reconnecting reader', + async (clientKind) => { + await createChat(HOST_TEST_SESSION) + const before = await snapshot() + await clear(HOST_TEST_SESSION, { clientKind, clientCapabilities: capabilities }) + const listed = await dispatcher.dispatch( + { + id: 'reconnected', + authToken: 'token', + method: 'session.tabs.list', + params: { worktree: WORKTREE } + }, + { clientKind, clientCapabilities: capabilities } + ) + expect(listed).toMatchObject({ + ok: true, + result: { + activeTabId: before.activeTabId, + tabs: [{ id: `agent-session:${HOST_TEST_SESSION}`, sessionId: HOST_TEST_SESSION }] } - } - }) - return latest - } - - it('shows another window and a phone the replacement the marker names, with nothing unread', async () => { - await createChat(HOST_TEST_SESSION) - const lists = { window: sessionList('window-b'), phone: sessionList('phone') } - const replacement = await clear(HOST_TEST_SESSION) - // The stored pointer is the only source of the id: nothing a client holds can derive it. - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - replacementSessionId: replacement - }) - expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - - for (const context of Object.values(clients)) { - expect(await listedChats(context)).toEqual([ - expect.objectContaining({ sessionId: replacement, replacesSessionId: HOST_TEST_SESSION }) - ]) - } - // Never started, so no client lists a turn for it to be unread. - for (const list of Object.values(lists)) { - expect(list.get(replacement)?.status ?? null).toBeNull() - expect(list.get(replacement)?.turnOutcome).toBeUndefined() - } - - expect(await send(replacement, 'first message')).toMatchObject({ - ok: true, - result: { ok: true } - }) - await vi.waitFor(() => { - for (const list of Object.values(lists)) { - expect(list.get(replacement)).toMatchObject({ sessionId: replacement, status: 'working' }) - } - }) - for (const context of Object.values(clients)) { - expect(await listedChats(context)).toEqual([ - expect.objectContaining({ sessionId: replacement }) - ]) + }) + const current = await snapshot() + expect(current.tabGroups).toEqual(before.tabGroups) + expect(current.tabs[0]).not.toHaveProperty('replacesSessionId') } - }) + ) }) describe('session tab mutations from other clients, unchanged by the table', () => { @@ -512,12 +315,12 @@ describe('a create that reserves its tab', () => { }) it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => { - acquireFails = true + setAcquireFailure(true) expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false }) expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() expect(store.listVisibleSessionIds()).toEqual([]) - acquireFails = false + setAcquireFailure(false) expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ ok: true, value: { tabId: 'reserved-tab' } diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 0b3b8d9b307e..d29159ec1116 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -272,7 +272,11 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_A}`, `agent-session:${CHAT_B}` ]) - expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + expect( + published().every( + (tab) => tab.type !== 'agent-session' || tab.replacesSessionId === undefined + ) + ).toBe(true) // A newer Orca's leases are adjudicated in memory, so nothing fails there; its chats do not open. if (newer) { for (const sessionId of [CHAT_A, CHAT_B]) { diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6a9ac88504d7..6e9faebd1cae 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -14,7 +14,7 @@ import type { AgentJournalRenderItem } from '../../shared/agent-session-journal- import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { activeProviderContext } from '../../shared/agent-session-provider-context' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' @@ -92,10 +92,9 @@ export async function openChat(sessionId: string): Promise { export const threadBySession = new Map() export function connectionFor(sessionId: string): FakeConnection { - // A cleared chat's successor starts on its first message; its record then names its thread. - const head = agentSessionProviderHandleChainHead( - host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] - ) + // A cleared context starts on its first message; its record then names its new thread. + const record = host.deps.store.getRecord(sessionId) + const head = record ? activeProviderContext(record).head : null const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) if (!connection) { @@ -206,7 +205,7 @@ export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: return { runId, taskId: idOf(task.task) } } -/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ +/** `/clear` as the chat surface runs it: the same conversation gets a fresh context. */ export async function clearChat(sessionId: string): Promise { const command = 'clear' as const const cleared = await host.conversationCommand( @@ -225,21 +224,21 @@ export async function clearChat(sessionId: string): Promise { } } ) - const successor = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!successor) { + expect(cleared, JSON.stringify(cleared)).toMatchObject({ ok: true }) + if (!cleared.ok) { throw new Error(`clear failed: ${JSON.stringify(cleared)}`) } - // The surface swaps the tab over to the session that continues the chat. - await host.setSessionTabVisibility(sessionId, false) - await host.setSessionTabVisibility(successor, true) - return successor + expect(cleared.value.replacementSessionId).toBeUndefined() + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(sessionId) + threadBySession.delete(sessionId) + return sessionId } -/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */ -export async function startSuccessor(successor: string): Promise { - expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) - await settleTurn(successor, 0) +/** A cleared context starts when the user writes to the same conversation. */ +export async function startClearedContext(sessionId: string): Promise { + expect(await sendUserMessage(sessionId, 'hello')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(connectionFor(sessionId).turns).toHaveLength(1), WAIT) + await settleTurn(sessionId, 0) } beforeEach(async () => { diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 5eb0f57209cb..132154d98c72 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -38,7 +38,7 @@ import { finishWorker, coordinatorRunAndTask, clearChat, - startSuccessor, + startClearedContext, WAIT, POINTER, ptyPointer, @@ -489,37 +489,38 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) describe('a /clear keeps the chat its orchestration address', () => { - it("delivers the conversation's Run to the session that continues it, and acts as it", async () => { + it("delivers the conversation's Run to its fresh context under the same address", async () => { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const generation = db.getRunRaw(runId)!.consumer_generation - const successor = await clearChat(COORDINATOR) + const cleared = await clearChat(COORDINATOR) + expect(cleared).toBe(COORDINATOR) const opened = codex.connections.length - // The worker's result is the successor's first message, which starts it on a new thread. + // The worker's result starts the fresh context on a new thread. await finishWorker(taskId) - await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) - const next = connectionFor(successor) + await vi.waitFor(() => expect(connectionFor(cleared).turns).toHaveLength(1), WAIT) + const next = connectionFor(cleared) expect(codex.connections.slice(opened)).toEqual([next]) expect(next.methods.filter((method) => method.startsWith('thread/'))).toEqual(['thread/start']) expect(next.turns[0]!.text).toMatch(POINTER) - await settleTurn(successor, 0) + await settleTurn(cleared, 0) await expect( - call('orchestration.runCurrent', {}, { sessionId: successor }) + call('orchestration.runCurrent', {}, { sessionId: cleared }) ).resolves.toMatchObject({ run: { id: runId } }) - await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + await expect(call('orchestration.check', {}, { sessionId: cleared })).resolves.toMatchObject({ runId, count: 1, messages: [{ type: 'worker_done' }] }) - // Nothing was rewritten: the Run is bound exactly as the first session bound it. + // Clear preserves the original Run binding and consumer generation. expect(db.getRunRaw(runId)).toMatchObject({ coordinator_orca_session_id: COORDINATOR, consumer_generation: generation }) }) - it('points mail the cleared chat never took at the successor, with no message from the user', async () => { + it('points mail the earlier context never took at the fresh context, without a user message', async () => { const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await finishWorker(taskId) @@ -531,31 +532,32 @@ describe('a /clear keeps the chat its orchestration address', () => { ) expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) - const successor = await clearChat(COORDINATOR) - await vi.waitFor(() => expect(connectionFor(successor).turns[0]?.text).toMatch(POINTER), WAIT) - await settleTurn(successor, 0) + const cleared = await clearChat(COORDINATOR) + expect(cleared).toBe(COORDINATOR) + await vi.waitFor(() => expect(connectionFor(cleared).turns[0]?.text).toMatch(POINTER), WAIT) + await settleTurn(cleared, 0) await vi.waitFor( () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), WAIT ) }) - it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => { + it('keeps a Run under the same conversation address through repeated clears', async () => { await openChat(COORDINATOR) - const middle = await clearChat(COORDINATOR) - await startSuccessor(middle) + const firstClear = await clearChat(COORDINATOR) + await startClearedContext(firstClear) const created = await call( 'orchestration.runCreate', { objective: 'next' }, - { sessionId: middle } + { sessionId: firstClear } ) const runId = idOf(created.run) expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) - const successor = await clearChat(middle) - await startSuccessor(successor) + const cleared = await clearChat(firstClear) + await startClearedContext(cleared) await expect( - call('orchestration.runCurrent', {}, { sessionId: successor }) + call('orchestration.runCurrent', {}, { sessionId: cleared }) ).resolves.toMatchObject({ run: { id: runId } }) expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) // What it sends carries the same address. @@ -564,20 +566,20 @@ describe('a /clear keeps the chat its orchestration address', () => { call( 'orchestration.send', { to: `orca_session_id:${PEER_CHAT}`, subject: 'hi' }, - { sessionId: successor } + { sessionId: cleared } ) ).resolves.toMatchObject({ message: { from_handle: `orca_session_id:${COORDINATOR}` } }) }) - it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { + it('binds a Run a cleared chat creates or uses to the same conversation at the current generation', async () => { const root = COORDINATOR const boundOrcaSessionId = (runId: string): string | null => currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!) await openChat(COORDINATOR) - const middle = await clearChat(COORDINATOR) - await startSuccessor(middle) + const firstClear = await clearChat(COORDINATOR) + await startClearedContext(firstClear) const first = idOf( - (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run + (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: firstClear })).run ) expect(db.getRunRaw(first)).toMatchObject({ coordinator_orca_session_id: root, @@ -585,40 +587,43 @@ describe('a /clear keeps the chat its orchestration address', () => { }) expect(boundOrcaSessionId(first)).toBe(root) const second = idOf( - (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run + (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: firstClear })) + .run ) expect(boundOrcaSessionId(first)).toBeNull() - const successor = await clearChat(middle) - await startSuccessor(successor) - await call('orchestration.runUse', { id: first }, { sessionId: successor }) + const cleared = await clearChat(firstClear) + await startClearedContext(cleared) + await call('orchestration.runUse', { id: first }, { sessionId: cleared }) const rebound = db.getRunRaw(first)! expect(rebound.coordinator_orca_session_id).toBe(root) expect(rebound.coordinator_orca_session_id_generation).toBe(rebound.consumer_generation) expect(boundOrcaSessionId(second)).toBeNull() await expect( - call('orchestration.runCurrent', {}, { sessionId: successor }) + call('orchestration.runCurrent', {}, { sessionId: cleared }) ).resolves.toMatchObject({ run: { id: first } }) }) - it('lands mail sent to any session of the conversation in the live one', async () => { + it('lands successive mail at the same conversation after repeated clears', async () => { await openChat(PEER_CHAT) - const middle = await clearChat(PEER_CHAT) - const successor = await clearChat(middle) - // The first ping starts the live session, which neither clear did. - for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { + const firstClear = await clearChat(PEER_CHAT) + const cleared = await clearChat(firstClear) + expect(firstClear).toBe(PEER_CHAT) + expect(cleared).toBe(PEER_CHAT) + // The first ping starts the fresh context, which neither clear did. + for (const [index, spelling] of [PEER_CHAT, firstClear, cleared].entries()) { const sent = await call('orchestration.send', { from: 'term_worker', to: `orca_session_id:${spelling}`, subject: `ping ${index}` }) expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } }) - await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT) - await settleTurn(successor, index) + await vi.waitFor(() => expect(connectionFor(cleared).turns).toHaveLength(index + 1), WAIT) + await settleTurn(cleared, index) // Read each ping before the next is sent, so each check holds exactly one. - const checked = await call('orchestration.check', {}, { sessionId: successor }) + const checked = await call('orchestration.check', {}, { sessionId: cleared }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: `ping ${index}` }] }) - await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: successor }) + await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: cleared }) } }) }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts index 76e9330b8f91..044a73701661 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -22,7 +22,7 @@ import { request, sendUserMessage, settleTurn, - startSuccessor + startClearedContext } from './structured-chat-coordinator-mail-rig.test-fixture' const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) @@ -68,13 +68,14 @@ describe("a chat assignee's report", () => { expect(db.getTask(taskId)?.status).toBe('completed') }) - it('is admitted from the session a /clear continued the chat in, which keeps its Dispatch', async () => { + it('is admitted from the same cleared chat, which keeps its Dispatch', async () => { const { taskId, dispatchId } = await dispatchToChat() - const successor = await clearChat(PEER_CHAT) + const cleared = await clearChat(PEER_CHAT) + expect(cleared).toBe(PEER_CHAT) expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') - await startSuccessor(successor) + await startClearedContext(cleared) - await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: successor }) + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: cleared }) expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') }) @@ -200,18 +201,19 @@ describe("a chat assignee's liveness, stop and close", () => { expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') }) - it('reads unverifiable, never exited, when the session that continues the chat is unknown', async () => { + it('reads unverifiable, never exited, when the cleared chat record is unknown', async () => { const { dispatchId } = await dispatchToChat() - const successor = await clearChat(PEER_CHAT) + const cleared = await clearChat(PEER_CHAT) + expect(cleared).toBe(PEER_CHAT) const getRecord = host.deps.store.getRecord.bind(host.deps.store) vi.spyOn(host.deps.store, 'getRecord').mockImplementation((sessionId) => - sessionId === successor ? null : getRecord(sessionId) + sessionId === cleared ? null : getRecord(sessionId) ) expect(await call('orchestration.workerShow', { dispatch: dispatchId })).toMatchObject({ observation: { status: 'unverifiable', - reason: expect.stringContaining('after a /clear cannot be verified') + reason: 'No durable record backs this chat.' }, projection: { liveness: { verdict: 'unverifiable' } } }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts index 5460520db9d1..a4200de211ec 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts @@ -1,7 +1,7 @@ import './rpc/unused-default-rpc-methods.test-fixture' // A chat worker's coordinator mail lands in its Dispatch's mailbox. When the pointer to it was held // (the provider died before taking it), the chat's next idle edge points it again, after a restart -// or in the session a /clear continued the chat in, as it does for a chat's own mail. +// or in the same chat after /clear, as it does for a chat's own mail. import { describe, expect, it, vi } from 'vitest' import { formatOrcaSessionAddress } from '../../shared/orca-session-address' @@ -79,11 +79,12 @@ describe("a chat worker's held Dispatch mail is pointed again", () => { expect(turnText(revived.turns[1]!)).toMatch(POINTER) }) - it('in the session a /clear continued the chat in', async () => { + it('in the same chat after /clear', async () => { await heldDispatchMail() - const successor = await clearChat(PEER_CHAT) + const cleared = await clearChat(PEER_CHAT) + expect(cleared).toBe(PEER_CHAT) - const revived = await nextTurn(successor) + const revived = await nextTurn(cleared) await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) expect(turnText(revived.turns[1]!)).toMatch(POINTER) diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 4362c12fec76..25a0ef4ebe84 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -147,9 +147,13 @@ afterEach(async () => { }) describe('Claude rewind is unsupported', () => { - it('refuses a rewind RPC before writing any rewind record', async () => { + it('keeps ordinary unsupported refusal ahead of stale epoch without writing a rewind record', async () => { expect(CLAUDE_STRUCTURED_AGENT.capabilities.rewind).toBe(false) - expect(await host.rewind(caller, rewindParams(fence()))).toMatchObject({ + const request = rewindParams(fence()) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( + request.expectedEpoch + ) + expect(await host.rewind(caller, request)).toMatchObject({ ok: false, refusal: { rewindReason: 'unsupported' } }) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 77e66d31d639..8d7db18ecfd5 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -74,12 +74,17 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { releaseStalls = resolve }) let root: string | null = null + let host: StructuredAgentSessionHost | null = null let operations = 0 const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { const providerSessionId = String(launch.options.sessionId ?? launch.options.resume) const sessionId = sessionIds.find( - (candidate) => claudeSessionIdForOrcaSession(candidate) === providerSessionId + (candidate) => + claudeSessionIdForOrcaSession( + candidate, + host?.deps.store.getRecord(candidate)?.providerContextBoundary?.operationId + ) === providerSessionId ) if (!sessionId) { throw new Error(`no scripted Claude session for ${providerSessionId}`) @@ -228,7 +233,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { root = await mkdtemp(join(tmpdir(), 'orca-scripted-claude-runtime-')) await mkdir(join(root, 'claude-home'), { recursive: true }) const directory = root - return ensureStructuredAgentSessionHost({ + host = await ensureStructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), stateDirectory: directory, hostId: 'local', @@ -240,6 +245,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { openClaudeConnection: openConnection, readProcessStartTime }) + return host }, attachParams: ( sessionId: string, diff --git a/src/main/runtime/structured-conversation-tab-replacement.test.ts b/src/main/runtime/structured-conversation-tab-replacement.test.ts deleted file mode 100644 index f9061124f618..000000000000 --- a/src/main/runtime/structured-conversation-tab-replacement.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' - -describe('conversation pane replacement', () => { - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: 'folder', - publicationEpoch: 'epoch', - snapshotVersion: 4, - activeGroupId: 'right', - activeTabId: 'old-tab', - activeTabType: 'agent-session', - tabs: [ - { - type: 'agent-session', - id: 'old-tab', - sessionId: 'old-session', - agent: 'claude', - title: 'Old title', - isActive: true, - isPinned: true - } - ], - tabGroups: [ - { id: 'right', tabOrder: ['old-tab'], activeTabId: 'old-tab', recentTabIds: ['old-tab'] } - ] - } - const replacement = { - workspaceId: 'folder', - sourceSessionId: 'old-session', - sessionId: 'new-session', - agent: 'claude' as const - } - it('preserves group, position, selection and pinning while resetting identity/title', () => { - const result = replaceConversationInSnapshot(snapshot, replacement) - expect(result).toMatchObject({ - publicationEpoch: 'epoch', - snapshotVersion: 5, - activeGroupId: 'right', - activeTabId: 'agent-session:new-session' - }) - expect(result.tabs[0]).toMatchObject({ - sessionId: 'new-session', - title: 'Claude Chat', - replacesSessionId: 'old-session', - isPinned: true - }) - expect(result.tabGroups?.[0]).toMatchObject({ - tabOrder: ['agent-session:new-session'], - recentTabIds: ['agent-session:new-session'] - }) - expect(snapshot.tabs[0]).toMatchObject({ sessionId: 'old-session' }) - expect(replaceConversationInSnapshot(result, replacement)).toBe(result) - }) - it('does not touch another workspace', () => { - expect( - replaceConversationInSnapshot(snapshot, { ...replacement, workspaceId: 'elsewhere' }) - ).toBe(snapshot) - }) -}) diff --git a/src/main/runtime/structured-conversation-tab-replacement.ts b/src/main/runtime/structured-conversation-tab-replacement.ts deleted file mode 100644 index 7384a374c91f..000000000000 --- a/src/main/runtime/structured-conversation-tab-replacement.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' - -export function replaceConversationInSnapshot( - snapshot: RuntimeMobileSessionTabsSnapshot, - replacement: ConversationReplacement -): RuntimeMobileSessionTabsSnapshot { - if (snapshot.worktree !== replacement.workspaceId) { - return snapshot - } - const source = snapshot.tabs.find( - (tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sourceSessionId - ) - if (!source) { - return snapshot - } - const id = `agent-session:${replacement.sessionId}` - const rename = (value: string | null) => (value === source.id ? id : value) - return { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: rename(snapshot.activeTabId), - tabs: snapshot.tabs - .filter((tab) => tab.id !== id) - .map((tab) => - tab.id === source.id - ? { - ...tab, - type: 'agent-session' as const, - id, - sessionId: replacement.sessionId, - agent: replacement.agent, - title: defaultAgentChatLabel(replacement.agent), - replacesSessionId: replacement.sourceSessionId - } - : tab - ), - tabGroups: snapshot.tabGroups?.map((group) => ({ - ...group, - tabOrder: [...new Set(group.tabOrder.map((entry) => rename(entry)!))], - activeTabId: rename(group.activeTabId), - recentTabIds: group.recentTabIds?.map((entry) => rename(entry)!) - })) - } -} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 0560f51fe429..00825a7cf828 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -180,6 +180,12 @@ function PickerOption({ onChoose: (item: NativeChatPickerItem) => void }): React.JSX.Element { const annotation = getPickerAnnotation(item) + const description = + item.kind === 'command' && + item.name === 'clear' && + item.description === 'Clear conversation context' + ? translate('components.native-chat.composer.clearDescription', 'Clear conversation context') + : item.description return ( ) : null} - {item.description ? ( - {item.description} + {description ? ( + {description} ) : null} {annotation ? ( {annotation} diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 2aa52449dd30..e5391829efe1 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -46,8 +46,11 @@ export function NativeChatNoticeRow({ }): React.JSX.Element { useTranslation() const orcaStopView = useNativeChatOrcaStopView() - if (block.presentation === 'compaction') { - const label = translate('components.native-chat.notices.compaction', 'Context compacted') + if (block.presentation === 'compaction' || block.presentation === 'context-cleared') { + const label = + block.presentation === 'context-cleared' + ? translate('components.native-chat.notices.contextCleared', 'Context cleared') + : translate('components.native-chat.notices.compaction', 'Context compacted') return (
0} runtimeContext={context.runtimeContext} rewind={ - nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined) + nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined) && + (!rewind?.eligibleItemIds || rewind.eligibleItemIds.has(message.id)) ? rewind : undefined } 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 e4814640de47..25ff332ceb87 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 @@ -1,3 +1,4 @@ +import { agentSessionCurrentContextRows } from '../../../../shared/agent-session-context-clear' import type { AgentJournalRenderItem, AgentJournalSubmission @@ -29,7 +30,7 @@ export type StructuredPromptItem = AgentJournalRenderItem & { export function pendingStructuredSessionPrompts( items: AgentJournalRenderItem[] ): StructuredPromptItem[] { - return items.filter( + return agentSessionCurrentContextRows(items).items.filter( (item): item is StructuredPromptItem => (item.body.kind === 'approval' || item.body.kind === 'question') && item.body.resolution.state === 'pending' diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx index b30730b2056d..ab6a8366de3c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -101,6 +101,43 @@ describe('structured chat rewind', () => { expect(toastError).not.toHaveBeenCalled() }) + it('offers rewind only after the clear, including when its divider is off the loaded page', async () => { + const props = input() + const clear = { + ...item('clear', 11), + body: { + kind: 'status' as const, + text: 'Context cleared', + contextClear: { operationId: 'clear', afterFence: 1, clearedAt: 1 } + } + } + const view = render({ + ...props, + state: { ...props.state, items: [item('user', 10), clear, item('later', 12)] } + }) + expect([...(view.result.current.surface?.eligibleItemIds ?? [])]).toEqual(['later']) + const confirm = vi.fn().mockResolvedValue(true) + await act(() => view.result.current.request('user', confirm)) + expect(confirm).not.toHaveBeenCalled() + view.rerender({ ...props, contextFloor: { epoch: 'old', sequence: 11 } }) + expect([...(view.result.current.surface?.eligibleItemIds ?? [])]).toEqual(['later']) + }) + + it('awaits the suffix sequence in the same epoch before unblocking', async () => { + const props = input() + props.send.mockResolvedValue({ + kind: 'done', + value: { itemId: 'user', epoch: 'old', sequence: 15 } + }) + const view = render(props) + await act(() => view.result.current.request('user', vi.fn().mockResolvedValue(true))) + expect(view.result.current.pending).toBe(true) + expect(view.result.current.blockedRef.current).toBe(true) + view.rerender({ ...props, state: { ...props.state, cursor: { epoch: 'old', sequence: 15 } } }) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + }) + it('offers nothing for a row the journal does not hold as a user message', async () => { const props = input() const confirm = vi.fn() diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts index bd71d991fe3e..e547dec2e70b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts @@ -16,24 +16,22 @@ import { nativeChatRewindTimeoutCopy, nativeChatRewindUnavailableCopy } from './native-chat-rewind-copy' -import type { - StructuredAgentSessionWrite, - StructuredAgentSessionWriteOutcome -} from './use-structured-agent-session-mutate' +import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' import { returnMessageToComposer } from './structured-agent-session-message-hand-back' import { nativeChatRewindOffered } from './native-chat-rewind-eligibility' -import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' -import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/structured-agent-session-host-capability' +import { latestAgentSessionContextClearSequence } from '../../../../shared/agent-session-context-clear' +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' export type NativeChatRewindSurface = { disabledReason: string | null + eligibleItemIds?: ReadonlySet request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise } /** How long a confirmed rewind waits for its new conversation before letting go. */ export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000 -type RewindInput = { +export type RewindInput = { sessionId: string /** The composer the discarded message returns to, after whatever is typed there. */ composerScopeKey?: string @@ -42,6 +40,7 @@ type RewindInput = { /** Whether the pane is shown, and so reading; absent means shown. */ isVisible?: boolean state: StructuredAgentSessionState + contextFloor?: AgentJournalCursor /** Undefined until the host has answered for the current runtime. */ support: AgentSessionRewindSupport | undefined /** The host's in-doubt latch: disables the action, never sending. */ @@ -72,9 +71,23 @@ function rewindFailureReason(failure: AgentSessionWriteFailure): string | undefi : undefined } -function isRewindTarget(state: StructuredAgentSessionState, itemId: string): boolean { - return state.items.some( - (item) => item.itemId === itemId && item.body.kind === 'message' && item.body.role === 'user' +function isRewindTarget(input: RewindInput, itemId: string): boolean { + const floor = rewindContextFloor(input) + return input.state.items.some( + (item) => + item.itemId === itemId && + item.sequence > floor && + item.body.kind === 'message' && + item.body.role === 'user' + ) +} + +function rewindContextFloor(input: RewindInput): number { + return Math.max( + latestAgentSessionContextClearSequence(input.state.items), + input.contextFloor && input.contextFloor.epoch === input.state.epoch + ? input.contextFloor.sequence + : 0 ) } @@ -118,9 +131,15 @@ export function useNativeChatRewind(input: RewindInput) { const inFlight = useRef(false) const [sending, setSending] = useState(false) // A confirmed rewind whose new conversation has not arrived yet. - const [awaiting, setAwaiting] = useState<{ sessionId: string; epoch: string } | null>(null) + const [awaiting, setAwaiting] = useState<{ + sessionId: string + epoch: string + sequence?: number + } | null>(null) const awaitingReset = - awaiting?.sessionId === input.sessionId && awaiting.epoch === input.state.epoch + awaiting?.sessionId === input.sessionId && + awaiting.epoch === input.state.epoch && + (awaiting.sequence === undefined || (input.state.cursor?.sequence ?? 0) < awaiting.sequence) // A hidden pane stops reading, so its new conversation cannot arrive; that is no failure to report. const hiddenWhileAwaiting = useRef(false) const visible = input.isVisible !== false @@ -155,7 +174,7 @@ export function useNativeChatRewind(input: RewindInput) { inFlight.current || blockedRef.current || blockedReason(captured) || - !isRewindTarget(captured.state, itemId) + !isRewindTarget(captured, itemId) ) { return } @@ -204,8 +223,22 @@ export function useNativeChatRewind(input: RewindInput) { } if (outcome.kind === 'done') { giveBack() - keepBlocked = !reset - setAwaiting(reset ? null : { sessionId: captured.sessionId, epoch: expectedEpoch }) + const caughtUp = + reset || + (outcome.value.sequence !== undefined && + (latest.current.state.cursor?.sequence ?? 0) >= outcome.value.sequence) + keepBlocked = !caughtUp + setAwaiting( + caughtUp + ? null + : { + sessionId: captured.sessionId, + epoch: expectedEpoch, + ...(outcome.value.sequence !== undefined + ? { sequence: outcome.value.sequence } + : {}) + } + ) return } if (outcome.kind === 'dropped') { @@ -248,10 +281,20 @@ export function useNativeChatRewind(input: RewindInput) { [admitsSend] ) const offered = nativeChatRewindOffered(input.support) + const floor = rewindContextFloor(input) + const eligibleItemIds = useMemo( + () => + floor > 0 + ? new Set( + input.state.items.filter((item) => item.sequence > floor).map((item) => item.itemId) + ) + : undefined, + [floor, input.state.items] + ) // Rows re-render only when this changes; none get the action where the provider can never rewind. const surface = useMemo( - () => (offered ? { disabledReason, request } : undefined), - [offered, disabledReason, request] + () => (offered ? { disabledReason, request, eligibleItemIds } : undefined), + [offered, disabledReason, request, eligibleItemIds] ) return { request, admitsSend, unlessBlocked, surface, disabledReason, pending, blockedRef } } @@ -263,52 +306,3 @@ export type NativeChatRewindHost = { onMessageReturned?: () => void isVisible?: boolean } - -/** The rewind a structured session's user rows offer, sent through the session's own writes. */ -export function useStructuredAgentSessionRewind( - args: Omit & - NativeChatRewindHost & { target: RuntimeClientTarget; write: StructuredAgentSessionWrite } -) { - const { - blocked, - composerScopeKey, - hostBlockedReason, - isVisible, - onMessageReturned, - sessionId, - state, - target, - write - } = args - // An in-doubt rewind returns the prompt for the next send to settle; only a host that settles it - // on a send may offer one. Unknown hides it, as unresolved support does. - const support = useStructuredAgentSessionHostRecoversRewindOnSend(target) - ? args.support - : undefined - const input = useMemo( - () => ({ - sessionId, - composerScopeKey, - onMessageReturned, - isVisible, - hostBlockedReason: hostBlockedReason ?? undefined, - state, - support, - blocked, - send: (fields) => - write('agentSession.rewind', 'agentSession.rewind', fields) - }), - [ - blocked, - composerScopeKey, - hostBlockedReason, - isVisible, - onMessageReturned, - sessionId, - state, - support, - write - ] - ) - return useNativeChatRewind(input) -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts index 54b0df881b3f..e5ed6dd0cb2a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts @@ -10,12 +10,7 @@ import type { } from './use-structured-agent-session-mutate' import { holdStructuredAgentSessionSends } from './structured-agent-session-pending-sends' -/** - * Sends a conversation command. A /clear keeps the chat's sends out while it runs, as its host - * refuses them, so text typed meanwhile stays in the box. Once it moves the chat to a new - * conversation, the old one, which its host also refuses, takes nothing until this view leaves it. - * Either way the hold ends by the command's deadline: the local call has none of its own. - */ +/** Holds sends during clear; an older host's replacement keeps the hold until the view leaves. */ export function useStructuredAgentSessionCommandWrite( sessionId: string, write: StructuredAgentSessionWrite diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts index 23f6166a7087..80db32368162 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts @@ -10,10 +10,18 @@ import { * filling a part the window lacks. A host without that answer leaves the window's alone. */ export function useStructuredAgentSessionContextUsage( journalItems: readonly AgentJournalRenderItem[], - support: AgentSessionOptionsResult['contextUsage'] + support: AgentSessionOptionsResult['contextUsage'], + journalEpoch?: string | null ): StructuredAgentContextUsage | null { return useMemo( - () => selectStructuredAgentContextUsage(journalItems, support?.current), - [journalItems, support] + () => + selectStructuredAgentContextUsage( + journalItems, + support?.current, + support?.contextFloor && (!journalEpoch || support.contextFloor.epoch === journalEpoch) + ? support.contextFloor.sequence + : 0 + ), + [journalItems, support, journalEpoch] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts new file mode 100644 index 000000000000..9f86b1e0b12a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts @@ -0,0 +1,62 @@ +import { useMemo } from 'react' +import type { AgentSessionRewindResult } from '../../../../shared/agent-session-rewind' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/structured-agent-session-host-capability' +import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate' +import { + useNativeChatRewind, + type NativeChatRewindHost, + type RewindInput +} from './use-native-chat-rewind' + +/** The rewind a structured session's user rows offer, sent through the session's own writes. */ +export function useStructuredAgentSessionRewind( + args: Omit & + NativeChatRewindHost & { target: RuntimeClientTarget; write: StructuredAgentSessionWrite } +) { + const { + blocked, + composerScopeKey, + contextFloor, + hostBlockedReason, + isVisible, + onMessageReturned, + sessionId, + state, + target, + write + } = args + // An in-doubt rewind returns the prompt for the next send to settle; only a host that settles it + // on a send may offer one. Unknown hides it, as unresolved support does. + const support = useStructuredAgentSessionHostRecoversRewindOnSend(target) + ? args.support + : undefined + const input = useMemo( + () => ({ + sessionId, + composerScopeKey, + contextFloor, + onMessageReturned, + isVisible, + hostBlockedReason: hostBlockedReason ?? undefined, + state, + support, + blocked, + send: (fields) => + write('agentSession.rewind', 'agentSession.rewind', fields) + }), + [ + blocked, + composerScopeKey, + contextFloor, + hostBlockedReason, + isVisible, + onMessageReturned, + sessionId, + state, + support, + write + ] + ) + return useNativeChatRewind(input) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts index a963a3b9c9ae..d3dc5bd00098 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -22,14 +22,22 @@ export type StructuredAgentSessionThreadGoal = { export function useStructuredAgentSessionThreadGoal(args: { journalItems: readonly AgentJournalRenderItem[] support: AgentSessionOptionsResult['threadGoal'] + journalEpoch?: string | null mutate: StructuredAgentSessionMutate }): StructuredAgentSessionThreadGoal | null { - const { journalItems, mutate, support } = args + const { journalItems, mutate, support, journalEpoch } = args const [pending, setPending] = useState(false) const pendingRef = useRef(false) // The loaded window reaches the live head, so a goal row in it is newer than the // host's whole-journal answer; that answer covers only rows older than the window. - const loaded = useMemo(() => currentAgentSessionThreadGoal(journalItems), [journalItems]) + const floor = + support?.contextFloor && (!journalEpoch || support.contextFloor.epoch === journalEpoch) + ? support.contextFloor.sequence + : 0 + const loaded = useMemo( + () => currentAgentSessionThreadGoal(journalItems, floor), + [journalItems, floor] + ) const goal = loaded === undefined ? (support?.current ?? null) : loaded const change = useCallback( async (next: AgentSessionThreadGoalChange): Promise => { 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 b956d721ffd4..ef730a1781df 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 @@ -7,6 +7,7 @@ import { selectStructuredAgentTurnActivity } from '../../../../shared/native-cha import { structuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view' import { structuredSessionForegroundCommands } from '../../../../shared/structured-session-foreground-commands' import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' +import { agentSessionCurrentContextRows } from '../../../../shared/agent-session-context-clear' const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] @@ -21,10 +22,14 @@ export function useStructuredAgentSessionTransportState( const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null const latestTurn = enabled ? state.latestTurn : 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, submissions, fence) + const isWorking = 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. @@ -65,6 +70,7 @@ export function useStructuredAgentSessionTransportState( queuePause: (enabled ? state.queuePause : null) ?? null, /** Working only because the queue is about to send: nothing is in flight to stop yet. */ queueSendsNext, - backgroundTasks + backgroundTasks, + conversationBusy: Boolean(turnId || backgroundTasks.isMonitoring) } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index 52736ea98470..84976d1d4659 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -17,7 +17,7 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import type * as RewindModule from './use-native-chat-rewind' +import type * as RewindModule from './use-structured-agent-session-rewind' const mocks = vi.hoisted(() => ({ call: vi.fn(), @@ -53,7 +53,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ })) // The real rewind hook, with only its in-flight latch forced when a test says so. -vi.mock('./use-native-chat-rewind', async (importOriginal) => { +vi.mock('./use-structured-agent-session-rewind', async (importOriginal) => { const actual = await importOriginal() return { ...actual, 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 d21cb5b29a97..0efcd8074e24 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 @@ -36,7 +36,7 @@ import { import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' -import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' +import { useStructuredAgentSessionRewind } from './use-structured-agent-session-rewind' import type { NativeChatRewindHost } from './use-native-chat-rewind' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -153,12 +153,14 @@ export function useStructuredAgentSession(args: { const threadGoal = useStructuredAgentSessionThreadGoal({ journalItems: transportState.journalItems, + journalEpoch: state.epoch, support: threadGoalSupport, mutate }) const contextUsage = useStructuredAgentSessionContextUsage( transportState.journalItems, - contextUsageSupport + contextUsageSupport, + state.epoch ) const railOutline = useStructuredAgentSessionRailOutline({ @@ -172,12 +174,7 @@ export function useStructuredAgentSession(args: { const commandWrite = useStructuredAgentSessionCommandWrite(sessionId, write) const sending = pending.some((entry) => entry.phase === 'sending') // What the host refuses a rewind behind; a command's own hold is the runner's below. - const conversationBusy = Boolean( - transportState.turnId || - prompts.length || - transportState.backgroundTasks.isMonitoring || - sending - ) + const conversationBusy = transportState.conversationBusy || prompts.length > 0 || sending const rewind = useStructuredAgentSessionRewind({ sessionId, target, @@ -185,6 +182,7 @@ export function useStructuredAgentSession(args: { ...args.rewind, state, support: transportEnabled ? rewindSupport : undefined, + contextFloor: contextUsageSupport?.contextFloor ?? threadGoalSupport?.contextFloor, // The host also refuses a rewind behind its queued cards, paused ones included. blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 140dc56c828b..bf28c4d6130a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18186,6 +18186,7 @@ "cancelledBy": "Cancelled on {{device}}" }, "notices": { + "contextCleared": "Context cleared", "compaction": "Context compacted", "details": "Details", "plan": "Plan", @@ -18218,6 +18219,7 @@ "skills": "Skills", "loadingSkills": "Loading skills...", "skillsLoaded": "Skills loaded", + "clearDescription": "Clear conversation context", "skillsUnavailableHost": "Skills can't be listed in SSH chats", "skillsLoadFailed": "Couldn't load skills", "retrySkills": "Retry", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 903faac31db7..2ffd1e5f0d7a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17997,6 +17997,7 @@ "commands": "Comandos", "skills": "Habilidades", "loadingSkills": "Cargando habilidades…", + "clearDescription": "Borrar el contexto de la conversación", "skillsUnavailableHost": "Las habilidades no se pueden listar en los chats SSH", "skillsLoadFailed": "No se pudieron cargar las habilidades", "retrySkills": "Reintentar", @@ -18337,6 +18338,7 @@ "recorded": "Totales de las ediciones registradas en este turno." }, "notices": { + "contextCleared": "Contexto borrado", "compaction": "Contexto compactado", "details": "Detalles", "plan": "Plan", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ad5cfc6b400d..67387c7e0abd 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18054,6 +18054,7 @@ "skills": "Skills", "loadingSkills": "Chargement des skills...", "skillsLoaded": "Skills chargées", + "clearDescription": "Effacer le contexte de la conversation", "skillsUnavailableHost": "Les skills ne peuvent pas être listées dans les chats SSH", "skillsLoadFailed": "Impossible de charger les skills", "retrySkills": "Réessayer", @@ -18321,6 +18322,7 @@ "cancelledBy": "Annulé le {{device}}" }, "notices": { + "contextCleared": "Contexte effacé", "compaction": "Contexte compacté", "details": "Détails", "plan": "Plan", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3aa840dfaa01..80462774244f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18028,6 +18028,7 @@ "commands": "コマンド", "skills": "スキル", "loadingSkills": "スキルを読み込み中…", + "clearDescription": "会話のコンテキストをクリア", "skillsUnavailableHost": "SSH チャットではスキルを一覧表示できません", "skillsLoadFailed": "スキルを読み込めませんでした", "retrySkills": "再試行", @@ -18255,6 +18256,7 @@ "cancelledBy": "{{device}}にキャンセルされました" }, "notices": { + "contextCleared": "コンテキストをクリアしました", "compaction": "圧縮されたコンテキスト", "details": "詳細", "plan": "プラン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 43a423f16585..3a04f8ccaa9d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18028,6 +18028,7 @@ "commands": "명령어", "skills": "스킬", "loadingSkills": "스킬 로딩 중…", + "clearDescription": "대화 컨텍스트 지우기", "skillsUnavailableHost": "SSH 채팅에서는 스킬 목록을 표시할 수 없습니다", "skillsLoadFailed": "스킬을 불러올 수 없습니다", "retrySkills": "재시도", @@ -18255,6 +18256,7 @@ "cancelledBy": "{{device}}에 취소됨" }, "notices": { + "contextCleared": "컨텍스트 지워짐", "compaction": "컨텍스트가 압축됨", "details": "세부", "plan": "계획", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9057858199aa..4d42a6fb10fe 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17993,6 +17993,7 @@ "commands": "命令", "skills": "技能", "loadingSkills": "正在加载技能…", + "clearDescription": "清除对话上下文", "skillsUnavailableHost": "SSH 聊天中无法列出技能", "skillsLoadFailed": "无法加载技能", "retrySkills": "重试", @@ -18220,6 +18221,7 @@ "cancelledBy": "于 {{device}} 取消" }, "notices": { + "contextCleared": "上下文已清除", "compaction": "上下文压缩", "details": "细节", "plan": "计划", diff --git a/src/shared/agent-session-context-clear.test.ts b/src/shared/agent-session-context-clear.test.ts new file mode 100644 index 000000000000..b115c44b61c0 --- /dev/null +++ b/src/shared/agent-session-context-clear.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalThreadGoal +} from './agent-session-journal-types' +import { + agentSessionCurrentContextRows, + isAgentSessionContextClear, + isNativeChatContextClear, + latestAgentSessionContextClearSequence +} from './agent-session-context-clear' +import { + currentAgentSessionThreadGoal, + currentAgentSessionThreadGoalBySequence +} from './agent-session-thread-goal' +import { + activeStructuredAgentSessionTurnId, + newestStructuredAgentSessionTurn +} from './structured-agent-session-live-turn' +import { + projectStructuredAgentSessionStatusState, + projectStructuredItemToNativeChat +} from './structured-agent-session-projection' +import { + latestStructuredAgentContextFacts, + selectStructuredAgentContextUsage +} from './structured-agent-session-context-usage' +import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' + +const boundary = { operationId: 'clear', afterFence: 3, clearedAt: 100 } +const row = (sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem => ({ + itemId: `row-${sequence}`, + sequence, + revision: 1, + observedAt: sequence, + body +}) +const clear = row(5, { + kind: 'status', + text: 'Localized fallback', + presentation: 'context-cleared', + contextClear: boundary +}) +const oldSend: AgentJournalSubmission = { + clientMessageId: 'old-send', + fence: 3, + payloadFingerprint: 'same', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + acceptedSequence: 2 +} +const user = row(1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Prior prompt' }] +}) +const running = row(3, { kind: 'turn', turnId: 'old-turn', state: 'running' }) +const oldPrompt = row(4, { + kind: 'question', + question: 'Old question', + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +}) +const goal: AgentJournalThreadGoal = { + objective: 'Old goal', + status: 'active', + tokenBudget: null, + tokensUsed: 1, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 +} +const goalRow = row(2, { kind: 'status', text: 'Goal set', threadGoal: { state: 'set', goal } }) +const usageRow = row(3, { + kind: 'turn', + turnId: 'old-turn', + state: 'completed', + contextUsage: { + window: { tokens: 200_000, capturedAt: 1 }, + used: { + kind: 'estimate', + capturedAt: 1, + usage: { + inputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + outputTokens: 0 + } + } + } +}) + +describe('completed context clear projections', () => { + it('uses typed metadata and carries it through the shared desktop and mobile row', () => { + expect(isAgentSessionContextClear(clear.body)).toBe(true) + expect( + isAgentSessionContextClear({ + kind: 'status', + text: 'Context cleared', + presentation: 'context-cleared' + }) + ).toBe(false) + const message = projectStructuredItemToNativeChat(clear)! + expect(message).toMatchObject({ + role: 'system', + blocks: [{ text: 'Localized fallback', contextClear: boundary }] + }) + expect(isNativeChatContextClear(message)).toBe(true) + }) + + it('leaves earlier rows and uncertainty visible while clearing current work and prompts', () => { + const rows = [user, running, oldPrompt, clear] + const state = projectStructuredAgentSessionStatusState(rows, [oldSend], 3) + expect(state.summary).toEqual({ status: 'idle', latestPrompt: 'Prior prompt' }) + expect(state.latestRequest).toBeNull() + expect(state.pendingPromptIds).toEqual([]) + expect(state.owesWork).toBe(false) + expect(owesStructuredAgentSessionWork(rows, [oldSend], 3)).toBe(false) + expect(activeStructuredAgentSessionTurnId(rows)).toBeNull() + expect(newestStructuredAgentSessionTurn(rows)).toBeNull() + expect(rows).toHaveLength(4) + expect(oldSend.dispatchState).toBe('unknown') + }) + + it('includes new sends but excludes older unknown sends even at the current fence', () => { + const currentSend = { ...oldSend, clientMessageId: 'new-send', acceptedSequence: 6 } + const rows = [user, clear, row(7, { kind: 'turn', turnId: 'new-turn', state: 'running' })] + expect(agentSessionCurrentContextRows(rows, [oldSend, currentSend])).toEqual({ + items: [rows[2]], + submissions: [currentSend] + }) + expect(activeStructuredAgentSessionTurnId(rows)).toBe('new-turn') + expect(owesStructuredAgentSessionWork(rows, [oldSend, currentSend], 3)).toBe(true) + }) + + it('resets the current goal in sorted snapshots and unordered host maps', () => { + expect(currentAgentSessionThreadGoal([goalRow, clear])).toBeNull() + expect(currentAgentSessionThreadGoalBySequence([clear, goalRow])).toBeNull() + const newGoal = row(6, { + kind: 'status', + text: 'Goal set', + threadGoal: { state: 'set', goal: { ...goal, objective: 'New goal' } } + }) + expect(currentAgentSessionThreadGoalBySequence([newGoal, goalRow, clear])?.objective).toBe( + 'New goal' + ) + expect(currentAgentSessionThreadGoal([goalRow], clear.sequence)).toBeUndefined() + }) + + it('does not use earlier usage or stale whole-journal facts after a loaded clear', () => { + const old = latestStructuredAgentContextFacts([usageRow]) + expect(selectStructuredAgentContextUsage([usageRow, clear], old)).toBeNull() + expect(latestStructuredAgentContextFacts([clear, usageRow])).toEqual({}) + const newUsage = { ...usageRow, itemId: 'new-usage', sequence: 6 } + expect(latestStructuredAgentContextFacts([newUsage, clear, usageRow])).toEqual(old) + expect(selectStructuredAgentContextUsage([usageRow], undefined, clear.sequence)).toBeNull() + }) + + it('chooses the newest clear independent of map order and scopes off-page rows', () => { + const again = { ...clear, itemId: 'clear-again', sequence: 8 } + expect(latestAgentSessionContextClearSequence([again, user, clear])).toBe(8) + expect(agentSessionCurrentContextRows([user], [oldSend], 5)).toEqual({ + items: [], + submissions: [] + }) + }) +}) diff --git a/src/shared/agent-session-context-clear.ts b/src/shared/agent-session-context-clear.ts new file mode 100644 index 000000000000..1564054d66ef --- /dev/null +++ b/src/shared/agent-session-context-clear.ts @@ -0,0 +1,65 @@ +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { isAgentSessionProviderContextBoundary } from './agent-session-provider-context' +import type { NativeChatMessage } from './native-chat-types' + +export function isAgentSessionContextClear(body: AgentJournalItemBody | undefined): boolean { + return body?.kind === 'status' && isAgentSessionProviderContextBoundary(body.contextClear) +} + +export function isNativeChatContextClear(message: NativeChatMessage): boolean { + return ( + message.role === 'system' && + message.blocks.some( + (block) => block.type === 'text' && isAgentSessionProviderContextBoundary(block.contextClear) + ) + ) +} + +export function agentSessionContextSequenceFor( + sequence: number, + boundaries: readonly number[] +): number { + let left = 0 + let right = boundaries.length + while (left < right) { + const middle = Math.floor((left + right) / 2) + if (boundaries[middle] < sequence) { + left = middle + 1 + } else { + right = middle + } + } + return left > 0 ? boundaries[left - 1] : 0 +} + +export function latestAgentSessionContextClearSequence( + items: Iterable> +): number { + let sequence = 0 + for (const item of items) { + if (item.sequence > sequence && isAgentSessionContextClear(item.body)) { + sequence = item.sequence + } + } + return sequence +} + +export function agentSessionCurrentContextRows( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [], + minimumSequence = 0 +) { + const sequence = Math.max(minimumSequence, latestAgentSessionContextClearSequence(items)) + return sequence > 0 + ? { + items: items.filter((item) => item.sequence > sequence), + submissions: submissions.filter( + (submission) => (submission.acceptedSequence ?? 0) > sequence + ) + } + : { items, submissions } +} diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index b696d0515317..6cd3ba9d1319 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -64,7 +64,7 @@ export function isAgentSessionConversationCommandRecord( return ( (row.phase === 'prepared' || row.phase === 'committed') && (row.runtimeFence === undefined || - (Number.isSafeInteger(row.runtimeFence) && row.runtimeFence > 0)) && + (Number.isSafeInteger(row.runtimeFence) && row.runtimeFence >= 0)) && typeof row.operationId === 'string' && row.operationId.length > 0 && row.operationId.length <= 512 && diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index e7dd3c2a8b14..9e5262693514 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -251,6 +251,13 @@ const KnownItemBody = z.discriminatedUnion('kind', [ kind: z.literal('status'), text: z.string(), presentation: z.string().optional(), + contextClear: z + .object({ + operationId: z.string().min(1).max(512), + afterFence: z.number().int().nonnegative(), + clearedAt: z.number().int().nonnegative() + }) + .optional(), tone: z.string().optional(), turnLifecycle: z.object(TurnLifecycleFields).optional(), providerFrame: ProviderFrame.optional(), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 2ad0cc921acb..2ff04f88ad4f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -1,3 +1,4 @@ +import type { AgentSessionProviderContextBoundary } from './agent-session-provider-context' // ─── Canonical agent-session journal: cross-process wire shapes ───────────── // The host-owned timeline for a structured agent session. Everything here must // be plain JSON: rows are persisted verbatim and later republished to clients, @@ -321,6 +322,7 @@ type AgentJournalStatusItemFields = { kind: 'status' /** Optional display hints; unknown values retain the ordinary text fallback. */ presentation?: string + contextClear?: AgentSessionProviderContextBoundary tone?: string /** Legacy carrier of a turn record: written by hosts before v3, and published * to clients that predate the `turn` item. New code reads turns through diff --git a/src/shared/agent-session-provider-context.test.ts b/src/shared/agent-session-provider-context.test.ts new file mode 100644 index 000000000000..8378394c2b1d --- /dev/null +++ b/src/shared/agent-session-provider-context.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + activeProviderContext, + isAgentSessionProviderContextBoundary +} from './agent-session-provider-context' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { encodeAgentSessionRecord } from './agent-session-record-stored-form' + +const boundary = { operationId: 'clear-operation', afterFence: 3, clearedAt: 5000 } + +describe('provider context after clear', () => { + it('has no provider handle while the fresh context has not started', () => { + expect( + activeProviderContext({ providerHandleChain: [], providerContextBoundary: boundary }) + ).toEqual({ + head: null, + handleChain: [], + pendingClear: true + }) + }) + + it('uses the chain head after fresh creation without filtering or replacing proof', () => { + const record = { ...agentSessionRecordFixture(), providerContextBoundary: boundary } + expect(activeProviderContext(record)).toEqual({ + head: record.providerHandleChain.at(-1), + handleChain: record.providerHandleChain, + pendingClear: false + }) + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true) + }) + + it('rejects a boundary beyond the record fence', () => { + const record = agentSessionRecordFixture() + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord({ + ...record, + providerContextBoundary: { ...boundary, afterFence: record.lease.runtimeFence + 1 } + }) + ) + ).toBe(false) + }) + + it.each([ + {}, + { ...boundary, afterFence: -1 }, + { ...boundary, clearedAt: Number.NaN }, + { ...boundary, operationId: '' } + ])('rejects malformed boundary metadata', (value) => + expect(isAgentSessionProviderContextBoundary(value)).toBe(false) + ) +}) diff --git a/src/shared/agent-session-provider-context.ts b/src/shared/agent-session-provider-context.ts new file mode 100644 index 000000000000..267988359a36 --- /dev/null +++ b/src/shared/agent-session-provider-context.ts @@ -0,0 +1,41 @@ +import type { AgentSessionRecord } from './agent-session-record' + +export type AgentSessionProviderContextBoundary = { + operationId: string + afterFence: number + clearedAt: number +} + +export function isAgentSessionProviderContextBoundary( + value: unknown +): value is AgentSessionProviderContextBoundary { + return ( + typeof value === 'object' && + value !== null && + 'operationId' in value && + typeof value.operationId === 'string' && + value.operationId.length > 0 && + value.operationId.length <= 512 && + 'afterFence' in value && + typeof value.afterFence === 'number' && + Number.isSafeInteger(value.afterFence) && + value.afterFence >= 0 && + 'clearedAt' in value && + typeof value.clearedAt === 'number' && + Number.isSafeInteger(value.clearedAt) && + value.clearedAt >= 0 + ) +} + +/** Clear resets the chain; the boundary identifies a fresh launch before its first proof. */ +export function activeProviderContext( + record: Pick +) { + const handleChain = record.providerHandleChain + const head = handleChain.at(-1) ?? null + return { + handleChain, + head, + pendingClear: record.providerContextBoundary !== undefined && head === null + } +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index c228335a6264..185c62c8333e 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -25,9 +25,10 @@ import { type AgentSessionProviderHandleLink } from './agent-session-provider-handle' import { - isAgentSessionProviderHandleInNamespace, - isStructuredAgentId -} from './agent-session-provider-handle-encoding' + isAgentSessionProviderContextBoundary, + type AgentSessionProviderContextBoundary +} from './agent-session-provider-context' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' import { isAgentSessionAccountHome, MAX_PATH_LENGTH, @@ -149,6 +150,7 @@ export type AgentSessionRecord = { /** The agent this session names, whether this build can run it or not. */ provider: string providerHandleChain: AgentSessionProviderHandleLink[] + providerContextBoundary?: AgentSessionProviderContextBoundary accountHome: AgentSessionAccountHome /** The directory the provider first launched in, in the execution host's path syntax. Floating * sessions resume here; worktree and folder ids still resolve by id to their durable place. */ @@ -359,6 +361,8 @@ export function isPersistedAgentSessionRecord( isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && + (record.providerContextBoundary === undefined || + isAgentSessionProviderContextBoundary(record.providerContextBoundary)) && (record.conversationCommand === undefined || isAgentSessionConversationCommandRecord(record.conversationCommand)) && (record.conversationName === undefined || @@ -376,15 +380,12 @@ export function isPersistedAgentSessionRecord( // The row holds stored handles; validate the chain they decode to. const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) const head = chain?.at(-1) - // One namespace, owned by the record's own agent; which transport is the chain's own fact. - const transport = chain?.[0]?.handle.transport - const namespace = transport === undefined ? null : { transport, agent: validated.provider } return ( chain !== null && - chain.every( - (link) => - namespace !== null && isAgentSessionProviderHandleInNamespace(link.handle, namespace) - ) && + // Chain validation already enforces one namespace. + (!head || head.handle.agent === validated.provider) && + (validated.providerContextBoundary === undefined || + validated.providerContextBoundary.afterFence <= validated.lease.runtimeFence) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index 3890bd8f81d3..5fee1084379e 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -74,6 +74,7 @@ export type AgentSessionResumeMarker = { * record it. */ journalCursor?: AgentJournalCursor + contextClearOperationId?: string /** * What the session was doing, captured at the same stop-time snapshot that decided the offer. * The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the @@ -128,6 +129,7 @@ const agentSessionResumeMarkerSchema = z.object({ trigger: z.enum(AGENT_SESSION_RESUME_TRIGGERS), providerHandleRoot: markerField, teardownId: markerField, + contextClearOperationId: markerField.optional().catch(undefined), journalCursor: z .object({ epoch: markerField, sequence: z.number().int().nonnegative() }) .optional() diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts index 805d74b8843a..e7d864390cca 100644 --- a/src/shared/agent-session-rewind.ts +++ b/src/shared/agent-session-rewind.ts @@ -27,7 +27,11 @@ export type AgentSessionRewindParams = { itemId: string expectedEpoch: string } -export type AgentSessionRewindResult = { itemId: string; epoch: string } +export type AgentSessionRewindResult = { + itemId: string + epoch: string + sequence?: number +} const Key = z.string().min(1).max(4096) export const AgentSessionRewindRecordSchema = z.object({ @@ -36,8 +40,11 @@ export const AgentSessionRewindRecordSchema = z.object({ itemId: Key, providerItemId: Key.optional(), expectedEpoch: Key, + contextClearOperationId: Key.optional(), + contextClearSequence: z.number().int().positive().optional(), phase: z.enum(['prepared', 'provider-succeeded', 'completed', 'refused']), epoch: Key.optional(), + sequence: z.number().int().nonnegative().optional(), hydrationVerified: z.boolean().optional(), reason: z.string().min(1).max(512).optional(), retained: z diff --git a/src/shared/agent-session-thread-goal.ts b/src/shared/agent-session-thread-goal.ts index fc0b3ffbc9a2..5f15bb3b5fdb 100644 --- a/src/shared/agent-session-thread-goal.ts +++ b/src/shared/agent-session-thread-goal.ts @@ -2,6 +2,7 @@ // beside it. The latest goal transition in journal order is the whole answer. import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { isAgentSessionContextClear } from './agent-session-context-clear' import { AGENT_JOURNAL_THREAD_GOAL_STATUSES, type AgentJournalItemBody, @@ -48,7 +49,10 @@ function goalFromRow(body: AgentJournalItemBody): AgentJournalThreadGoal | null } function isGoalTransition(item: GoalCandidate): boolean { - return isRootAgentJournalItem(item) && isAgentJournalThreadGoalRow(item.body) + return ( + isAgentSessionContextClear(item.body) || + (isRootAgentJournalItem(item) && isAgentJournalThreadGoalRow(item.body)) + ) } /** @@ -59,11 +63,12 @@ function isGoalTransition(item: GoalCandidate): boolean { * row's sequence, so the last goal row is the answer. */ export function currentAgentSessionThreadGoal( - items: readonly GoalCandidate[] + items: readonly GoalCandidate[], + minimumSequence = 0 ): AgentJournalThreadGoal | null | undefined { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] - if (item && isGoalTransition(item)) { + if (item && item.sequence > minimumSequence && isGoalTransition(item)) { return goalFromRow(item.body) } } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index bfcd45c7cf34..236b15c66de3 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -474,11 +474,11 @@ export type AgentSessionOptionsResult = { * `agentSession.threadGoal` never offers the controls. `current` is the * latest goal the whole journal records, for a client whose loaded page * starts after it. */ - threadGoal?: { current: AgentJournalThreadGoal | null } + threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } /** Present only where this session writes context facts to its turn rows. * `current` is the newest of each part the whole journal records, for a * client whose loaded page starts after the row that carries it. */ - contextUsage?: { current: AgentSessionContextUsage } + contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } models: AgentSessionModelOption[] /** Session/account/transport support. Absent means unknown, never unsupported. */ fastModeSupport?: AgentSessionFastModeSupport diff --git a/src/shared/native-chat-context-usage.ts b/src/shared/native-chat-context-usage.ts index 1bb23582b838..bbb41a8c9dfb 100644 --- a/src/shared/native-chat-context-usage.ts +++ b/src/shared/native-chat-context-usage.ts @@ -4,6 +4,7 @@ import { contextTokensFromUsage } from './agent-session-context-usage' import type { NativeChatMessage } from './native-chat-types' +import { isNativeChatContextClear } from './agent-session-context-clear' export type NativeChatContextUsage = { usedTokens: number @@ -32,7 +33,7 @@ export function deriveNativeChatContextUsage( ): NativeChatContextUsage | null { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]! - if (isNativeChatCompactionBoundary(message)) { + if (isNativeChatCompactionBoundary(message) || isNativeChatContextClear(message)) { return null } if (message.role !== 'assistant' || !message.usage) { diff --git a/src/shared/native-chat-provider-retry-runs.ts b/src/shared/native-chat-provider-retry-runs.ts index 54ce2deee15e..347b95d56a3b 100644 --- a/src/shared/native-chat-provider-retry-runs.ts +++ b/src/shared/native-chat-provider-retry-runs.ts @@ -3,6 +3,7 @@ import { agentJournalItemSubagentId } from './agent-session-journal-producer' import type { NativeChatMessage } from './native-chat-types' +import { isNativeChatContextClear } from './agent-session-context-clear' function isProviderRetryRow(message: NativeChatMessage): boolean { const block = message.blocks.length === 1 ? message.blocks[0] : undefined @@ -25,6 +26,9 @@ export function collapseProviderRetryRuns( const openRuns = new Map() const drawn: (NativeChatMessage | null)[] = [] for (const message of messages) { + if (isNativeChatContextClear(message)) { + openRuns.clear() + } if (!isProviderRetryRow(message)) { if (openRuns.size > 0) { openRuns.delete(agentJournalItemSubagentId(message)) diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 40487d018133..730a6af0032d 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -1,3 +1,4 @@ +import type { AgentSessionProviderContextBoundary } from './agent-session-provider-context' // ─── Native chat conversation model (cross-process, IPC-serializable) ──────── // The single renderer-facing conversation contract for the native chat view. // Assembled from layered sources in priority order: on-disk JSONL transcripts, @@ -50,6 +51,7 @@ export type NativeChatTextBlock = { text: string /** Optional journal display hints; readers narrow only the values they know. */ presentation?: string + contextClear?: AgentSessionProviderContextBoundary tone?: string /** Optional structured detail for an otherwise ordinary fallback line. */ providerFrame?: { diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 4bdea4f68bc0..8ef8bff69a3a 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -18,7 +18,7 @@ const EFFORT_COMMAND: SlashCommandSuggestion = { } const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [ - { name: 'clear', description: 'Start a fresh conversation' }, + { name: 'clear', description: 'Clear conversation context' }, { name: 'compact', description: 'Compact conversation context' } ] diff --git a/src/shared/structured-agent-session-context-usage.ts b/src/shared/structured-agent-session-context-usage.ts index f18771242d03..6e7bb391ae6d 100644 --- a/src/shared/structured-agent-session-context-usage.ts +++ b/src/shared/structured-agent-session-context-usage.ts @@ -12,6 +12,10 @@ import { } from './agent-session-context-usage' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' +import { + isAgentSessionContextClear, + latestAgentSessionContextClearSequence +} from './agent-session-context-clear' export type StructuredAgentContextUsage = { usedTokens: number @@ -28,11 +32,24 @@ type ContextFactSource = Pick /** The newest of each part these rows carry, in any order; a part none of them carries is absent. */ export function latestStructuredAgentContextFacts( - items: Iterable + items: Iterable, + minimumSequence = 0 ): AgentSessionContextUsage { let used: { sequence: number; fact: AgentSessionContextUsed } | null = null let window: { sequence: number; fact: AgentSessionContextWindow } | null = null for (const item of items) { + if (isAgentSessionContextClear(item.body) && item.sequence > minimumSequence) { + minimumSequence = item.sequence + if (used && used.sequence <= minimumSequence) { + used = null + } + if (window && window.sequence <= minimumSequence) { + window = null + } + } + if (item.sequence <= minimumSequence) { + continue + } const facts = readAgentJournalTurn(item.body)?.contextUsage if (facts?.used && (used === null || item.sequence >= used.sequence)) { used = { sequence: item.sequence, fact: facts.used } @@ -52,10 +69,16 @@ export function latestStructuredAgentContextFacts( */ export function selectStructuredAgentContextUsage( items: readonly AgentJournalRenderItem[], - wholeJournal?: AgentSessionContextUsage + wholeJournal?: AgentSessionContextUsage, + minimumSequence = 0 ): StructuredAgentContextUsage | null { - const loaded = latestStructuredAgentContextFacts(items) - return summarizeStructuredAgentContextFacts({ ...wholeJournal, ...loaded }) + const clearedSequence = latestAgentSessionContextClearSequence(items) + const loaded = latestStructuredAgentContextFacts( + items, + Math.max(minimumSequence, clearedSequence) + ) + const fallback = clearedSequence > minimumSequence ? undefined : wholeJournal + return summarizeStructuredAgentContextFacts({ ...fallback, ...loaded }) } function summarizeStructuredAgentContextFacts({ diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 44bc6be69bca..a716b8f4a131 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -26,6 +26,7 @@ import { import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentSessionLatestTurn, AgentSessionSubscribeEvent } from './agent-session-wire' import { readAgentJournalTurn } from './agent-session-turn-record' +import { isAgentSessionContextClear } from './agent-session-context-clear' import type { NativeChatToolCallBlock } from './native-chat-types' import { isRunningStructuredAgentSessionToolAction, @@ -38,6 +39,9 @@ export function activeStructuredAgentSessionTurnId( items: readonly AgentJournalRenderItem[] ): string | null { for (let index = items.length - 1; index >= 0; index -= 1) { + if (isAgentSessionContextClear(items[index]?.body)) { + return null + } const turn = readAgentJournalTurn(items[index]?.body) if (turn) { return turn.state === 'running' ? turn.turnId : null @@ -58,6 +62,11 @@ export function newestStructuredAgentSessionTurnBySequence( if (item.sequence < newestSequence) { continue } + if (isAgentSessionContextClear(item.body)) { + newestSequence = item.sequence + newest = null + continue + } const turn = readAgentJournalTurn(item.body) if (turn) { newestSequence = item.sequence @@ -74,7 +83,10 @@ export function liveStructuredAgentSessionTurnScope( ): AgentJournalTurnScope { let newest: AgentJournalRenderItem | null = null for (const item of items) { - if ((newest === null || item.sequence >= newest.sequence) && readAgentJournalTurn(item.body)) { + if ( + (newest === null || item.sequence >= newest.sequence) && + (readAgentJournalTurn(item.body) || isAgentSessionContextClear(item.body)) + ) { newest = item } } @@ -111,6 +123,9 @@ export function latestStructuredAgentSessionTurn( ): AgentSessionLatestTurn | null { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] + if (isAgentSessionContextClear(item?.body)) { + return null + } const turn = readAgentJournalTurn(item?.body) if (item && turn) { return { itemId: item.itemId, observedAt: item.observedAt, turn } @@ -172,6 +187,9 @@ export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurn for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] const body = item?.body + if (isAgentSessionContextClear(body)) { + return false + } const turn = readAgentJournalTurn(body) if (turn) { return (hostRunning ?? turn.state === 'running') && newestContentIsReasoning === true @@ -212,6 +230,9 @@ export function statusStructuredAgentSessionToolCall( continue } const body = item.body + if (isAgentSessionContextClear(body)) { + return null + } const turn = readAgentJournalTurn(body) if (turn) { const named = turn.state === 'running' ? (running ?? newest) : null diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 041d18e57110..43c3cf1ffee5 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -15,6 +15,10 @@ import { compareNativeChatTranscriptMessages } from './native-chat-transcript-pr import type { NativeChatMessage } from './native-chat-types' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +import { + agentSessionContextSequenceFor, + isAgentSessionContextClear +} from './agent-session-context-clear' /** A message this client sent that the host has not drawn yet: its bubble until the row lands. */ export type StructuredAgentSessionOptimisticMessage = { @@ -50,19 +54,25 @@ export function structuredAgentSessionCommandItemIds( */ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], - commandItemIds: ReadonlySet + commandItemIds: ReadonlySet, + clearSequences: readonly number[] = [] ): Set { // Each body's copies, as positions in submission order. A withdrawn one is no failed copy, so it // supersedes nothing. const copies = new Map() + const scopeKey = (submission: AgentJournalSubmission) => + JSON.stringify([ + agentSessionContextSequenceFor(submission.acceptedSequence ?? 0, clearSequences), + submission.payloadFingerprint + ]) for (const [index, submission] of submissions.entries()) { if (!dispatchWasWithdrawn(submission)) { const copy = { index, submittedAt: submission.submittedAt } - const same = copies.get(submission.payloadFingerprint) + const same = copies.get(scopeKey(submission)) if (same) { same.push(copy) } else { - copies.set(submission.payloadFingerprint, [copy]) + copies.set(scopeKey(submission), [copy]) } } } @@ -80,7 +90,7 @@ export function structuredAgentSessionRejectedShownInPlace( // host re-delivers its own messages under new ids. Only a later copy sent once the rejection // was known counts, so a repeat sent before it is kept. (resolvedAt !== null && - (copies.get(submission.payloadFingerprint) ?? []).some( + (copies.get(scopeKey(submission)) ?? []).some( (copy) => copy.index > index && copy.submittedAt >= resolvedAt )) ) { @@ -121,7 +131,8 @@ export function projectStructuredAgentSessionMessages( const inPlace = options.rejectedInPlace ? structuredAgentSessionRejectedShownInPlace( submissions, - structuredAgentSessionCommandItemIds(items) + structuredAgentSessionCommandItemIds(items), + items.filter((item) => isAgentSessionContextClear(item.body)).map((item) => item.sequence) ) : new Set() const visibleItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-owed-work.ts b/src/shared/structured-agent-session-owed-work.ts index 324c5160ed0e..fe7c9695ae73 100644 --- a/src/shared/structured-agent-session-owed-work.ts +++ b/src/shared/structured-agent-session-owed-work.ts @@ -1,6 +1,7 @@ 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 { agentSessionCurrentContextRows } from './agent-session-context-clear' /** A running turn or an unanswered send: what a `working` status means, and what an `attention` * status hides beneath its pending prompt. The main-agent rule, read from the journal's items. */ @@ -9,9 +10,10 @@ export function owesStructuredAgentSessionWork( submissions: readonly AgentJournalSubmission[], currentFence?: number | null ): boolean { + const current = agentSessionCurrentContextRows(items, submissions) return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(items), - submissions, + activeStructuredAgentSessionTurnId(current.items), + current.submissions, currentFence ) } diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index dd5737d1c54e..d5b08c833665 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 { agentSessionCurrentContextRows } from './agent-session-context-clear' // Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one // import site. @@ -222,6 +223,7 @@ export function projectStructuredAgentSessionStatus( submissions: readonly AgentJournalSubmission[] = [], currentFence?: number | null ): StructuredAgentSessionProjectedStatus { + ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions)) if (items.some(isPendingStructuredAgentSessionPrompt)) { return 'attention' } @@ -273,6 +275,7 @@ export function projectStructuredAgentSessionStatusState( /** Item ids of the approvals and questions waiting on the user: what makes the status `attention`. */ pendingPromptIds: string[] } { + const latestPrompt = latestStructuredAgentSessionPrompt(items) if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) { return { summary: { status: null, latestPrompt: '' }, @@ -281,6 +284,7 @@ export function projectStructuredAgentSessionStatusState( pendingPromptIds: [] } } + ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions)) const status = projectStructuredAgentSessionStatus(items, submissions, currentFence) const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null const toolName = statusToolCall @@ -318,7 +322,7 @@ export function projectStructuredAgentSessionStatusState( : [], summary: { status, - latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), + latestPrompt: normalizePromptField(latestPrompt), ...(toolName ? { toolName } : {}), ...(toolInput ? { toolInput } : {}), ...(lastAssistantMessage ? { lastAssistantMessage } : {}), diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts index de7bad597e1b..222420d217f4 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -14,6 +14,7 @@ export function structuredAgentSessionStatusBlock( type: 'text', text: body.text, ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(body.contextClear !== undefined ? { contextClear: body.contextClear } : {}), ...(body.tone !== undefined ? { tone: body.tone } : {}), ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), ...(failure ? { failure } : {}), diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts new file mode 100644 index 000000000000..d43214047c6b --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../src/shared/structured-agent-session-reducer' +import type { useNativeChatRewind } from '../../../src/renderer/src/components/native-chat/use-native-chat-rewind' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +type RewindInput = Parameters[0] +type OldRewindModule = Record & { + useNativeChatRewind: typeof useNativeChatRewind +} +function isOldRewindModule(module: Record): module is OldRewindModule { + return typeof module.useNativeChatRewind === 'function' +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + toastError.mockReset() +}) + +test('an older desktop releases its clear hold from the actual same-conversation result while its pane stays mounted', async () => { + const checkout = await materializeReleaseCheckout('b6b4d68cd844c921c7c5191cef72d5adafc0dafa') + const [commands, pending] = await Promise.all([ + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts' + ), + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts' + ) + ]) + const useCommand = commands.useStructuredAgentSessionCommandWrite + const held = pending.structuredAgentSessionSendsHeld + if (typeof useCommand !== 'function' || typeof held !== 'function') { + throw new Error('historical clear hold missing') + } + const rig = await createQueuedMessageTestRig({ restartable: true }) + try { + const before = await rig.host.journalSnapshot(SESSION) + const view = renderHook(() => + useCommand(SESSION, async () => { + const fields = { command: 'clear' as const } + const result = await rig.host.conversationCommand(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + }) + if (!result.ok) { + throw new Error('clear failed') + } + expect(result.value.replacementSessionId).toBeUndefined() + return { kind: 'done', value: result.value } + }) + ) + let request: Promise | undefined + act(() => { + request = view.result.current('clear') + }) + expect(held(SESSION)).toBe(true) + await act(async () => { + expect(await request).toMatchObject({ + kind: 'done', + value: { command: 'clear', state: 'completed' } + }) + }) + expect(held(SESSION)).toBe(false) + const after = await rig.host.journalSnapshot(SESSION) + expect(after.cursor.epoch).toBe(before.cursor.epoch) + expect(after.items.slice(0, before.items.length)).toEqual(before.items) + expect(after.items.at(-1)?.body).toMatchObject({ presentation: 'context-cleared' }) + } finally { + cleanup() + await rig.dispose() + } +}, 300_000) + +test('an older rewind-capable desktop releases its temporary same-epoch hold without losing history or its returned draft', async () => { + const checkout = await materializeReleaseCheckout('b6b4d68cd844c921c7c5191cef72d5adafc0dafa') + vi.doMock( + `${checkout.root}/src/renderer/src/runtime/structured-agent-session-host-capability.ts`, + () => ({ + useStructuredAgentSessionHostRecoversRewindOnSend: () => true + }) + ) + const [module, drafts] = await Promise.all([ + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/use-native-chat-rewind.ts' + ), + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/native-chat-draft-cache.ts' + ) + ]) + if (!isOldRewindModule(module) || typeof drafts.readNativeChatDraftCache !== 'function') { + throw new Error('historical rewind hook or draft reader missing') + } + const rig = await createQueuedMessageTestRig({ + restartable: true, + rewind: async () => ({ ok: true }) + }) + try { + async function send(text: string, turn: string) { + const sent = rig.send(text) + expect(await sent.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(sent.id, turn) + return agentJournalSubmissionKey(sent.id) + } + await send('earlier context remains visible', 'old') + const clear = { command: 'clear' as const } + expect( + await rig.host.conversationCommand(CALLER, { + ...clear, + envelope: rig.envelope(clear, 'agentSession.conversationCommand', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + await send('keep this current-context message', 'kept') + const target = await send('return this message to the draft', 'target') + const before = await rig.host.journalSnapshot(SESSION) + const fields = { itemId: target, expectedEpoch: before.cursor.epoch } + const result = await rig.host.rewind(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId()) + }) + expect(result).toMatchObject({ ok: true }) + if (!result.ok) { + throw new Error('suffix rewind failed') + } + const after = await rig.host.journalSnapshot(SESSION) + expect(after.cursor.epoch).toBe(before.cursor.epoch) + expect(result.value.sequence).toBe(after.cursor.sequence) + expect(after.items.map((item) => item.body)).toEqual( + before.items.filter((item) => item.itemId !== target).map((item) => item.body) + ) + const floor = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.context.floor()! + expect(after.items.filter((item) => item.sequence <= floor.sequence)).toEqual( + before.items.filter((item) => item.sequence <= floor.sequence) + ) + + vi.useFakeTimers() + const input: RewindInput = { + sessionId: SESSION, + composerScopeKey: 'old-rewind-clear-compat', + state: { + ...EMPTY_STRUCTURED_AGENT_SESSION, + status: 'ready', + epoch: before.cursor.epoch, + cursor: before.cursor, + fence: result.fence, + items: before.items, + submissions: before.submissions + }, + support: { supported: true }, + blocked: false, + send: async () => ({ kind: 'done', value: result.value }) + } + const view = renderHook((props: RewindInput) => module.useNativeChatRewind(props), { + initialProps: input + }) + await act(() => view.result.current.request(target, async () => true)) + view.rerender({ ...input, state: { ...input.state, cursor: after.cursor, items: after.items } }) + expect(view.result.current.pending).toBe(true) + expect(view.result.current.blockedRef.current).toBe(true) + expect(drafts.readNativeChatDraftCache(input.composerScopeKey)).toContain( + 'return this message to the draft' + ) + act(() => vi.advanceTimersByTime(119_999)) + expect(view.result.current.blockedRef.current).toBe(true) + act(() => vi.advanceTimersByTime(1)) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + expect(toastError).toHaveBeenCalledOnce() + expect(await rig.host.journalSnapshot(SESSION)).toEqual(after) + } finally { + cleanup() + vi.useRealTimers() + await rig.dispose() + } +}, 300_000) diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts new file mode 100644 index 000000000000..7151b9bcefee --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' +import { + HOST_TEST_SESSION, + HOST_TEST_NOW +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + WORKTREE, + createChat, + clear, + snapshot +} from '../../../src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture' + +describe('historical clients after a clear', () => { + it.each(['v1.4.222', 'b6b4d68cd844c921c7c5191cef72d5adafc0dafa'])( + '%s mirrors the actual published tab without changing pane or conversation identity', + async (ref) => { + const checkout = await materializeReleaseCheckout(ref) + vi.doMock(`${checkout.root}/src/renderer/src/runtime/web-runtime-session.ts`, () => ({ + HOST_TERMINAL_SURFACE_SEPARATOR: '::', + WEB_TERMINAL_SURFACE_TAB_PREFIX: 'web-terminal:', + toWebTerminalSurfaceTabId: (id: string) => id + })) + vi.doMock(`${checkout.root}/src/renderer/src/runtime/runtime-terminal-stream.ts`, () => ({ + getRemoteRuntimePtyEnvironmentId: () => null + })) + const module = await importReleaseCheckoutModule( + checkout, + 'src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts' + ) + const build = module.buildMirroredAgentTabs + if (typeof build !== 'function') { + throw new Error('historical agent tab mirror missing') + } + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) + const localTabs = [ + { + id: 'existing-local-pane', + entityId: HOST_TEST_SESSION, + contentType: 'agent-session', + agentSessionAgent: 'codex', + worktreeId: WORKTREE, + groupId: 'existing-local-group', + label: 'Codex Chat', + customLabel: 'My conversation', + color: null, + createdAt: HOST_TEST_NOW, + sortOrder: 0, + isPinned: true + } + ] + const mirror = async () => + build(await snapshot(), 'remote-host', new Map(), 'fallback', 0, localTabs, HOST_TEST_NOW) + const before = await mirror() + await clear(HOST_TEST_SESSION) + expect(await mirror()).toEqual(before) + expect(await mirror()).toMatchObject([ + { + hostTabId: `agent-session:${HOST_TEST_SESSION}`, + unifiedTab: { + id: 'existing-local-pane', + entityId: HOST_TEST_SESSION, + groupId: 'existing-local-group', + customLabel: 'My conversation', + isPinned: true + } + } + ]) + }, + 300_000 + ) +})