From 1ae7aa8bb4fb725f20310cb9cfa3c3d9686e9dcb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:24:21 -0700 Subject: [PATCH 1/5] feat(native-chat): resume an Agent Session History row into a new structured chat (#19176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): resume an Agent Session History row into a new structured chat A Claude or Codex row in Agent Session History gains "Resume in New Chat": it opens a new structured native-chat tab that continues that provider conversation, with the prior turns already in the journal. Until now those rows could only be resumed into a PTY terminal; the structured branch could reveal a chat Orca already owned but could not adopt one it had never held. Almost all of the machinery existed. Both lanes already resume from the record's provider handle chain, the journal already has a transcript importer, and the handle chain already models `adopted` as an origin. The gap was that a create always minted an empty chain, so the adapters started a fresh conversation. This seeds that chain. The client names only the conversation. `agentSession.create` is reachable by paired mobile clients, so the transcript path and the account home are derived by the executing host and validated against the account homes it recognises — a client-supplied path would choose which file the host imports and which credential directory the provider child launches against. Failure refuses rather than degrades. A transcript that cannot be found refuses before anything is created; one that fails or decodes empty *after* the provider has resumed fails the attach, tearing the child down and publishing no tab, because an empty journal beside a context-carrying agent claims a continuity the provider never gave. Codex can resume into any workspace since it is handed the rollout path; Claude resolves transcripts under a project key derived from the launch cwd, so it is offered only for the workspace the conversation was recorded in. * fix(native-chat): widen adopted-home discovery and keep ordinary launches untouched Three corrections from review of the first commit. The adoption's account-home candidates now include the extra Codex homes session discovery already scans. A row this host listed could otherwise refuse to resume, which reads as the feature being broken rather than as a scope. Ordinary launches call `createStructuredAgentSessionLaunchIntent` with two arguments again. Passing the resume source unconditionally appended a trailing `undefined` that four existing call-site assertions had to absorb; the churn was the caller's fault, not the tests'. The transactional adoption guard's comment claimed the self-exemption is what lets a committed create replay. It is not: replay is settled earlier by the operation ledger, and an adoption always arrives with a null expected fence, so a request naming an existing session id is refused a few lines below either way. The exemption is part of what "another record" means, and the comment now says that instead. * fix: preserve history adoption through create and retries * fix: replay committed history adoption from durable identity * fix: validate history before claiming adopted sessions * fix: extract AI vault resume domains * fix: recognize typed history resume refusals --------- Co-authored-by: Merge Sim --- src/main/ai-vault/cached-session-list.ts | 6 + .../journal-legacy-import.ts | 35 +-- ...tured-agent-session-adopted-import.test.ts | 250 ++++++++++++++++++ ...structured-agent-session-adopted-import.ts | 110 ++++++++ .../structured-agent-session-attach-flow.ts | 11 + .../structured-agent-session-attach.ts | 63 ++++- ...red-agent-session-history-adoption.test.ts | 235 ++++++++++++++++ ...ructured-agent-session-history-adoption.ts | 156 +++++++++++ ...gent-session-reservation-admission.test.ts | 199 ++++++++++++++ .../agent-session-reservation-admission.ts | 53 +++- ...lve-recovered-structured-tui-transcript.ts | 57 +++- ...ured-agent-session-adoption-replay.test.ts | 235 ++++++++++++++++ .../structured-agent-session-create.ts | 11 +- .../structured-agent-session-schemas.ts | 12 +- .../methods/structured-agent-session.test.ts | 40 ++- .../rpc/methods/structured-agent-session.ts | 13 +- ...tructured-agent-session-create-adoption.ts | 113 ++++++++ .../components/right-sidebar/AiVaultPanel.tsx | 20 ++ .../AiVaultSessionActionMenuItems.tsx | 12 + .../right-sidebar/AiVaultSessionDetails.tsx | 36 ++- .../right-sidebar/AiVaultSessionRow.tsx | 5 + .../AiVaultSessionVirtualList.tsx | 207 +-------------- .../right-sidebar/AiVaultVirtualRow.tsx | 212 +++++++++++++++ .../SessionRowTrailingActions.tsx | 4 + .../ai-vault-session-launch-actions.ts | 164 ++++++------ .../ai-vault-session-launch-target.ts | 87 ++++++ ...-vault-session-resume-in-chat-workspace.ts | 64 +++++ .../ai-vault-session-resume-in-chat.test.ts | 170 ++++++++++++ .../ai-vault-session-resume-in-chat.ts | 100 +++++++ .../ai-vault-session-resume.test.ts | 2 +- src/renderer/src/i18n/locales/en.json | 8 +- .../src/lib/agent-launch-routing.test.ts | 28 +- src/renderer/src/lib/agent-launch-routing.ts | 32 ++- .../lib/launch-structured-agent-session.ts | 7 +- ...structured-agent-session-launch-callers.ts | 4 + ...ent-session-launch-resume-identity.test.ts | 157 +++++++++++ .../lib/structured-agent-session-launch.ts | 38 ++- .../agent-session-provider-handle.test.ts | 91 +++++++ src/shared/protocol-version.ts | 7 + .../structured-agent-session-create.test.ts | 82 ++++++ src/shared/structured-agent-session-create.ts | 21 +- .../structured-agent-session-mutation.ts | 6 +- 42 files changed, 2827 insertions(+), 336 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts create mode 100644 src/main/native-chat/structured-agent-session-history-adoption.test.ts create mode 100644 src/main/native-chat/structured-agent-session-history-adoption.ts create mode 100644 src/main/runtime/agent-session-reservation-admission.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts create mode 100644 src/main/runtime/structured-agent-session-create-adoption.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts create mode 100644 src/shared/structured-agent-session-create.test.ts diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index c8d04205091..c46e4bdd4a6 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -49,6 +49,12 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi sources = next } +/** The extra Codex homes session discovery scans. Anything that decides what a listed row may be + * resumed from must read the same set, or a row can be listed and then refuse to resume. */ +export function configuredAdditionalCodexHomePaths(): readonly string[] { + return sources.getAdditionalCodexHomePaths?.() ?? [] +} + export async function listAiVaultSessions( args?: AiVaultListArgs, options: { signal?: AbortSignal } = {} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 6a2ff099dbc..0907ebd28f2 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -90,6 +90,24 @@ export async function importLegacyTranscriptIntoJournal(input: { fence: number options?: LegacyImportOptions }): Promise { + const prepared = await prepareLegacyTranscriptImport(input) + if (!prepared.ok) { + return prepared + } + // An empty import must preserve any existing repair anchor and disclosure. + if (prepared.items.length === 0) { + const current = input.journal.cursor() + return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false } + } + const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, prepared.items) + return { ok: true, epoch: cursor.epoch, cursor, imported: prepared.items.length, replaced: true } +} + +export async function prepareLegacyTranscriptImport(input: { + agent: AgentType + sessionId: string + options?: LegacyImportOptions +}): Promise<{ ok: true; items: JournalReplacementItem[] } | { ok: false; error: string }> { const options = input.options ?? {} const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) @@ -145,22 +163,7 @@ export async function importLegacyTranscriptIntoJournal(input: { observedAt: message.timestamp ?? undefined }) } - // A transcript that decodes to nothing reconstructs nothing, and an empty - // replacement is not a harmless no-op: it would delete the repair's anchor and - // its disclosure, leaving nothing to ask for the history again. The epoch - // stands so a later read can still rebuild it. - if (replacement.length === 0) { - const current = input.journal.cursor() - return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false } - } - const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement) - return { - ok: true, - epoch: cursor.epoch, - cursor, - imported: decoded.messages.length, - replaced: true - } + return { ok: true, items: replacement } } const TRANSCRIPT_DECODERS = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts new file mode 100644 index 00000000000..0248799980a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -0,0 +1,250 @@ +// Source validation must finish before a new session claims the provider conversation. + +import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' +import * as legacyImport from '../agent-session-journal/journal-legacy-import' + +const NOW = 1_800_000_000_000 +const SESSION = 'codex_adopting_session' +const THREAD = 'adopted-thread' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null +let store: AgentSessionRecordStore | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null + store = null + vi.restoreAllMocks() +}) + +/** A minimal Codex rollout the legacy transcript decoder can read back. */ +async function writeCodexRollout(path: string, text: string): Promise { + const lines = [ + JSON.stringify({ + type: 'session_meta', + payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' } + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { + type: 'message', + role: 'user', + content: text + } + }) + ] + await writeFile(path, `${lines.join('\n')}\n`, 'utf8') +} + +function attachParams(transcriptPath?: string): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + adopt: { + providerHandle: { kind: 'codex', threadId: THREAD }, + ...(transcriptPath ? { transcriptPath } : {}) + } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: 'resumed-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + })), + // Proven released, so the failure rethrows its own cause rather than an unproven-exit wrapper. + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +async function attach( + transcriptPath: string | undefined, + sessionAdapter: StructuredAgentSessionAdapter, + onAttached: AttachFlowInput['onAttached'] = () => {} +) { + store ??= await AgentSessionRecordStore.open({ directory: join(root!, 'store'), hostId: 'local' }) + return performAttach({ + store, + adapter: sessionAdapter, + journalRoot: root!, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(transcriptPath), + now: () => NOW, + onAttached + }) +} + +describe('adopting a provider conversation on create', () => { + it('seeds the chain from the adopted handle and fills the journal from its transcript', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-import-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-1') + const sessionAdapter = adapter() + + const result = await attach(transcriptPath, sessionAdapter) + + expect(result).toMatchObject({ ok: true }) + // The adapter was asked to resume, not to start: the seeded chain is what tells it which + // conversation this session owns. + const page = (result as { value: { page: { items: unknown[] } } }).value.page + expect(JSON.stringify(page.items)).toContain('ORCA-ADOPT-1') + }) + + it('replays create without replacing journal-only messages or rereading the source', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-replay-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'original turn') + const sessionAdapter = adapter() + const first = await attach(transcriptPath, sessionAdapter, async ({ journal }) => { + await journal.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, + { fence: 1 } + ) + await journal.close() + }) + expect(first.ok).toBe(true) + await rm(transcriptPath) + const replay = await attach(transcriptPath, sessionAdapter, async ({ journal }) => + journal.close() + ) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (!first.ok || !replay.ok) { + throw new Error('attach failed') + } + expect(replay.cursor.epoch).toBe(first.cursor.epoch) + expect(JSON.stringify(replay.value.page.items)).toContain('not yet in rollout') + expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + }) + + it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)( + 'refuses %s source before claiming a conversation', + async (kind) => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-preflight-')) + const transcriptPath = join(root, 'rollout.jsonl') + if (kind === 'oversized') { + await writeCodexRollout(transcriptPath, 'original turn') + await truncate(transcriptPath, 16 * 1024 * 1024 + 1) + } else if (kind === 'empty' || kind === 'invalid') { + await writeFile(transcriptPath, kind === 'empty' ? '' : 'not json\n') + } + const sessionAdapter = adapter() + const onAttached = vi.fn() + const result = await attach( + kind === 'source-less' ? undefined : transcriptPath, + sessionAdapter, + onAttached + ) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_identity_required' } + }) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + expect(sessionAdapter.releaseAcquisition).not.toHaveBeenCalled() + expect(onAttached).not.toHaveBeenCalled() + expect(store?.getRecord(SESSION)).toBeNull() + expect(store?.listOperationRows()).toEqual([]) + if (kind === 'oversized') { + expect(JSON.stringify(result)).toContain('import bound') + } + } + ) + + it('still releases acquisition and closes the provisional journal on an import write failure', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'valid source') + vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( + new Error('disk write failed') + ) + const close = vi.spyOn(agentSessionJournalCloseRetries, 'closeOrRetain') + const sessionAdapter = adapter() + await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') + expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('prepares a valid source once before acquisition and imports those exact items', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'prepared before acquiring') + const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport') + const sessionAdapter = adapter() + const acquire = sessionAdapter.acquire + sessionAdapter.acquire = vi.fn(async (input) => { + expect(prepare).toHaveBeenCalledTimes(1) + await rm(transcriptPath) + return acquire(input) + }) + const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) => + journal.close() + ) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error('attach failed') + } + expect(JSON.stringify(result.value.page.items)).toContain('prepared before acquiring') + expect(prepare).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts new file mode 100644 index 00000000000..459d21b1f3b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -0,0 +1,110 @@ +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' +import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' +import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' +import { + importLegacyTranscriptIntoJournal, + prepareLegacyTranscriptImport +} from '../agent-session-journal/journal-legacy-import' + +export async function prepareAdoptedTranscript( + params: AgentSessionAttachParams +): Promise< + | { ok: true; items: JournalReplacementItem[] | null } + | { ok: false; refusal: AgentSessionWireRefusal } +> { + try { + return { ok: true, items: await readAdoptedTranscript(params) } + } catch (error) { + return { + ok: false, + refusal: { + code: 'agent_session_identity_required', + message: error instanceof Error ? error.message : String(error) + } + } + } +} + +// Validate source input before a new record can claim the provider conversation. +async function readAdoptedTranscript( + params: AgentSessionAttachParams +): Promise { + const adopt = params.adopt + if (!adopt) { + return null + } + if (!adopt.transcriptPath) { + throw new Error('agent_session_identity_required') + } + const prepared = await prepareLegacyTranscriptImport({ + agent: params.agent, + sessionId: + adopt.providerHandle.kind === 'claude' + ? adopt.providerHandle.sessionId + : adopt.providerHandle.threadId, + options: { filePath: adopt.transcriptPath } + }) + if (!prepared.ok) { + throw new Error(prepared.error) + } + if (prepared.items.length === 0) { + throw new Error('agent_session_identity_required') + } + return prepared.items +} + +// Import before publication so the first visible chat agrees with the provider's resumed context. +export async function importAdoptedTranscript( + params: AgentSessionAttachParams, + attached: AttachedJournal, + record: AgentSessionRecord, + prepared: JournalReplacementItem[] | null +): Promise { + try { + await applyAdoptedTranscript(params, attached, record, prepared) + } catch (error) { + // Publication has not taken ownership of this provisional journal yet. + await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) + throw error + } +} + +async function applyAdoptedTranscript( + params: AgentSessionAttachParams, + attached: AttachedJournal, + record: AgentSessionRecord, + prepared: JournalReplacementItem[] | null +): Promise { + const adopt = params.adopt + // A new journal contains only its epoch row; replay must preserve subsequent durable writes. + if (!adopt || attached.journal.cursor().sequence > 1) { + return + } + if (prepared) { + await attached.journal.replaceEpochItems('legacy_import', record.lease.runtimeFence, prepared) + return + } + if (!adopt.transcriptPath) { + throw new Error('agent_session_identity_required') + } + const imported = await importLegacyTranscriptIntoJournal({ + journal: attached.journal, + agent: params.agent, + sessionId: + adopt.providerHandle.kind === 'claude' + ? adopt.providerHandle.sessionId + : adopt.providerHandle.threadId, + fence: record.lease.runtimeFence, + options: { filePath: adopt.transcriptPath } + }) + if (!imported.ok) { + throw new Error(imported.error) + } + // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and + // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. + if (!imported.replaced) { + throw new Error('agent_session_identity_required') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index b08a56ea4d9..8697e76ba3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -36,6 +36,10 @@ import type { StructuredAgentSessionEventSink } from './structured-agent-session import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { + importAdoptedTranscript, + prepareAdoptedTranscript +} from './structured-agent-session-adopted-import' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -78,6 +82,12 @@ export async function performAttach( let acquisitionGeneration: string | null = null let reservedRecord: AgentSessionRecord | null = null let replayed = false + const preparedTranscript = store.getRecord(sessionId) + ? { ok: true as const, items: null } + : await prepareAdoptedTranscript(params) + if (!preparedTranscript.ok) { + return preparedTranscript + } try { const reserved = await store.reserveOwner( reserveRequestFor({ @@ -181,6 +191,7 @@ export async function performAttach( journalRoot: input.journalRoot, adapter: input.adapter }) + await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration) await store.recordOperationOutcome({ callerKey: input.callerKey, 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 25bd808fd8b..38b626b2123 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 @@ -10,7 +10,12 @@ import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionHandleProvider, + AgentSessionProviderHandleLink +} from '../../../shared/agent-session-provider-handle' +import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity' +import { codexProviderHandleLink } from '../../codex/codex-structured-owner-identity' import type { AgentSessionAccountHome, AgentSessionExecutionLocation, @@ -59,6 +64,19 @@ export type AgentSessionAttachParams = { launchArgs?: string[] /** Omitted only for create-by-intent; the adapter proves the durable handle. */ providerHandle?: Exclude + /** + * Host-resolved only. Present when this create adopts an existing provider conversation rather + * than starting one: it seeds the handle chain so the adapter resumes instead of creating, and + * names the transcript to import so the journal shows the conversation so far. + * + * Deliberately separate from `providerHandle`, which `agentSession.ensure` already supplies + * without adopting — presence of a handle must never be what triggers a resume. + */ + adopt?: { + providerHandle: Exclude + /** Omitted only when the exact committed operation replays an already-imported journal. */ + transcriptPath?: string + } } /** Host-supplied half of the reservation. */ @@ -85,6 +103,10 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor accountHome: params.accountHome, runtimeKind: params.runtimeKind, providerHandle: params.providerHandle, + // Which conversation this attaches to, so an adopting create and a blank one never share an + // identity. The transcript path is excluded: it is where the host found that conversation this + // time, not part of what the caller asked for. + adoptedProviderHandle: params.adopt?.providerHandle, expectedRuntimeFence: params.envelope.expectedRuntimeFence } } @@ -183,6 +205,38 @@ export async function attachJournal(input: { } } +/** + * The first link of an adopting session's chain. + * + * `adopted` is the only origin besides `created` a chain will accept at its head, and it is the + * honest one here: this session did not create the conversation. The adapter appends its own + * `resumed` link once the provider proves the same identity root — or, when it proves the identical + * handle at the same fence, the validator elides that as a retry and this link stays the head. + */ +const ADOPTED_HANDLE_FENCE = 1 + +function adoptedProviderHandleLink( + handle: Exclude, + observedAt: number +): AgentSessionProviderHandleLink { + return handle.kind === 'claude' + ? claudeProviderHandleLink({ + sessionId: handle.sessionId, + leafUuid: handle.leafUuid, + resumed: false, + origin: 'adopted', + fence: ADOPTED_HANDLE_FENCE, + observedAt + }) + : codexProviderHandleLink({ + threadId: handle.threadId, + resumed: false, + origin: 'adopted', + fence: ADOPTED_HANDLE_FENCE, + observedAt + }) +} + export function reserveRequestFor(input: { sessionId: string params: AgentSessionAttachParams @@ -201,6 +255,13 @@ export function reserveRequestFor(input: { ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), runtimeKind: params.runtimeKind, + ...(params.adopt + ? { + // Fence 1 is a new record's first, and the owner probe requires the head link to carry + // the record's current fence. + adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, input.now) + } + : {}), expectedFence: params.envelope.expectedRuntimeFence, spawnToken: authority.spawnToken, claimKeyId: authority.claimKeyId, diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts new file mode 100644 index 00000000000..581bb29c364 --- /dev/null +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { + findCommittedStructuredAgentSessionAdoptionReplay, + findConflictingStructuredAdoption, + resolveStructuredAgentSessionAdoption, + structuredAdoptionConflictError, + type StructuredAgentSessionAdoptionOwnership +} from './structured-agent-session-history-adoption' + +const OPERATION = '1800000000000-00000000000000000000000000000001' + +function committedReplay(overrides: { callerKey?: string; operationId?: string } = {}) { + const lease = agentSessionLeaseFixture({ sessionId: 'codex_adopted' }) + return findCommittedStructuredAgentSessionAdoptionReplay({ + agent: 'codex', + providerSessionId: 'thread-1', + selfSessionId: 'codex_adopted', + callerKey: overrides.callerKey ?? 'client-1', + operationId: overrides.operationId ?? OPERATION, + record: { + ...agentSessionRecordFixture(lease), + provider: 'codex', + providerHandleChain: [ + { + linkId: 'codex-1-thread-1', + origin: 'adopted', + mintedAtFence: 1, + observedAt: 1_800_000_000_000, + handle: { provider: 'codex', threadId: 'thread-1' } + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex-original' } + }, + operations: [ + { + callerKey: 'client-1', + operationId: OPERATION, + fingerprint: 'fingerprint-1', + operationTimestamp: 1_800_000_000_000, + recordedAt: 1_800_000_000_000, + expiresAt: 1_900_000_000_000, + outcome: { status: 'succeeded', sessionId: 'codex_adopted' } + } + ] + }) +} + +function ownership( + overrides: Partial = {} +): StructuredAgentSessionAdoptionOwnership { + return { + sessionId: 'codex_owner', + provider: 'codex', + providerSessionId: 'thread-1', + lease: agentSessionLeaseFixture(), + ...overrides + } +} + +describe('findConflictingStructuredAdoption', () => { + it('names the session that already holds the conversation', () => { + const owner = ownership() + + expect( + findConflictingStructuredAdoption({ + agent: 'codex', + providerSessionId: 'thread-1', + selfSessionId: 'codex_new', + ownership: [ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), owner] + }) + ).toBe(owner) + }) + + it('exempts the requesting session, so a committed create replays instead of refusing', () => { + expect( + findConflictingStructuredAdoption({ + agent: 'codex', + providerSessionId: 'thread-1', + selfSessionId: 'codex_new', + ownership: [ownership({ sessionId: 'codex_new' })] + }) + ).toBeNull() + }) + + it('ignores an identical id held under the other provider', () => { + expect( + findConflictingStructuredAdoption({ + agent: 'claude', + providerSessionId: 'thread-1', + selfSessionId: 'claude_new', + ownership: [ownership({ provider: 'codex' })] + }) + ).toBeNull() + }) + + it('finds nothing when no session holds the conversation', () => { + expect( + findConflictingStructuredAdoption({ + agent: 'codex', + providerSessionId: 'thread-unheld', + selfSessionId: 'codex_new', + ownership: [ownership()] + }) + ).toBeNull() + }) +}) + +describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { + it('returns the record-pinned account and adopted handle for the exact committed operation', () => { + expect(committedReplay()).toMatchObject({ + record: { accountHome: { path: '/home/dev/.codex-original' } }, + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }) + }) + + it('does not cross caller or operation namespaces', () => { + expect(committedReplay({ callerKey: 'client-2' })).toBeNull() + expect(committedReplay({ operationId: `${OPERATION}-other` })).toBeNull() + }) + + it('preserves the adopted Claude leaf that participated in the attach fingerprint', () => { + const lease = agentSessionLeaseFixture({ sessionId: 'claude_adopted' }) + const record = agentSessionRecordFixture(lease) + record.providerHandleChain[0] = { + ...record.providerHandleChain[0]!, + origin: 'adopted', + handle: { + provider: 'claude', + sessionId: 'provider-session-alpha-1', + leafUuid: 'leaf-1' + } + } + + expect( + findCommittedStructuredAgentSessionAdoptionReplay({ + agent: 'claude', + providerSessionId: 'provider-session-alpha-1', + selfSessionId: 'claude_adopted', + callerKey: 'client-1', + operationId: OPERATION, + record, + operations: [ + { + callerKey: 'client-1', + operationId: OPERATION, + fingerprint: 'fingerprint-1', + operationTimestamp: 1_800_000_000_000, + recordedAt: 1_800_000_000_000, + expiresAt: 1_900_000_000_000, + outcome: { status: 'succeeded', sessionId: 'claude_adopted' } + } + ] + }) + ).toMatchObject({ + providerHandle: { + kind: 'claude', + sessionId: 'provider-session-alpha-1', + leafUuid: 'leaf-1' + } + }) + }) +}) + +describe('structuredAdoptionConflictError', () => { + it('calls a conversation with an admitted writer a conflict', () => { + expect(structuredAdoptionConflictError(ownership()).message).toBe('agent_session_conflict') + }) + + it.each([ + ['a reservation with no process yet', { ownerProcess: null, claimStatus: 'reserved' as const }], + ['a lease mid-handoff', { handoffStage: 'new-owner-proving' as const }], + ['an unreconciled lease', { unreconciled: true }] + ])('calls %s an unknown owner rather than a conflict', (_label, leaseOverrides) => { + // Neither verdict admits a second writer; they differ only in what the user is told. + expect( + structuredAdoptionConflictError( + ownership({ lease: agentSessionLeaseFixture(leaseOverrides) }) + ).message + ).toBe('agent_session_ownership_unknown') + }) +}) + +describe('resolveStructuredAgentSessionAdoption', () => { + it('takes the first candidate home that holds the transcript and probes no further', async () => { + const resolveTranscript = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/home/dev/.codex/sessions/thread-1.jsonl') + + await expect( + resolveStructuredAgentSessionAdoption({ + agent: 'codex', + providerSessionId: 'thread-1', + candidateAccountHomes: ['/home/dev/.orca-codex', '/home/dev/.codex', '/never/probed'], + resolveTranscript + }) + ).resolves.toEqual({ + accountHomePath: '/home/dev/.codex', + transcriptPath: '/home/dev/.codex/sessions/thread-1.jsonl' + }) + expect(resolveTranscript).toHaveBeenCalledTimes(2) + }) + + it('skips blank and repeated candidates instead of probing them again', async () => { + const resolveTranscript = vi.fn().mockResolvedValue(null) + + await expect( + resolveStructuredAgentSessionAdoption({ + agent: 'claude', + providerSessionId: 'session-1', + candidateAccountHomes: ['', ' ', '/home/dev/.claude', ' /home/dev/.claude ', ''], + resolveTranscript + }) + ).rejects.toThrow('agent_session_identity_required') + expect(resolveTranscript.mock.calls.map(([args]) => args.accountHomePath)).toEqual([ + '/home/dev/.claude' + ]) + }) + + it('refuses rather than falling back to a home that does not hold the conversation', async () => { + // A resume under the wrong home lands in a blank chat wearing the old chat's name. + await expect( + resolveStructuredAgentSessionAdoption({ + agent: 'claude', + providerSessionId: 'session-1', + candidateAccountHomes: ['/home/dev/.claude-work', '/home/dev/.claude'], + resolveTranscript: async () => null + }) + ).rejects.toThrow('agent_session_identity_required') + }) +}) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts new file mode 100644 index 00000000000..d470735b032 --- /dev/null +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -0,0 +1,156 @@ +// Adopting an Agent Session History row into a brand-new structured chat. +// +// Kept out of the runtime class files because those are `@ts-nocheck`: this decides which +// credential directory a provider child will launch against and which file gets imported into a +// journal, and a call site written there would compile however wrong it was. The runtime hands over +// the facts it owns — the account homes it recognises, the records it holds — and this decides. + +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' + +export type StructuredAgentSessionAdoptionOwnership = { + sessionId: string + provider: 'claude' | 'codex' + providerSessionId: string + lease: AgentSessionLease +} + +export type StructuredAgentSessionAdoption = { + /** The account home the transcript was actually found under — never a client-supplied path. */ + accountHomePath: string + transcriptPath: string +} + +export type CommittedStructuredAgentSessionAdoptionReplay = { + record: AgentSessionRecord + providerHandle: Exclude +} + +/** Exact committed-operation identity; attach still validates its fingerprint. */ +export function findCommittedStructuredAgentSessionAdoptionReplay(input: { + agent: 'claude' | 'codex' + providerSessionId: string + selfSessionId: string + callerKey: string + operationId: string + record: AgentSessionRecord | null + operations: readonly AgentSessionOperationRow[] +}): CommittedStructuredAgentSessionAdoptionReplay | null { + const operation = input.operations.find( + (row) => row.callerKey === input.callerKey && row.operationId === input.operationId + ) + if ( + operation?.outcome.status !== 'succeeded' || + operation.outcome.sessionId !== input.selfSessionId + ) { + return null + } + const record = input.record + const adopted = record?.providerHandleChain[0] + if ( + !record || + record.sessionId !== input.selfSessionId || + record.provider !== input.agent || + adopted?.origin !== 'adopted' + ) { + return null + } + const providerSessionId = + adopted.handle.provider === 'codex' ? adopted.handle.threadId : adopted.handle.sessionId + if (providerSessionId !== input.providerSessionId) { + return null + } + return { + record, + providerHandle: + adopted.handle.provider === 'codex' + ? { kind: 'codex', threadId: adopted.handle.threadId } + : { + kind: 'claude', + sessionId: adopted.handle.sessionId, + leafUuid: adopted.handle.leafUuid + } + } +} + +/** + * A conversation has exactly one writer. Codex takes no lock of its own: a second app-server holding + * the same thread never errors, it loads history once and then diverges, and the rollout ends up + * recording a conversation that never happened. So the refusal is the correctness guard, and it has + * to be able to tell "someone else owns this" from "this very operation owns it". + * + * @param selfSessionId the structured session this create is reserving. A retry of a committed + * create re-runs every pre-commit check, and by then the record it created is itself in the + * ownership index — without this exemption the replay refuses instead of replaying. + */ +export function findConflictingStructuredAdoption(input: { + agent: 'claude' | 'codex' + providerSessionId: string + selfSessionId: string + ownership: readonly StructuredAgentSessionAdoptionOwnership[] +}): StructuredAgentSessionAdoptionOwnership | null { + return ( + input.ownership.find( + (owner) => + owner.sessionId !== input.selfSessionId && + owner.provider === input.agent && + owner.providerSessionId === input.providerSessionId + ) ?? null + ) +} + +/** Mirrors the legacy PTY resume's refusal vocabulary: a conversation with an admitted writer is a + * conflict, one without is an unknown owner. Neither ever admits a second writer. */ +export function structuredAdoptionConflictError( + ownership: StructuredAgentSessionAdoptionOwnership +): Error { + return new Error( + agentSessionLeaseAdmitsWriter(ownership.lease) + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + ) +} + +/** + * Resolve which recognised account home holds this conversation, by finding its transcript. + * + * The client names only the conversation. Everything else is derived here: `agentSession.create` is + * reachable by paired mobile clients, so a client-supplied account home would choose the credential + * directory the provider child launches against, and a client-supplied transcript path would choose + * which file this host reads into a journal. + * + * Candidates are tried in order and the FIRST hit wins, so the caller must order them by preference + * (selected account before the system default). + */ +export async function resolveStructuredAgentSessionAdoption(input: { + agent: 'claude' | 'codex' + providerSessionId: string + candidateAccountHomes: readonly string[] + resolveTranscript: (args: { + agent: 'claude' | 'codex' + providerSessionId: string + accountHomePath: string + }) => Promise +}): Promise { + const seen = new Set() + for (const accountHomePath of input.candidateAccountHomes) { + const trimmed = accountHomePath.trim() + if (!trimmed || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + const transcriptPath = await input.resolveTranscript({ + agent: input.agent, + providerSessionId: input.providerSessionId, + accountHomePath: trimmed + }) + if (transcriptPath) { + return { accountHomePath: trimmed, transcriptPath } + } + } + // Refuse rather than fall back to the default home. Resuming under a home that does not hold the + // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. + throw new Error('agent_session_identity_required') +} diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts new file mode 100644 index 00000000000..80bcb77cfa5 --- /dev/null +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -0,0 +1,199 @@ +// Adoption admission inside the reservation transaction: which conversation a new record may claim. + +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + applyAgentSessionReservation, + type AgentSessionReserveRequest +} from './agent-session-reservation-admission' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +const NOW = 1_800_000_000_000 +const LEASE_TTL_MS = 60_000 + +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +/** The link an adopting create seeds: fence 1, because that is a new record's first. */ +function adoptedLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'claude-1-provider-session-alpha-1-empty', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-adopting', + location: LOCATION, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: 'op-1', fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { + return { + schemaVersion: 2, + hostId: 'local', + records: new Map(records.map((record) => [record.sessionId, record])), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + visibleSessionIds: new Set(), + visibleSessionIdsIndexPresent: true + } +} + +describe('adopted handle chain seeding', () => { + it('seeds a new record with the adopted link alone, at the first fence of the record', () => { + const link = adoptedLink() + const { record, disposition } = applyAgentSessionReservation( + storeState(), + reserveRequest({ adoptedHandleLink: link }), + LEASE_TTL_MS + ) + + expect(disposition).toBe('created') + expect(record.providerHandleChain).toEqual([link]) + // The owner probe requires the head link to carry the record's current fence. + expect(record.providerHandleChain[0]?.mintedAtFence).toBe(record.lease.runtimeFence) + }) + + it('leaves a blank create with no chain, so the adapter starts a conversation', () => { + const { record } = applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS) + + expect(record.providerHandleChain).toEqual([]) + }) +}) + +describe('adopted conversation ownership', () => { + it('refuses when another record already holds the same conversation root', () => { + // The held link names a leaf; the adoption names none. Same root is the whole test: keying on + // the exact handle would let two writers onto one conversation on different branches. + const holder = agentSessionRecordFixture() + + expect(() => + applyAgentSessionReservation( + storeState([holder]), + reserveRequest({ adoptedHandleLink: adoptedLink() }), + LEASE_TTL_MS + ) + ).toThrow('agent_session_conflict') + }) + + it('admits an adoption of a conversation no record holds', () => { + const holder = agentSessionRecordFixture() + + expect(() => + applyAgentSessionReservation( + storeState([holder]), + reserveRequest({ + adoptedHandleLink: adoptedLink({ + handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } + }) + }), + LEASE_TTL_MS + ) + ).not.toThrow() + }) + + it('exempts the requesting session so a committed create can be re-run', () => { + // Pins the guard's own contract. No wire shape reaches it today: `adopt` is accepted only on + // create-by-intent, which always carries a null expected fence, and an existing record with a + // null expected fence is refused a few lines below anyway. + const link = adoptedLink() + const committed: AgentSessionRecord = { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: 'session-adopting', + runtimeFence: 1, + handoffStage: 'new-owner-proving', + claimStatus: 'reserved', + ownerProcess: null, + provenHandleLinkId: null, + handoffOperationId: 'handoff-1' + }) + ), + location: LOCATION, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandleChain: [link] + } + + const { record, disposition } = applyAgentSessionReservation( + storeState([committed]), + reserveRequest({ + adoptedHandleLink: link, + expectedFence: 1, + handoffOperationId: 'handoff-1' + }), + LEASE_TTL_MS + ) + + expect(disposition).toBe('retry-reservation') + expect(record.providerHandleChain).toEqual([link]) + }) + + it('refuses a Codex adoption another record already holds', () => { + const holder: AgentSessionRecord = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-codex' })), + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + providerHandleChain: [ + { + linkId: 'codex-1-thread-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: 7, + observedAt: NOW + } + ] + } + + expect(() => + applyAgentSessionReservation( + storeState([holder]), + reserveRequest({ + sessionId: 'session-codex-adopting', + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + adoptedHandleLink: adoptedLink({ + linkId: 'codex-1-thread-1-adopted', + handle: { provider: 'codex', threadId: 'thread-1' } + }) + }), + LEASE_TTL_MS + ) + ).toThrow('agent_session_conflict') + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index f1be94a2c0e..82176a05297 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -28,7 +28,11 @@ import { type AgentSessionLaunchEnv, type AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { + agentSessionProviderHandleRoot, + type AgentSessionHandleProvider, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' import { reserveAgentSessionOwner, type AgentSessionReservation @@ -46,6 +50,9 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> + /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so + * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ + adoptedHandleLink?: AgentSessionProviderHandleLink runtimeKind: AgentSessionReservation['runtimeKind'] /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ expectedFence: number | null @@ -146,6 +153,11 @@ export function applyAgentSessionReservation( leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs, now: request.now } + // Inside the transaction, not only in the RPC resolver: two concurrent adoptions of one + // conversation mint different session ids, so the compare-and-swap never collides and a + // pre-commit check passes for both. Codex would then hold one thread from two app-servers, which + // it permits silently and which corrupts the conversation rather than erroring. + assertAdoptedConversationUnowned(state, request) const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { @@ -181,6 +193,41 @@ export function applyAgentSessionReservation( }) } +/** + * Refuse an adoption whose conversation ANOTHER record already holds. + * + * The self-exemption is part of that definition, not a replay mechanism: replay is settled earlier + * by the operation ledger, and an adoption always arrives with a null expected fence, so a request + * naming an existing session id is refused a few lines below regardless. Keeping the scan scoped to + * other records is what makes this guard mean what its name says. + * + * It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be + * the guard: two concurrent adoptions of one conversation mint different session ids, so the + * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread + * silently, so the cost of missing this is a corrupted conversation rather than an error. + */ +function assertAdoptedConversationUnowned( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): void { + const adopted = request.adoptedHandleLink + if (!adopted) { + return + } + const root = agentSessionProviderHandleRoot(adopted.handle) + for (const record of state.records.values()) { + if (record.sessionId === request.sessionId) { + continue + } + const holdsSameConversation = record.providerHandleChain.some( + (link) => agentSessionProviderHandleRoot(link.handle) === root + ) + if (holdsSameConversation) { + throw new Error('agent_session_conflict') + } + } +} + function createAgentSessionRecord( request: AgentSessionReserveRequest, reservation: AgentSessionReservation @@ -190,7 +237,9 @@ function createAgentSessionRecord( sessionId: request.sessionId, location: request.location, provider: request.provider, - providerHandleChain: [], + // Fence 1 below is this record's first, and the owner probe requires the head link to carry the + // record's current fence — so an adopted link must be minted at that same fence. + providerHandleChain: request.adoptedHandleLink ? [request.adoptedHandleLink] : [], accountHome: request.accountHome, ...(request.options ? { options: { ...request.options } } : {}), ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 497d5b381e7..37752d207e6 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -7,6 +7,10 @@ import { supportsCodexStructuredLocation } from '../codex/codex-structured-locat import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' +import { + resolveCommittedStructuredAgentSessionAdoptionIntent, + resolveStructuredAgentSessionAdoptionForCreate +} from './structured-agent-session-create-adoption' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -111,6 +115,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca envelope: { sessionId: string; clientOperationId: string } worktree: string agent: 'claude' | 'codex' + callerKey?: string + resumeFrom?: { providerSessionId: string } }): Promise { if (input.agent === 'claude') { return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => { @@ -144,6 +150,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca envelope: { sessionId: string; clientOperationId: string } worktree: string agent: 'claude' | 'codex' + callerKey?: string + resumeFrom?: { providerSessionId: string } }, resolveAccountHomePath: (context: { workspacePath: string @@ -168,6 +176,35 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca ) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + const host = getStructuredAgentSessionHost() + const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ + host, + ...input, + location, + ...(options ? { options } : {}) + }) + if (committedReplay) { + return committedReplay + } + const selectedAccountHomePath = await resolveAccountHomePath({ + workspacePath, + launchEnv, + location + }) + // Adopting pins the account home to wherever the conversation actually lives, which is not + // necessarily the one a fresh create would pick: Codex resolves its rollout under + // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under + // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. + const adoption = input.resumeFrom + ? await resolveStructuredAgentSessionAdoptionForCreate({ + host, + settings, + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + selectedAccountHomePath + }) + : null return { envelope: { sessionId: input.envelope.sessionId, @@ -180,9 +217,27 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca agent: input.agent, accountHome: { variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: await resolveAccountHomePath({ workspacePath, launchEnv, location }) + path: adoption ? adoption.accountHomePath : selectedAccountHomePath }, ...(options ? { options } : {}), + ...(input.resumeFrom && adoption + ? { + // `adopt` is what makes the reservation seed the handle chain. Presence of + // `providerHandle` alone must not: `agentSession.ensure` already passes one today + // without adopting anything. + adopt: { + providerHandle: + input.agent === 'claude' + ? { + kind: 'claude' as const, + sessionId: input.resumeFrom.providerSessionId, + leafUuid: null + } + : { kind: 'codex' as const, threadId: input.resumeFrom.providerSessionId }, + transcriptPath: adoption.transcriptPath + } + } + : {}), runtimeKind: 'native' } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts new file mode 100644 index 00000000000..42fdbc772a0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -0,0 +1,235 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +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 { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const SESSION = 'session-adoption-replay' +const THREAD = 'thread-adoption-replay' +const WORKSPACE = 'workspace-1' +const OPERATION = `${Date.now()}-00000000000000000000000000000001` +const CLIENT = { + clientId: 'device-a', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +let root: string +let host: StructuredAgentSessionHost + +function adapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: () => true, + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_800_000_000_000, + spawnToken + }, + link: { + linkId: `codex-${fence}-${THREAD}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: 1_800_000_000_000 + } + })), + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +function createParams(operationId = OPERATION) { + const fields = { + worktree: `id:${WORKSPACE}`, + agent: 'codex' as const, + resumeFrom: { providerSessionId: THREAD } + } + return { + envelope: { + sessionId: SESSION, + clientOperationId: operationId, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields + }) + }, + ...fields + } +} + +async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT) { + const replies: RpcResponse[] = [] + const request: RpcRequest = { + id: `request-${replies.length + 1}`, + authToken: 'token', + method: 'agentSession.create', + params + } + await dispatcher.dispatchStreaming( + request, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + return replies[0] +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adoption-rpc-replay-')) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await host?.flushAllStreamedEvents() + await host?.close(SESSION) + await rm(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('committed adopting create RPC replay', () => { + it('republishes from durable identity after the source disappears and account selection drifts', async () => { + const originalHome = join(root, 'account-original') + const driftedHome = join(root, 'account-drifted') + const transcriptPath = join( + originalHome, + 'sessions', + '2026', + '09', + '06', + `rollout-2026-09-06T18-00-00-${THREAD}.jsonl` + ) + await mkdir(dirname(transcriptPath), { recursive: true }) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' } + })}\n${JSON.stringify({ + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { type: 'message', role: 'user', content: 'durable adopted history' } + })}\n`, + 'utf8' + ) + + let selectedHome = originalHome + const selectAccountHome = vi.fn(() => selectedHome) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ agentDefaultEnv: { codex: {} } }) + } as never, + undefined, + { prepareCodexStructuredLaunch: selectAccountHome } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: () => Promise<{ + executionHostId: 'local' + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: () => Promise<{ worktree: { path: string } }> + ensureStructuredAgentSessionHost: () => Promise + publishStructuredAgentSessionTab: () => Promise + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + internal.ensureStructuredAgentSessionHost = vi.fn(async () => undefined) + internal.publishStructuredAgentSessionTab = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('simulated lost tab publication')) + .mockResolvedValue(undefined) + + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const sessionAdapter = adapter() + host = new StructuredAgentSessionHost({ + store, + adapter: sessionAdapter, + journalRoot: root, + claimKeyId: 'key-1' + }) + setStructuredAgentSessionHost(host) + const dispatcher = new RpcDispatcher({ + runtime, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) + const params = createParams() + + expect(await call(dispatcher, params)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_operation_unknown' } } + }) + await rm(transcriptPath) + selectedHome = driftedHome + setStructuredAgentSessionHost(null) + internal.ensureStructuredAgentSessionHost = vi.fn(async () => { + setStructuredAgentSessionHost(host) + }) + + expect(await call(dispatcher, params)).toMatchObject({ + ok: true, + result: { + ok: true, + replayed: true, + value: { + page: { + items: expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ text: 'durable adopted history' }) + ]) + }) + }) + ]) + } + } + } + }) + expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) + expect(selectAccountHome).toHaveBeenCalledTimes(1) + + const otherOperation = createParams(`${Date.now()}-00000000000000000000000000000002`) + expect(await call(dispatcher, otherOperation)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_identity_required' } } + }) + expect(await call(dispatcher, params, { ...CLIENT, clientId: 'device-b' })).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_identity_required' } } + }) + expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 75a13ba6af9..b0ce4666861 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -24,6 +24,7 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create' import type { OrcaRuntimeService } from '../../orca-runtime' import { resolveUncommittedStructuredCreate, @@ -46,18 +47,24 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { envelope: AgentSessionMutationEnvelope worktree: string agent: 'claude' | 'codex' + caller: StructuredAgentSessionCaller + resumeFrom?: StructuredAgentSessionResumeSource }): Promise { + // Adoption replay may need the record loaded from disk before source discovery can be skipped. + let host = args.resumeFrom ? await args.ensureHost() : null const resolved = await args.runtime.resolveStructuredAgentSessionCreateIntent({ envelope: args.envelope, worktree: args.worktree, - agent: args.agent + agent: args.agent, + callerKey: args.caller.callerKey, + ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) }) const hostFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId: args.envelope.sessionId, fields: attachFingerprintFields({ ...resolved, envelope: args.envelope }) }) - const host = await args.ensureHost() + host ??= await args.ensureHost() const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved return { host, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 05a066ab184..5ec7a31d80d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -96,11 +96,21 @@ export const AttachParams = z }) .strict() +/** An identity, and nothing the host would otherwise read off disk. A transcript path or account + * home here would let a client choose which file this host imports and which credential directory + * the provider child launches against; both are derived host-side from this id instead. */ +const ResumeSource = z + .object({ + providerSessionId: Identifier('Invalid provider session id') + }) + .strict() + export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']) + agent: z.enum(['claude', 'codex']), + resumeFrom: ResumeSource.optional() }) .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 82f4cf9041f..5a38ae4ce2d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -483,7 +483,10 @@ describe('method routing', () => { } const created = await call('agentSession.create', params, STRUCTURED_CLIENT) expect(created).toMatchObject({ ok: true, result: { ok: true } }) - expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ + ...params, + callerKey: 'trusted-local:runtime' + }) expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -497,6 +500,36 @@ describe('method routing', () => { ) }) + it.each(['claude', 'codex'])( + 'forwards a %s history resume through create preparation', + async (agent) => { + const fields = { + worktree: 'id:workspace-1', + agent, + resumeFrom: { providerSessionId: 'prior-session' } + } + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields + }) + }), + ...fields + } + expect(await call('agentSession.create', params, STRUCTURED_CLIENT)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ + ...params, + callerKey: 'trusted-local:runtime' + }) + } + ) + it('routes Claude create support and create through the provider-aware runtime', async () => { const worktree = 'id:workspace-1' const support = await call( @@ -524,7 +557,10 @@ describe('method routing', () => { } const created = await call('agentSession.create', params, STRUCTURED_CLIENT) expect(created).toMatchObject({ ok: true, result: { ok: true } }) - expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ + ...params, + callerKey: 'trusted-local:runtime' + }) expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 60d02006d3a..f086fa7ed66 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -127,7 +127,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ const intentFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.create', sessionId: params.envelope.sessionId, - fields: { worktree: params.worktree, agent: params.agent } + // `resumeFrom` is part of the intent, not a detail of it: without it here, a retry of + // "adopt this conversation" would replay as, or conflict with, a blank create. The + // canonicalizer drops `undefined`, so plain creates keep the digest they always had. + fields: { + worktree: params.worktree, + agent: params.agent, + resumeFrom: params.resumeFrom + } }) const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint) if (conflict) { @@ -141,7 +148,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ }, envelope: params.envelope, worktree: params.worktree, - agent: params.agent as 'claude' | 'codex' + agent: params.agent as 'claude' | 'codex', + caller: callerFor(ctx), + ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}) }) } const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts new file mode 100644 index 00000000000..8bdac82abbe --- /dev/null +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -0,0 +1,113 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { agentSessionExecutionLocationsEqual } from '../../shared/agent-session-record' +import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { + findCommittedStructuredAgentSessionAdoptionReplay, + findConflictingStructuredAdoption, + resolveStructuredAgentSessionAdoption, + structuredAdoptionConflictError +} from '../native-chat/structured-agent-session-history-adoption' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { configuredAdditionalCodexHomePaths } from '../ai-vault/cached-session-list' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' + +type AdoptionSettings = { + codexManagedAccounts?: readonly { managedHomePath: string }[] +} + +export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { + host: StructuredAgentSessionHost | null + envelope: { sessionId: string; clientOperationId: string } + agent: 'claude' | 'codex' + callerKey?: string + resumeFrom?: { providerSessionId: string } + location: AgentSessionExecutionLocation + options?: Readonly> +}): AgentSessionAttachParams | null { + const replay = + input.resumeFrom && input.callerKey && input.host + ? findCommittedStructuredAgentSessionAdoptionReplay({ + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + callerKey: input.callerKey, + operationId: input.envelope.clientOperationId, + record: input.host.deps.store.getRecord(input.envelope.sessionId), + operations: input.host.deps.store.listOperationRows() + }) + : null + if (!replay || !agentSessionExecutionLocationsEqual(replay.record.location, input.location)) { + return null + } + return { + envelope: { + sessionId: input.envelope.sessionId, + clientOperationId: input.envelope.clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: input.location, + provider: input.agent, + agent: input.agent, + accountHome: replay.record.accountHome, + ...(input.options ? { options: input.options } : {}), + adopt: { providerHandle: replay.providerHandle }, + runtimeKind: replay.record.lease.runtimeKind + } +} + +export async function resolveStructuredAgentSessionAdoptionForCreate(input: { + host: StructuredAgentSessionHost | null + settings: AdoptionSettings + agent: 'claude' | 'codex' + providerSessionId: string + selfSessionId: string + selectedAccountHomePath: string +}) { + const conflict = input.host + ? findConflictingStructuredAdoption({ + agent: input.agent, + providerSessionId: input.providerSessionId, + selfSessionId: input.selfSessionId, + ownership: listStructuredProviderSessionOwnership(input.host.deps.store.listRecords()) + }) + : null + if (conflict) { + throw structuredAdoptionConflictError(conflict) + } + return resolveStructuredAgentSessionAdoption({ + agent: input.agent, + providerSessionId: input.providerSessionId, + candidateAccountHomes: structuredAdoptionAccountHomeCandidates(input), + resolveTranscript: async ({ agent, providerSessionId, accountHomePath }) => + resolveSessionFilePath( + agent, + providerSessionId, + agent === 'claude' + ? { claudeProjectsDir: join(accountHomePath, 'projects') } + : { codexSessionsDirs: [join(accountHomePath, 'sessions')] } + ) + }) +} + +/** Recognised adoption homes, most-preferred first. */ +function structuredAdoptionAccountHomeCandidates(input: { + settings: AdoptionSettings + agent: 'claude' | 'codex' + selectedAccountHomePath: string +}): string[] { + if (input.agent === 'claude') { + return [input.selectedAccountHomePath, join(homedir(), '.claude')] + } + return [ + input.selectedAccountHomePath, + ...(input.settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath), + ...configuredAdditionalCodexHomePaths(), + getOrcaManagedCodexHomePath(), + getSystemCodexHomePath() + ] +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e5a18bc3014..0e0fe1d7a78 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -30,6 +30,8 @@ import { resolveAiVaultSessionResumeState } from './ai-vault-session-resume' import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' +import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat' +import { resolveAiVaultSessionResumeInChatForWorkspace } from './ai-vault-session-resume-in-chat-workspace' import { useAiVaultSessionWorktreeMap, withAiVaultCurrentWorktreeStatus @@ -287,6 +289,22 @@ export default function AiVaultPanel(): React.JSX.Element { [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState] ) + // Resuming into a chat asks a different question from resuming into a terminal: not "can this + // workspace host a PTY" but "will the provider still find this conversation from the workspace we + // would run it in". The workspace it targets is the session's own when that is open, because + // Claude looks its transcript up under a directory derived from the launch cwd. + const getSessionResumeInChat = useCallback( + (session: AiVaultSession): AiVaultResumeInChatEligibility => + resolveAiVaultSessionResumeInChatForWorkspace({ + session, + resumeState: getSessionResumeState(session), + activeWorkspaceId: effectiveActiveWorktreeId, + targetState: resumeTargetState, + settings + }), + [effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings] + ) + const handleScopeChange = useCallback((nextScope: AiVaultScope) => { preferredScopeRef.current = nextScope userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE @@ -366,7 +384,9 @@ export default function AiVaultPanel(): React.JSX.Element { onJumpToOriginalPane={jumpToOriginalPane} onJumpToWorktree={jumpToWorktree} onResume={launchActions.handleResume} + getSessionResumeInChat={getSessionResumeInChat} onContinueInNewSession={launchActions.handleContinueInNewSession} + onResumeInNewChat={launchActions.handleResumeInNewChat} onCopyResume={(session, worktreeId) => void launchActions.copyResumeCommand(session, worktreeId) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index f63aa018f31..00f8da07d35 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -4,6 +4,7 @@ import { FolderOpen, LocateFixed, MessageSquarePlus, + MessagesSquare, PanelTopOpen, Play, Trash2 @@ -19,6 +20,7 @@ export function SessionActionMenuItems({ resumeLabel, onResume, onContinueInNewSession, + onResumeInNewChat, onJumpToOriginalPane, showJumpToWorktree, onJumpToWorktree, @@ -36,6 +38,7 @@ export function SessionActionMenuItems({ resumeLabel: string onResume: () => void onContinueInNewSession?: () => void + onResumeInNewChat?: () => void onJumpToOriginalPane?: () => void showJumpToWorktree: boolean onJumpToWorktree?: () => void @@ -93,6 +96,15 @@ export function SessionActionMenuItems({ {resumeLabel} + {onResumeInNewChat ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat', + 'Resume in New Chat' + )} + + ) : null} {onContinueInNewSession ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index b3907d68323..aa4f2e1430e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,5 +1,12 @@ import type React from 'react' -import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' +import { + FileJson, + FolderGit2, + MessageSquare, + MessageSquarePlus, + MessagesSquare, + Play +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -30,6 +37,7 @@ export function SessionInlineDetails({ onResumeInWorktree, onResumeInNewTab, onContinueInNewSession, + onResumeInNewChat, onOpenLog }: { id: string @@ -43,6 +51,7 @@ export function SessionInlineDetails({ onResumeInWorktree: () => void onResumeInNewTab: () => void onContinueInNewSession?: () => void + onResumeInNewChat?: () => void onOpenLog?: () => void }): React.JSX.Element { // A zero-turn transcript would resume into an empty conversation, so the plain @@ -68,7 +77,11 @@ export function SessionInlineDetails({ event.stopPropagation() }} > - {showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? ( + {showResumeInWorktree || + showResumeInNewTab || + onContinueInNewSession || + onResumeInNewChat || + onOpenLog ? (
{showResumeInWorktree ? ( + ) : null} {onContinueInNewSession ? (
) } - -function AiVaultVirtualRow({ - row, - index, - start, - activeStickyHeaderIndex, - measureElement, - collapsedGroups, - expandedSessionIds, - vaultScope, - buildResumeStartup, - getOriginalPaneTarget, - getSessionLiveState, - getWorktreeInfo, - getSessionResumeState, - getSessionResumeActions, - onToggleGroup, - onToggleSessionDetails, - onJumpToOriginalPane, - onJumpToWorktree, - onResume, - onContinueInNewSession, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd, - onRequestDelete -}: { - row: AiVaultListRow | undefined - index: number - start: number - activeStickyHeaderIndex: number | null - measureElement: (node: Element | null) => void - collapsedGroups: ReadonlySet - expandedSessionIds: ReadonlySet - vaultScope: AiVaultScope - buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup - getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null - getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null - getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null - getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState - getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions - onToggleGroup: (key: string) => void - onToggleSessionDetails: (sessionId: string) => void - onJumpToOriginalPane: (session: AiVaultSession) => void - onJumpToWorktree: (worktreeId: string) => void - onResume: (session: AiVaultSession, worktreeId: string) => void - onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void - onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void - onCopyId: (session: AiVaultSession) => void - onCopyPath: (session: AiVaultSession) => void - onOpenLog: (session: AiVaultSession) => void - onRevealLog: (session: AiVaultSession) => void - onOpenCwd: (session: AiVaultSession) => void - onRequestDelete: (session: AiVaultSession) => void -}): React.JSX.Element | null { - if (!row) { - return null - } - - const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index - const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null - const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null - // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op. - const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) - const worktreeJumpId = - showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) - ? worktreeInfo?.worktreeId - : null - const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null - const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null - const continuationWorktreeId = - row.type === 'session' && - canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId) - ? resumeState?.worktreeId - : null - // Gate resume on real content: a zero-turn transcript would resume into an - // empty conversation, so it is never offered as normally resumable. - const resumeGating = - row.type === 'session' - ? aiVaultSessionRowResumeGating(row.session, resumeState) - : { resumeDisabled: true, canCopyResumeCommand: false } - const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' - const canOpenLocalSessionPaths = - row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) - // Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode) - // identities that have no single file to open, while Reveal/CWD stay on the - // existing local-path gate. - const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session) - - return ( -
- {row.type === 'group' ? ( - onToggleGroup(row.group.key)} - /> - ) : ( - onToggleSessionDetails(row.session.id)} - onJumpToOriginalPane={ - originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined - } - showJumpToWorktree={showJumpToWorktree} - onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined} - onResume={() => { - if (resumeState?.worktreeId) { - onResume(row.session, resumeState.worktreeId) - } - }} - onContinueInNewSession={ - continuationWorktreeId - ? () => onContinueInNewSession(row.session, continuationWorktreeId) - : undefined - } - onResumeInWorktree={() => { - if (resumeActions?.worktree.worktreeId) { - onResume(row.session, resumeActions.worktree.worktreeId) - } - }} - onResumeInNewTab={() => { - if (resumeActions?.newTab.worktreeId) { - onResume(row.session, resumeActions.newTab.worktreeId) - } - }} - onCopyResume={ - resumeGating.canCopyResumeCommand - ? () => onCopyResume(row.session, resumeState?.worktreeId) - : undefined - } - onCopyId={() => onCopyId(row.session)} - onCopyPath={() => onCopyPath(row.session)} - onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined} - onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} - onOpenCwd={ - canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined - } - onRequestDelete={onRequestDelete} - /> - )} -
- ) -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx new file mode 100644 index 00000000000..1c7192654d7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx @@ -0,0 +1,212 @@ +import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' +import { cn } from '@/lib/utils' +import { VaultGroupHeader } from './AiVaultPanelControls' +import { VaultSessionRow } from './AiVaultSessionRow' +import type { AiVaultSessionGroup } from './ai-vault-session-filters' +import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane' +import { + aiVaultSessionResumeLabel, + aiVaultSessionRowResumeGating, + type AiVaultSessionResumeActions, + type AiVaultSessionResumeState +} from './ai-vault-session-resume' +import { + canJumpToAiVaultSessionWorktree, + isAiVaultSessionInCurrentWorktree, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' +import { + canOpenAiVaultSessionLogInOrca, + canUseLocalAiVaultSessionPathActions +} from './ai-vault-session-path-actions' +import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation' +import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat' + +export type AiVaultListRow = + | { type: 'group'; group: AiVaultSessionGroup } + | { type: 'session'; groupKey: string; session: AiVaultSession } + +export function AiVaultVirtualRow({ + row, + index, + start, + activeStickyHeaderIndex, + measureElement, + collapsedGroups, + expandedSessionIds, + vaultScope, + buildResumeStartup, + getOriginalPaneTarget, + getSessionLiveState, + getWorktreeInfo, + getSessionResumeState, + getSessionResumeActions, + getSessionResumeInChat, + onToggleGroup, + onToggleSessionDetails, + onJumpToOriginalPane, + onJumpToWorktree, + onResume, + onContinueInNewSession, + onResumeInNewChat, + onCopyResume, + onCopyId, + onCopyPath, + onOpenLog, + onRevealLog, + onOpenCwd, + onRequestDelete +}: { + row: AiVaultListRow | undefined + index: number + start: number + activeStickyHeaderIndex: number | null + measureElement: (node: Element | null) => void + collapsedGroups: ReadonlySet + expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope + buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup + getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null + getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null + getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null + getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState + getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions + getSessionResumeInChat: (session: AiVaultSession) => AiVaultResumeInChatEligibility + onToggleGroup: (key: string) => void + onToggleSessionDetails: (sessionId: string) => void + onJumpToOriginalPane: (session: AiVaultSession) => void + onJumpToWorktree: (worktreeId: string) => void + onResume: (session: AiVaultSession, worktreeId: string) => void + onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void + onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void + onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void + onCopyId: (session: AiVaultSession) => void + onCopyPath: (session: AiVaultSession) => void + onOpenLog: (session: AiVaultSession) => void + onRevealLog: (session: AiVaultSession) => void + onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void +}): React.JSX.Element | null { + if (!row) { + return null + } + + const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index + const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null + const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null + // Why: omit the jump affordance when the session already lives in the + // worktree on screen — jumping there is a no-op. + const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) + const worktreeJumpId = + showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) + ? worktreeInfo?.worktreeId + : null + const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null + const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null + const resumeInChat = row.type === 'session' ? getSessionResumeInChat(row.session) : null + const continuationWorktreeId = + row.type === 'session' && + canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId) + ? resumeState?.worktreeId + : null + // Gate resume on real content: a zero-turn transcript would resume into an + // empty conversation, so it is never offered as normally resumable. + const resumeGating = + row.type === 'session' + ? aiVaultSessionRowResumeGating(row.session, resumeState) + : { resumeDisabled: true, canCopyResumeCommand: false } + const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) + // Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode) + // identities that have no single file to open, while Reveal/CWD stay on the + // existing local-path gate. + const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session) + + return ( +
+ {row.type === 'group' ? ( + onToggleGroup(row.group.key)} + /> + ) : ( + onToggleSessionDetails(row.session.id)} + onJumpToOriginalPane={ + originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined + } + showJumpToWorktree={showJumpToWorktree} + onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined} + onResume={() => { + if (resumeState?.worktreeId) { + onResume(row.session, resumeState.worktreeId) + } + }} + onContinueInNewSession={ + continuationWorktreeId + ? () => onContinueInNewSession(row.session, continuationWorktreeId) + : undefined + } + onResumeInNewChat={ + resumeInChat?.available + ? () => onResumeInNewChat(row.session, resumeInChat.workspaceId) + : undefined + } + onResumeInWorktree={() => { + if (resumeActions?.worktree.worktreeId) { + onResume(row.session, resumeActions.worktree.worktreeId) + } + }} + onResumeInNewTab={() => { + if (resumeActions?.newTab.worktreeId) { + onResume(row.session, resumeActions.newTab.worktreeId) + } + }} + onCopyResume={ + resumeGating.canCopyResumeCommand + ? () => onCopyResume(row.session, resumeState?.worktreeId) + : undefined + } + onCopyId={() => onCopyId(row.session)} + onCopyPath={() => onCopyPath(row.session)} + onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } + onRequestDelete={onRequestDelete} + /> + )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index c41c89ef966..262d736661e 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -53,6 +53,7 @@ export function SessionRowTrailingActions({ onJumpToWorktree, onResume, onContinueInNewSession, + onResumeInNewChat, onCopyResume, onCopyId, onCopyPath, @@ -75,6 +76,8 @@ export function SessionRowTrailingActions({ onJumpToWorktree?: () => void onResume: () => void onContinueInNewSession?: () => void + /** Passed through to the overflow menu only; the resting row keeps its two-icon budget. */ + onResumeInNewChat?: () => void onCopyResume?: () => void onCopyId: () => void onCopyPath: () => void @@ -256,6 +259,7 @@ export function SessionRowTrailingActions({ resumeLabel={resumeLabel} onResume={onResume} onContinueInNewSession={onContinueInNewSession} + onResumeInNewChat={onResumeInNewChat} onJumpToOriginalPane={onJumpToOriginalPane} showJumpToWorktree={showJumpToWorktree} onJumpToWorktree={onJumpToWorktree} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index fbc14e6a19f..ce1ed671e7e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -10,25 +10,24 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' -import { - canResumeAiVaultSessionOnTarget, - getAiVaultResumeWorkspaceExecutionHostId, - getAiVaultResumeWorkspaceTargetStatus -} from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' import type { Worktree } from '../../../../shared/worktree/types' import { translate } from '@/i18n/i18n' import { agentLabel } from './ai-vault-session-filters' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { - isKnownAiVaultResumeWorkspaceTarget, - type AiVaultSessionResumeTargetState -} from './ai-vault-session-resume' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' -import { findWorktreeById } from '@/store/slices/worktree-helpers' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code' +import { + aiVaultResumeUnsupportedMessage, + resolveAiVaultSessionLaunchTarget, + resolveAiVaultTargetWorkspacePath +} from './ai-vault-session-launch-target' export function useAiVaultSessionLaunchActions({ activeWorktree, @@ -147,6 +146,44 @@ export function useAiVaultSessionLaunchActions({ [activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState] ) + const handleResumeInNewChat = useCallback( + (session: AiVaultSession, targetWorktreeId?: string): void => { + if (!isAgentSessionHandleProvider(session.agent)) { + return + } + const worktreeId = targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null + if (!worktreeId) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', + 'Open a workspace before resuming a session.' + ) + ) + return + } + // Codex rows can live under a shared legacy home; the same preparation the terminal resume + // runs re-pins them, and its result is what names the conversation the host will look for. + void prepareAiVaultSessionForResume(session) + .then((preparedSession) => { + const launch = startStructuredAgentLaunch( + worktreeId, + session.agent as 'claude' | 'codex', + { + resumeFrom: { providerSessionId: preparedSession.sessionId } + } + ) + return launch.launchResult + }) + .then(() => { + if (useAppStore.getState().activeWorktreeId !== worktreeId) { + activateAiVaultResumeWorkspace(worktreeId) + } + }) + .catch(notifyAiVaultSessionResumeInChatFailure) + }, + [activeWorktree?.id, activeWorktreeId] + ) + const handleContinueInNewSession = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ @@ -194,12 +231,43 @@ export function useAiVaultSessionLaunchActions({ buildResumeStartup, copyResumeCommand, handleResume, + handleResumeInNewChat, handleContinueInNewSession, continuationRequest, handleContinuationDialogOpenChange } } +/** The host refuses an adoption whose conversation another chat already holds, and refuses one it + * cannot find under any account home it recognises. Both are actionable, and neither is the + * generic "could not prepare" the terminal resume reports. */ +function notifyAiVaultSessionResumeInChatFailure(error: unknown): void { + if (hasRuntimeRpcErrorCode(error, 'agent_session_conflict')) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', + 'Another chat is already holding this conversation.' + ) + ) + return + } + if (hasRuntimeRpcErrorCode(error, 'agent_session_identity_required')) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatTranscriptMissing', + "This conversation's history could not be loaded, so it cannot be resumed in chat." + ) + ) + return + } + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatFailed', + 'Could not resume this session in a new chat.' + ) + ) +} + function notifyAiVaultSessionPreparationFailure(error: unknown): void { toast.error( error instanceof Error @@ -211,66 +279,9 @@ function notifyAiVaultSessionPreparationFailure(error: unknown): void { ) } -function resolveAiVaultTargetWorkspacePath( - state: AiVaultSessionResumeTargetState, - workspaceId: string -): string | null { - const scope = parseWorkspaceKey(workspaceId) - if (scope?.type === 'folder') { - return ( - state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) - ?.folderPath ?? null - ) - } - const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId - return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null -} - -export type AiVaultSessionLaunchTarget = - | { status: 'missing' } - | { - status: 'unsupported' - targetStatus: ReturnType - } - | { status: 'ready'; worktreeId: string } - -export function resolveAiVaultSessionLaunchTarget(args: { - sessionFilePath: string | null - sessionExecutionHostId?: AiVaultSession['executionHostId'] | null - activeWorktreeId: string | null - targetWorktreeId?: string - targetState: AiVaultSessionResumeTargetState -}): AiVaultSessionLaunchTarget { - const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId - if ( - !targetWorktreeId || - !isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId) - ) { - return { status: 'missing' } - } - - const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( - args.targetState, - targetWorktreeId - ) - if ( - !canResumeAiVaultSessionOnTarget({ - sessionFilePath: args.sessionFilePath, - sessionExecutionHostId: args.sessionExecutionHostId, - targetStatus, - targetExecutionHostId - }) - ) { - return { status: 'unsupported', targetStatus } - } - - return { status: 'ready', worktreeId: targetWorktreeId } -} - function resolveAiVaultSessionLaunchTargetOrNotify( args: Parameters[0] -): Extract | null { +): Extract, { status: 'ready' }> | null { const target = resolveAiVaultSessionLaunchTarget(args) if (target.status === 'missing') { toast.error( @@ -288,23 +299,6 @@ function resolveAiVaultSessionLaunchTargetOrNotify( return target } -function aiVaultResumeUnsupportedMessage( - targetStatus: ReturnType -): string { - // Why: local and SSH targets can both be valid generally; this branch means - // the session's recorded host does not match the selected workspace. - if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') { - return translate( - 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', - 'This session belongs to a different host. Open a workspace on the same host to resume it.' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a workspace before resuming a session.' - ) -} - function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts new file mode 100644 index 00000000000..98e136931d5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts @@ -0,0 +1,87 @@ +import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, + getAiVaultResumeWorkspaceTargetStatus +} from '@/lib/ai-vault-resume-target' +import { translate } from '@/i18n/i18n' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + isKnownAiVaultResumeWorkspaceTarget, + type AiVaultSessionResumeTargetState +} from './ai-vault-session-resume' + +export function resolveAiVaultTargetWorkspacePath( + state: AiVaultSessionResumeTargetState, + workspaceId: string +): string | null { + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + return ( + state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) + ?.folderPath ?? null + ) + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId + return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null +} + +export type AiVaultSessionLaunchTarget = + | { status: 'missing' } + | { + status: 'unsupported' + targetStatus: ReturnType + } + | { status: 'ready'; worktreeId: string } + +export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null + activeWorktreeId: string | null + targetWorktreeId?: string + targetState: AiVaultSessionResumeTargetState +}): AiVaultSessionLaunchTarget { + const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId + if ( + !targetWorktreeId || + !isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId) + ) { + return { status: 'missing' } + } + + const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + targetWorktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { + return { status: 'unsupported', targetStatus } + } + + return { status: 'ready', worktreeId: targetWorktreeId } +} + +export function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + // Why: local and SSH targets can both be valid generally; this branch means + // the session's recorded host does not match the selected workspace. + if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Open a workspace on the same host to resume it.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a workspace before resuming a session.' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts new file mode 100644 index 00000000000..adedc94b22a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -0,0 +1,64 @@ +import { + structuredAgentLaunchSupported, + type AgentLaunchRoutingInput +} from '@/lib/agent-launch-routing' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { useAppStore } from '@/store' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' +import { + resolveAiVaultSessionResumeInChatEligibility, + type AiVaultResumeInChatEligibility +} from './ai-vault-session-resume-in-chat' +import type { + AiVaultSessionResumeState, + AiVaultSessionResumeTargetState +} from './ai-vault-session-resume' + +export function resolveAiVaultSessionResumeInChatForWorkspace(args: { + session: AiVaultSession + resumeState: AiVaultSessionResumeState + activeWorkspaceId: string | null + targetState: AiVaultSessionResumeTargetState + settings: AgentLaunchRoutingInput['settings'] +}): AiVaultResumeInChatEligibility { + const targetWorkspaceId = args.resumeState.usesSessionWorktree + ? args.resumeState.worktreeId + : (args.resumeState.worktreeId ?? args.activeWorkspaceId) + const targetWorkspacePath = targetWorkspaceId + ? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId) + : null + return resolveAiVaultSessionResumeInChatEligibility({ + session: args.session, + targetWorkspaceId, + targetWorkspacePath, + structuredRouteAvailable: + isAgentSessionHandleProvider(args.session.agent) && + Boolean(targetWorkspaceId) && + structuredAgentLaunchSupported({ + agent: args.session.agent, + settings: args.settings, + executionHostId: getExecutionHostIdForWorktree( + useAppStore.getState(), + targetWorkspaceId as string + ), + platform: CLIENT_PLATFORM, + hostCapabilities: readLocalRuntimeCapabilities(), + workspaceKind: (targetWorkspaceId as string).startsWith('folder:') + ? 'folder' + : 'git-worktree', + projectRuntime: getLocalProjectExecutionRuntimeContext( + useAppStore.getState(), + targetWorkspaceId as string + ) + }) && + readLocalRuntimeCapabilities().includes( + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY + ) + }) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts new file mode 100644 index 00000000000..f945cdaa95a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { + aiVaultSessionCwdMatchesWorkspace, + resolveAiVaultSessionResumeInChatEligibility +} from './ai-vault-session-resume-in-chat' + +type ResumeInChatSession = Parameters< + typeof resolveAiVaultSessionResumeInChatEligibility +>[0]['session'] + +const WORKSPACE_PATH = '/repo/orca' + +function session(overrides: Partial = {}): ResumeInChatSession { + return { + agent: 'claude', + cwd: WORKSPACE_PATH, + filePath: '/home/dev/.claude/projects/-repo-orca/session-1.jsonl', + executionHostId: 'local', + messageCount: 12, + previewMessages: [], + ...overrides + } +} + +function eligibility( + overrides: Partial[0]> = {} +) { + return resolveAiVaultSessionResumeInChatEligibility({ + session: session(), + targetWorkspaceId: 'repo-1::/repo/orca', + targetWorkspacePath: WORKSPACE_PATH, + structuredRouteAvailable: true, + ...overrides + }) +} + +describe('resolveAiVaultSessionResumeInChatEligibility', () => { + it('offers the chat for a local Claude row in its own workspace', () => { + expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' }) + }) + + it.each(['hermes', 'grok', 'opencode'] as AiVaultSession['agent'][])( + 'refuses %s, which has no structured lane', + (agent) => { + expect(eligibility({ session: session({ agent }) })).toEqual({ + available: false, + reason: 'agent' + }) + } + ) + + it('refuses a row already adopted into a chat before any other check', () => { + // That row reopens its own chat; a second adoption is a conflict the host would refuse. + expect( + eligibility({ + session: { + ...session(), + structuredSession: { sessionId: 'claude_1', workspaceId: 'repo-1::/repo/orca' } + } + }) + ).toEqual({ available: false, reason: 'already-structured' }) + }) + + it('refuses a row recorded on a remote host', () => { + expect(eligibility({ session: session({ executionHostId: 'ssh:build-box' }) })).toEqual({ + available: false, + reason: 'remote' + }) + }) + + it('refuses a row whose transcript is stored inside WSL', () => { + expect( + eligibility({ + session: session({ + filePath: '//wsl.localhost/Ubuntu-22.04/home/dev/.claude/projects/p/session-1.jsonl' + }) + }) + ).toEqual({ available: false, reason: 'remote' }) + }) + + it('refuses a transcript that holds no conversation', () => { + expect(eligibility({ session: session({ messageCount: 0, previewMessages: [] }) })).toEqual({ + available: false, + reason: 'empty' + }) + }) + + it('offers a zero-count row whose preview proves the turns exist', () => { + // Some parsers only learn the turn count from metadata that may be absent. + expect( + eligibility({ + session: session({ + messageCount: 0, + previewMessages: [{ role: 'user', text: 'hello', timestamp: null }] + }) + }) + ).toMatchObject({ available: true }) + }) + + it('refuses when the same pair could not take the structured route for a fresh chat', () => { + expect(eligibility({ structuredRouteAvailable: false })).toEqual({ + available: false, + reason: 'workspace' + }) + }) + + it('refuses when there is no target workspace at all', () => { + expect(eligibility({ targetWorkspaceId: null })).toEqual({ + available: false, + reason: 'workspace' + }) + }) +}) + +describe('workspace matching, which only Claude is bound by', () => { + it('refuses a Claude row whose conversation was recorded in another workspace', () => { + // Claude's SDK keys transcripts by launch cwd, so resuming elsewhere silently finds nothing. + expect( + eligibility({ + session: session({ cwd: '/repo/other' }), + targetWorkspacePath: WORKSPACE_PATH + }) + ).toEqual({ available: false, reason: 'workspace' }) + }) + + it('refuses a Claude row that recorded no cwd', () => { + expect(eligibility({ session: session({ cwd: null }) })).toEqual({ + available: false, + reason: 'workspace' + }) + }) + + it('keeps Codex available in a different workspace, and with no recorded cwd', () => { + // Codex is handed the rollout file and a cwd, so it resumes anywhere. + expect(eligibility({ session: session({ agent: 'codex', cwd: '/repo/other' }) })).toMatchObject( + { available: true } + ) + expect(eligibility({ session: session({ agent: 'codex', cwd: null }) })).toMatchObject({ + available: true + }) + }) + + it('treats Windows spellings of one directory as the same workspace', () => { + expect( + eligibility({ + session: session({ cwd: 'C:\\Users\\Dev\\repo\\Orca\\' }), + targetWorkspacePath: 'c:/users/dev/repo/orca' + }) + ).toMatchObject({ available: true }) + }) +}) + +describe('aiVaultSessionCwdMatchesWorkspace', () => { + it('ignores separator, case, and a trailing slash', () => { + expect(aiVaultSessionCwdMatchesWorkspace('C:\\repo\\Orca', 'c:/repo/orca')).toBe(true) + expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca/', '/repo/orca')).toBe(true) + expect(aiVaultSessionCwdMatchesWorkspace(' /repo/orca ', '/repo/orca')).toBe(true) + }) + + it('never calls a missing path a match', () => { + expect(aiVaultSessionCwdMatchesWorkspace(null, '/repo/orca')).toBe(false) + expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca', null)).toBe(false) + expect(aiVaultSessionCwdMatchesWorkspace('', '')).toBe(false) + }) + + it('does not treat a sibling directory as the same workspace', () => { + expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca-2', '/repo/orca')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts new file mode 100644 index 00000000000..92817807f77 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -0,0 +1,100 @@ +// Whether an Agent Session History row can be resumed into a structured native chat, and where. +// +// Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the +// terminal resume asks whether a workspace can host a PTY, this asks whether a provider will still +// find the conversation from the workspace we would run it in. + +import { isWslStoredAiVaultSessionFile } from '@/lib/ai-vault-resume-target' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { + isAiVaultSessionResumableContent, + type AiVaultSession +} from '../../../../shared/ai-vault-types' + +export type AiVaultResumeInChatBlockedReason = + | 'agent' + | 'remote' + | 'empty' + | 'already-structured' + | 'workspace' + +export type AiVaultResumeInChatEligibility = + | { available: true; workspaceId: string } + | { available: false; reason: AiVaultResumeInChatBlockedReason } + +/** + * Claude and Codex do not have the same freedom about *where* a conversation may be resumed. + * + * Codex is handed the rollout file and a cwd, so it can resume into any workspace. Claude's SDK + * stores transcripts under a project key derived from the launch cwd, so resuming from a workspace + * other than the one the conversation was recorded in looks in a directory the transcript is not in. + * That is a resume that silently yields nothing, which is worse than a disabled affordance. + */ +export function aiVaultSessionResumeInChatWorkspaceMatters( + agent: AiVaultSession['agent'] +): boolean { + return agent === 'claude' +} + +/** + * Does the row's recorded directory name the same place as the target workspace? + * + * Uses the shared runtime-path comparison rather than a local normalizer, which also keeps POSIX + * paths case-SENSITIVE — folding their case would call two genuinely different directories the same. + */ +export function aiVaultSessionCwdMatchesWorkspace( + cwd: string | null | undefined, + workspacePath: string | null | undefined +): boolean { + if (!cwd || !workspacePath) { + return false + } + return ( + normalizeRuntimePathForComparison(cwd.trim()) === + normalizeRuntimePathForComparison(workspacePath.trim()) + ) +} + +export function resolveAiVaultSessionResumeInChatEligibility(args: { + session: Pick< + AiVaultSession, + 'agent' | 'cwd' | 'filePath' | 'executionHostId' | 'messageCount' | 'previewMessages' + > & { structuredSession?: AiVaultSession['structuredSession'] } + targetWorkspaceId: string | null + targetWorkspacePath: string | null + /** The route the same (workspace, agent) pair would take for a fresh chat. Reused rather than + * re-derived: it already encodes the settings flag, host capability, platform refusals and the + * WSL/repair refusal, and a second copy of those conditions would drift from it. */ + structuredRouteAvailable: boolean +}): AiVaultResumeInChatEligibility { + const { session } = args + if (!isAgentSessionHandleProvider(session.agent)) { + return { available: false, reason: 'agent' } + } + // An already-adopted row reopens its own chat instead; offering a second resume of it would ask + // for a conflict the host would rightly refuse. + if (session.structuredSession) { + return { available: false, reason: 'already-structured' } + } + if ( + session.executionHostId !== LOCAL_EXECUTION_HOST_ID || + isWslStoredAiVaultSessionFile(session.filePath) + ) { + return { available: false, reason: 'remote' } + } + if (!isAiVaultSessionResumableContent(session)) { + return { available: false, reason: 'empty' } + } + if (!args.targetWorkspaceId || !args.structuredRouteAvailable) { + return { available: false, reason: 'workspace' } + } + if ( + aiVaultSessionResumeInChatWorkspaceMatters(session.agent) && + !aiVaultSessionCwdMatchesWorkspace(session.cwd, args.targetWorkspacePath) + ) { + return { available: false, reason: 'workspace' } + } + return { available: true, workspaceId: args.targetWorkspaceId } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 583e668cd2a..b5d3fe53c72 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-actions' +import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-target' import { aiVaultSessionResumeLabel, aiVaultSessionRowResumeGating, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ca33b766622..cf00f09b861 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13006,7 +13006,10 @@ "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead.", "prepareSessionResumeFailed": "Could not prepare this session for resume.", "sessionDeleted": "Session deleted", - "sessionDeleteFailed": "Couldn't delete the session" + "sessionDeleteFailed": "Couldn't delete the session", + "resumeInChatConflict": "Another chat is already holding this conversation.", + "resumeInChatTranscriptMissing": "This conversation's history could not be loaded, so it cannot be resumed in chat.", + "resumeInChatFailed": "Could not resume this session in a new chat." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -13142,7 +13145,8 @@ "delete": "Delete", "deleteReasonNonLocalHost": "Only sessions on this device can be deleted.", "deleteReasonSyntheticPath": "This session can't be deleted from Orca.", - "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca." + "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.", + "resumeInNewChat": "Resume in New Chat" }, "AiVaultSessionDeleteDialog": { "title": "Delete this session?", diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 45c86bb1517..af219bab633 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -4,7 +4,8 @@ import { hasExplicitTuiAgentArgs, hasExplicitTuiLaunchCustomization, hasSemanticallyNonEmptyAgentArgs, - resolveAgentLaunchRoute + resolveAgentLaunchRoute, + structuredAgentLaunchSupported } from './agent-launch-routing' const settings = { @@ -190,3 +191,28 @@ describe('resolveAgentLaunchRoute', () => { expect(hasExplicitTuiAgentArgs('codex', '--model gpt-5.6-sol')).toBe(true) }) }) + +describe('explicit structured chat requests', () => { + it.each(['claude', 'codex'] as const)( + 'supports %s history resume when new tabs default to terminal', + (agent) => { + const input = { + agent, + settings: { ...settings, openAgentTabsInChatByDefault: false }, + executionHostId: 'local', + platform: 'darwin' as const, + hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + workspaceKind: 'folder' as const + } + expect(resolveAgentLaunchRoute(input)).toBe('terminal-tui') + expect(structuredAgentLaunchSupported(input)).toBe(true) + expect(structuredAgentLaunchSupported({ ...input, hostCapabilities: [] })).toBe(false) + expect( + structuredAgentLaunchSupported({ + ...input, + settings: { ...input.settings, experimentalStructuredNativeChat: false } + }) + ).toBe(false) + } + ) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 17cb95a43d0..2bca72ba3ae 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -56,16 +56,24 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa if (!prefersStructuredNativeChatByDefault(input.settings)) { return 'legacy-native-chat' } - return resolveStructuredNativeChatSupport({ - agent: input.agent, - executionHostId: input.executionHostId, - platform: input.platform, - hostCapabilities: input.hostCapabilities, - workspaceKind: input.workspaceKind, - projectRuntime: input.projectRuntime, - isDraftPrompt: input.promptDelivery === 'draft', - requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization - }).supported - ? 'structured-native-chat' - : 'legacy-native-chat' + return structuredAgentLaunchSupported(input) ? 'structured-native-chat' : 'legacy-native-chat' +} + +// Explicit chat requests do not depend on the default view mode for new tabs. +export function structuredAgentLaunchSupported( + input: Omit +): boolean { + return ( + input.settings?.experimentalStructuredNativeChat === true && + resolveStructuredNativeChatSupport({ + agent: input.agent, + executionHostId: input.executionHostId, + platform: input.platform, + hostCapabilities: input.hostCapabilities, + workspaceKind: input.workspaceKind, + projectRuntime: input.projectRuntime, + isDraftPrompt: input.promptDelivery === 'draft', + requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization + }).supported + ) } diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index ae85117b7e6..503ae771419 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -6,7 +6,8 @@ import type { import { createStructuredAgentSessionId, structuredAgentSessionCreateParams, - type StructuredAgentSessionCreateParams + type StructuredAgentSessionCreateParams, + type StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' @@ -88,7 +89,8 @@ export function isDefinitiveStructuredAgentSessionCreateError(error: unknown): b export function createStructuredAgentSessionLaunchIntent( worktreeId: string, - agent: AgentSessionHandleProvider + agent: AgentSessionHandleProvider, + resumeFrom?: StructuredAgentSessionResumeSource ): StructuredAgentSessionLaunchIntent { const sessionId = createStructuredAgentSessionId(agent, () => crypto.randomUUID()) const state = useAppStore.getState() @@ -107,6 +109,7 @@ export function createStructuredAgentSessionLaunchIntent( sessionId, worktree: toRuntimeWorktreeSelector(worktreeId), agent, + ...(resumeFrom ? { resumeFrom } : {}), randomUuid: () => crypto.randomUUID() }) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index db9715c0c1a..18e14c006c0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -4,6 +4,7 @@ import { type StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' export type StructuredRefusalFallback = () => | void @@ -14,6 +15,9 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' onPromptDelivered?: () => void + /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's + * identity, not a preference — see `launchIdentity`. */ + resumeFrom?: StructuredAgentSessionResumeSource } export type StructuredLaunchCaller = { diff --git a/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts new file mode 100644 index 00000000000..7e99ff73fe6 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +// Launch coalescing when a launch adopts a conversation. Drives the real intent builder, because +// the identity under test is derived there — mocking it out would assert only the mock's shape. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + refresh: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), message: vi.fn() } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + LOCAL_STRUCTURED_SESSION_OWNER: 'local', + refreshLocalStructuredSessionTabs: mocks.refresh +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ unifiedTabsByWorktree: {} }), + subscribe: () => () => {} + } +})) + +import { + getStructuredAgentLaunchStatus, + startStructuredAgentLaunch +} from './structured-agent-session-launch' + +/** The create is dispatched off a microtask, so every assertion on it has to drain them first. */ +async function flushLaunchDispatch(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +/** Every create is left in flight, so each launch is still pending when the next one arrives. */ +function createParams(): StructuredAgentSessionCreateParams[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.create') + .map(([, , params]) => params as StructuredAgentSessionCreateParams) +} + +describe('a launch that adopts a conversation is its own identity', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.refresh.mockResolvedValue([]) + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.create' + ? new Promise(() => {}) + : { ok: true, value: { submission: { dispatchState: 'accepted' } } } + ) + }) + + it('does not hand a resume the blank launch already pending for the same worktree', async () => { + // A joining caller is handed the EXISTING intent and contributes only its prompt, so joining + // here would silently drop the adoption and open a blank chat instead. + const worktreeId = 'wt-resume-vs-blank' + const blank = startStructuredAgentLaunch(worktreeId, 'codex') + const resume = startStructuredAgentLaunch(worktreeId, 'codex', { + resumeFrom: { providerSessionId: 'thread-1' } + }) + + await flushLaunchDispatch() + + expect(resume.sessionId).not.toBe(blank.sessionId) + expect(createParams()).toEqual([ + expect.not.objectContaining({ resumeFrom: expect.anything() }), + expect.objectContaining({ resumeFrom: { providerSessionId: 'thread-1' } }) + ]) + }) + + it('does not hand a blank launch the resume already pending for the same worktree', async () => { + const worktreeId = 'wt-blank-vs-resume' + const resume = startStructuredAgentLaunch(worktreeId, 'codex', { + resumeFrom: { providerSessionId: 'thread-1' } + }) + const blank = startStructuredAgentLaunch(worktreeId, 'codex') + + await flushLaunchDispatch() + + expect(blank.sessionId).not.toBe(resume.sessionId) + expect(createParams()).toHaveLength(2) + }) + + it('keeps two resumes of different rows apart', async () => { + const worktreeId = 'wt-two-rows' + const first = startStructuredAgentLaunch(worktreeId, 'codex', { + resumeFrom: { providerSessionId: 'thread-1' } + }) + const second = startStructuredAgentLaunch(worktreeId, 'codex', { + resumeFrom: { providerSessionId: 'thread-2' } + }) + + await flushLaunchDispatch() + + expect(second.sessionId).not.toBe(first.sessionId) + expect(createParams().map((params) => params.resumeFrom?.providerSessionId)).toEqual([ + 'thread-1', + 'thread-2' + ]) + }) + + it('coalesces a duplicate click on the same row', async () => { + const worktreeId = 'wt-same-row-twice' + const resumeFrom = { providerSessionId: 'thread-1' } + const first = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom }) + const second = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom }) + + await flushLaunchDispatch() + + expect(second.sessionId).toBe(first.sessionId) + expect(createParams()).toHaveLength(1) + }) + + it('keeps the same row apart across worktrees and agents', async () => { + const resumeFrom = { providerSessionId: 'thread-1' } + const here = startStructuredAgentLaunch('wt-here', 'codex', { resumeFrom }) + const there = startStructuredAgentLaunch('wt-there', 'codex', { resumeFrom }) + + await flushLaunchDispatch() + + expect(there.sessionId).not.toBe(here.sessionId) + expect(createParams()).toHaveLength(2) + }) + + it('reports a pending resume as a launch in flight for the worktree', () => { + // "Is a chat starting here" means any launch for the pair, not only the blank one. + const worktreeId = 'wt-resume-status' + expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('idle') + + startStructuredAgentLaunch(worktreeId, 'codex', { + resumeFrom: { providerSessionId: 'thread-1' } + }) + + expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('pending') + expect(getStructuredAgentLaunchStatus(worktreeId, 'claude')).toBe('idle') + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 2a97f6acb36..7543c176180 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -33,6 +33,7 @@ import { type StructuredLaunchCallerGroup, type StructuredRefusalFallback } from '@/lib/structured-agent-session-launch-callers' +import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -79,11 +80,18 @@ export function getStructuredAgentLaunchStatus( worktreeId: string, agent: AgentSessionHandleProvider ): StructuredAgentLaunchStatus { - const state = pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)) - if (!state) { + // Any launch for this pair, not just the blank one: adopting launches carry the conversation in + // their identity, and a caller asking "is a chat starting here" means all of them. + const states = [ + pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)), + ...[...pendingStructuredLaunchesByIdentity.entries()] + .filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`)) + .map(([, state]) => state) + ].filter((state): state is StructuredLaunchState => Boolean(state)) + if (states.length === 0) { return 'idle' } - return state.visibilityUnknown ? 'unknown' : 'pending' + return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending' } export function useStructuredAgentLaunchStatus( @@ -99,8 +107,19 @@ export function useStructuredAgentLaunchStatus( // Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared // key would hand the second caller the first agent's intent. -function launchIdentity(worktreeId: string, agent: AgentSessionHandleProvider): string { - return `${agent}:${worktreeId}` +// +// Why keyed by the adopted conversation as well: a joining caller is handed the EXISTING intent and +// contributes only its prompt, so without this a resume that arrives while a blank launch is pending +// would be silently dropped — the user would get a blank chat, or another row's conversation, with +// no error. A launch that adopts a conversation is a different launch. +function launchIdentity( + worktreeId: string, + agent: AgentSessionHandleProvider, + resumeFrom?: StructuredAgentSessionResumeSource +): string { + return resumeFrom + ? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}` + : `${agent}:${worktreeId}` } function cleanupLaunchState(state: StructuredLaunchState): void { @@ -207,7 +226,7 @@ function structuredAgentLaunchState( agent: AgentSessionHandleProvider, options: StructuredAgentLaunchOptions ): StructuredLaunchStateResult { - const identity = launchIdentity(worktreeId, agent) + const identity = launchIdentity(worktreeId, agent, options.resumeFrom) const existing = pendingStructuredLaunchesByIdentity.get(identity) if (existing) { if (existing.visibilityUnknown) { @@ -233,7 +252,12 @@ function structuredAgentLaunchState( } } - const intent = createStructuredAgentSessionLaunchIntent(worktreeId, agent) + // Only pass the third argument when adopting: every ordinary launch keeps the two-argument call + // it has always made, so this change adds no trailing `undefined` for call-site assertions to + // absorb. + const intent = options.resumeFrom + ? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom) + : createStructuredAgentSessionLaunchIntent(worktreeId, agent) const text = options.prompt?.trim() ?? '' const stagedPrompt = text ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 538ff638983..e2830c01029 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -300,3 +300,94 @@ describe('chain lookup and validation', () => { ).toBe(false) }) }) + +describe('adopted chain heads', () => { + // What a resume-from-history builds: the create seeds an `adopted` head, then the provider's own + // proof lands on top of it. + const adopted = (overrides: Partial = {}) => + link({ + linkId: 'claude-1-sess-1-empty', + origin: 'adopted', + handle: { ...CLAUDE, leafUuid: null }, + ...overrides + }) + + it('appends a Claude resume that lands on the adopted root with a leaf', () => { + // The adopted head names no leaf; the provider answers with one. Same root, so it is a resume. + const resumed = link({ + linkId: 'claude-1-sess-1-leaf-1', + origin: 'resumed', + handle: CLAUDE, + mintedAtFence: 1 + }) + const chain = appendAgentSessionProviderHandleLink([adopted()], resumed) + + expect(chain.map((entry) => entry.origin)).toEqual(['adopted', 'resumed']) + expect(agentSessionProviderHandleChainHead(chain)).toBe(resumed) + }) + + it('elides a Claude re-proof of the identical adopted handle at the same fence', () => { + const chain = [adopted()] + const elided = appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'claude-1-sess-1-empty-retry', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: null }, + mintedAtFence: 1 + }) + ) + + expect(elided).toEqual(chain) + }) + + it('appends a Codex resume only once the fence has moved', () => { + const codexAdopted = link({ + linkId: 'codex-1-thread-1', + origin: 'adopted', + handle: { provider: 'codex', threadId: 'thread-1' } + }) + const reproved = link({ + linkId: 'codex-1-thread-1-retry', + origin: 'resumed', + handle: { provider: 'codex', threadId: 'thread-1' }, + mintedAtFence: 1 + }) + + // Codex's thread id is the whole key, so a same-fence re-proof can only ever be a retry. + expect(appendAgentSessionProviderHandleLink([codexAdopted], reproved)).toEqual([codexAdopted]) + expect( + appendAgentSessionProviderHandleLink([codexAdopted], { ...reproved, mintedAtFence: 2 }) + ).toHaveLength(2) + }) + + it('refuses a second origin link on top of an adopted head', () => { + // Nothing re-origins a chain: a create landing here would erase where the conversation came from. + for (const origin of ['created', 'adopted'] as const) { + expect(() => + appendAgentSessionProviderHandleLink( + [adopted()], + link({ linkId: 'claude-2-sess-1-leaf-1', origin, handle: CLAUDE, mintedAtFence: 2 }) + ) + ).toThrow('agent_session_provider_handle_invalid') + } + }) + + it('refuses a resume that landed on another conversation entirely', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [adopted()], + link({ + linkId: 'claude-1-sess-9-leaf-9', + origin: 'resumed', + handle: { provider: 'claude', sessionId: 'sess-9', leafUuid: 'leaf-9' }, + mintedAtFence: 1 + }) + ) + ).toThrow('agent_session_provider_handle_forked') + }) + + it('accepts an adopted head as a persisted chain', () => { + expect(isAgentSessionProviderHandleChain([adopted()])).toBe(true) + }) +}) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ef342d55d6a..e6de276133e 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -146,6 +146,12 @@ export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = // negotiation rather than by calling and reading a refusal it cannot distinguish from a real one. export const STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY = 'agent-session.structured.reveal.v1' as const +// Why: `agentSession.create` gains an optional `resumeFrom`, and its params are a STRICT union — an +// older host rejects the unknown key as a schema error, which a client cannot tell from a real +// refusal. Worse, without probing, a client cannot know whether a host that accepted the call +// adopted the conversation or quietly started a blank one. Negotiate before offering the action. +export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = + 'agent-session.structured.resume-history.v1' as const // Why: agentSession.subscribeStatus is additive to a surface that already shipped, so a host // advertising agent-session.structured.v1 may still answer it with method_not_found. Clients must // probe before subscribing or they reconnect forever and never show any status at all. @@ -251,6 +257,7 @@ export const RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts new file mode 100644 index 00000000000..d3aabf357f9 --- /dev/null +++ b/src/shared/structured-agent-session-create.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionCreateParams } from './structured-agent-session-create' +import { + structuredAgentSessionCreateFingerprint, + structuredAgentSessionPayloadFingerprint +} from './structured-agent-session-mutation' + +const SESSION_ID = 'codex_11111111_2222_3333_4444_555555555555' +const RESUME = { providerSessionId: 'thread-abc' } + +/** Distinct per call so two envelopes never share an operation id by accident. */ +let uuidCounter = 0 +function nextUuid(): string { + uuidCounter += 1 + return `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, '0')}` +} + +function createParams(overrides: { resumeFrom?: { providerSessionId: string } } = {}) { + return structuredAgentSessionCreateParams({ + sessionId: SESSION_ID, + worktree: 'id:repo-1::/repo/orca', + agent: 'codex', + ...overrides, + randomUuid: nextUuid, + now: 1_800_000_000_000 + }) +} + +describe('structured agent session create params', () => { + it('carries resumeFrom only when the create adopts a conversation', () => { + expect(createParams()).not.toHaveProperty('resumeFrom') + expect(createParams({ resumeFrom: RESUME })).toMatchObject({ resumeFrom: RESUME }) + }) + + it('declares a fingerprint the host can recompute from the same fields', () => { + const params = createParams({ resumeFrom: RESUME }) + + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionCreateFingerprint({ + sessionId: SESSION_ID, + worktree: 'id:repo-1::/repo/orca', + agent: 'codex', + resumeFrom: RESUME + }) + ) + }) + + it('separates an adopting create from a blank one and from another row', () => { + const blank = createParams().envelope.payloadFingerprint + const adopted = createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint + const otherRow = createParams({ + resumeFrom: { providerSessionId: 'thread-other' } + }).envelope.payloadFingerprint + + expect(adopted).not.toBe(blank) + expect(otherRow).not.toBe(adopted) + }) + + it('gives a replay of the same adoption the same digest under a new operation id', () => { + const first = createParams({ resumeFrom: RESUME }) + const second = createParams({ resumeFrom: RESUME }) + + expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + expect(second.envelope.payloadFingerprint).toBe(first.envelope.payloadFingerprint) + }) + + it('leaves a blank create byte-identical to the pre-resume digest', () => { + // Pinned literal: a create with no `resumeFrom` must keep the digest older clients and hosts + // already compute, so adding a field to the create fingerprint fails here rather than in the + // field on a mixed-version pair. + expect(createParams().envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION_ID, + fields: { worktree: 'id:repo-1::/repo/orca', agent: 'codex' } + }) + ) + expect(createParams().envelope.payloadFingerprint).toBe( + '56cb15e22414c0f62fd89d77d00d2d6a0a422f16e95edee154fb8b5bf53fbbc3' + ) + }) +}) diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 13c7b4fe29a..a467db550a4 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -5,10 +5,24 @@ import { structuredAgentSessionCreateFingerprint } from './structured-agent-session-mutation' +/** + * The conversation a create adopts instead of starting a fresh one. + * + * Deliberately carries an identity and nothing else. The transcript file and the account home it + * lives under are derived by the executing host, never sent: `agentSession.create` is reachable by + * paired mobile clients, and a client-supplied path would let one choose which file the host reads + * into a journal and which credential directory the provider child launches against. + */ +export type StructuredAgentSessionResumeSource = { + /** claude: the session id. codex: the thread id. */ + providerSessionId: string +} + export type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope worktree: string agent: AgentSessionHandleProvider + resumeFrom?: StructuredAgentSessionResumeSource } /** Provider-prefixed so a session id names its lane on sight, and underscore-only @@ -29,10 +43,15 @@ export function structuredAgentSessionCreateParams(args: { sessionId: string worktree: string agent: AgentSessionHandleProvider + resumeFrom?: StructuredAgentSessionResumeSource randomUuid: () => string now?: number }): StructuredAgentSessionCreateParams { - const fields = { worktree: args.worktree, agent: args.agent } + const fields = { + worktree: args.worktree, + agent: args.agent, + ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) + } return { envelope: { sessionId: args.sessionId, diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 79c82095f29..1f3400b5e85 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -30,13 +30,17 @@ export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string agent: 'claude' | 'codex' + resumeFrom?: { providerSessionId: string } }): string { return structuredAgentSessionPayloadFingerprint({ method: 'agentSession.create', sessionId: input.sessionId, fields: { worktree: input.worktree, - agent: input.agent + agent: input.agent, + // `canonicalize` drops undefined, so a plain create keeps the digest it has always had. + // Adopting a conversation is a different intent and must not replay as a blank create. + resumeFrom: input.resumeFrom } }) } From c300913f902ac754b01ebb3d569ea2594cc9ab15 Mon Sep 17 00:00:00 2001 From: blade035 Date: Mon, 7 Sep 2026 09:27:13 +0300 Subject: [PATCH 2/5] fix(mobile): stop double-scaling commit timestamps in history rows (#17731) Co-authored-by: Claude Co-authored-by: Jinwoo-H --- .../MobileGitHistoryList.test.tsx | 18 +++++++++++++++++- .../source-control/mobile-git-history.test.ts | 19 ++++++++++--------- .../src/source-control/mobile-git-history.ts | 7 ++++--- src/shared/git-history-types.ts | 1 + src/shared/git-history.test.ts | 4 +++- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/mobile/src/source-control/MobileGitHistoryList.test.tsx b/mobile/src/source-control/MobileGitHistoryList.test.tsx index b19a069c80f..c6d7a9895a5 100644 --- a/mobile/src/source-control/MobileGitHistoryList.test.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.test.tsx @@ -27,11 +27,24 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight' })) vi.mock('../transport/client-context', () => ({ useForceReconnect: () => vi.fn() })) +// Captured at module scope: the list renders rows against Date.now() a few ms later, +// so a 3h offset stays inside the '3h' relative-time bucket. +const RENDER_NOW = Date.now() + function historyResponse(subject: string) { return { ok: true, result: { - items: [{ id: 'commit-1', displayId: 'c0mm1t1', subject, author: 'Ada', parentIds: [] }] + items: [ + { + id: 'commit-1', + displayId: 'c0mm1t1', + subject, + author: 'Ada', + parentIds: [], + timestamp: RENDER_NOW - 3 * 3_600_000 + } + ] } } } @@ -92,6 +105,9 @@ describe('MobileGitHistoryList', () => { await render(client, 'connected') expect(tree()).toContain('first load') + // Rows format the RPC timestamp (epoch ms); a regression to seconds-scaling + // renders every commit as 'just now' instead. + expect(tree()).toContain('3h') await update(client, 'reconnecting') expect(tree()).toContain('first load') diff --git a/mobile/src/source-control/mobile-git-history.test.ts b/mobile/src/source-control/mobile-git-history.test.ts index 7357f76aeff..7660d3f72d8 100644 --- a/mobile/src/source-control/mobile-git-history.test.ts +++ b/mobile/src/source-control/mobile-git-history.test.ts @@ -11,20 +11,21 @@ function item(overrides: Partial = {}): GitHistoryItem { subject: 'feat: thing', message: 'feat: thing\n\nbody', author: 'Jane', - timestamp: NOW / 1000 - 3600, + timestamp: NOW - 3_600_000, ...overrides } } describe('formatCommitTime', () => { - it('formats across thresholds', () => { - const s = NOW / 1000 - expect(formatCommitTime(s - 30, NOW)).toBe('just now') - expect(formatCommitTime(s - 5 * 60, NOW)).toBe('5m') - expect(formatCommitTime(s - 3 * 3600, NOW)).toBe('3h') - expect(formatCommitTime(s - 2 * 86400, NOW)).toBe('2d') - expect(formatCommitTime(s - 60 * 86400, NOW)).toBe('2mo') - expect(formatCommitTime(s - 800 * 86400, NOW)).toBe('2y') + it('formats across thresholds from epoch-millisecond timestamps', () => { + // GitHistoryItem.timestamp is epoch ms (git-history-log-parser scales git %at by 1000). + const ms = { min: 60_000, hour: 3_600_000, day: 86_400_000 } + expect(formatCommitTime(NOW - 3 * ms.hour, NOW)).toBe('3h') + expect(formatCommitTime(NOW - 30_000, NOW)).toBe('just now') + expect(formatCommitTime(NOW - 5 * ms.min, NOW)).toBe('5m') + expect(formatCommitTime(NOW - 2 * ms.day, NOW)).toBe('2d') + expect(formatCommitTime(NOW - 60 * ms.day, NOW)).toBe('2mo') + expect(formatCommitTime(NOW - 800 * ms.day, NOW)).toBe('2y') }) it('returns empty for missing timestamp', () => { diff --git a/mobile/src/source-control/mobile-git-history.ts b/mobile/src/source-control/mobile-git-history.ts index 416b761d214..d0d42929ade 100644 --- a/mobile/src/source-control/mobile-git-history.ts +++ b/mobile/src/source-control/mobile-git-history.ts @@ -12,12 +12,13 @@ export type MobileCommitRow = { } // Short relative time for a commit list (just now / Xm / Xh / Xd / Xmo / Xy). -export function formatCommitTime(timestampSeconds: number | undefined, nowMs: number): string { +// `timestampMs` is epoch ms, the unit GitHistoryItem.timestamp already carries. +export function formatCommitTime(timestampMs: number | undefined, nowMs: number): string { // Nullish — not falsy — so a real epoch-0 timestamp still formats. - if (timestampSeconds == null) { + if (timestampMs == null) { return '' } - const delta = nowMs - timestampSeconds * 1000 + const delta = nowMs - timestampMs if (delta < 60_000) { return 'just now' } diff --git a/src/shared/git-history-types.ts b/src/shared/git-history-types.ts index 4e99d4b2eb2..ede5ba19fac 100644 --- a/src/shared/git-history-types.ts +++ b/src/shared/git-history-types.ts @@ -48,6 +48,7 @@ export type GitHistoryItem = { displayId?: string author?: string authorEmail?: string + /** Epoch milliseconds (git %at seconds × 1000). */ timestamp?: number statistics?: GitHistoryItemStatistics references?: GitHistoryItemRef[] diff --git a/src/shared/git-history.test.ts b/src/shared/git-history.test.ts index 617fa33c2ef..38f7cdd2bd5 100644 --- a/src/shared/git-history.test.ts +++ b/src/shared/git-history.test.ts @@ -115,7 +115,9 @@ describe('git history parsing', () => { message: 'feat: add graph\n\nbody line', author: 'Ada Lovelace', authorEmail: 'ada@example.com', - displayId: HEAD_OID.slice(0, 7) + displayId: HEAD_OID.slice(0, 7), + // The format feeds %at seconds; consumers get epoch milliseconds. + timestamp: 1_700_000_000_000 }) expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([ ['refs/heads/feature', 'feature', 'branches'], From ba4e79c2504233890754f64e3ad2ba73a7cfdec4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:32:00 -0700 Subject: [PATCH 3/5] fix(runtime): apply the structured-chat setting to every RPC caller (#18700) * fix(runtime): apply the structured-chat setting to every RPC caller supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat when clientKind === 'mobile', so identical host settings admitted desktop and in-process callers while refusing a phone. The server branched on client surface. The setting is now one rule for every caller. The negotiated capability stays a wire term asked of remote clients only, so a capability-less in-process caller is still admitted on the setting alone. Making the projection's structuredNativeChatEnabled argument required surfaced eight call sites that passed `undefined` for non-mobile clients; they now read the host setting, so tab projection follows the same single rule. Announced behaviour change: with the flag off, session.tabs.list/listAll no longer restore structured tabs for desktop. The desktop renderer already discards them in that state, and startup record/lease reconciliation is unaffected. * fix(runtime): keep structured session cleanup available * test(runtime): enable structured chat in desktop projection fixture * test(agent-session): settle merged fixtures against the all-clients structured policy The merge with main left three fixtures written for the old mobile-only rule: a duplicate getClientSettings key, a create fixture with no host settings at all, and a projection call whose 'old client' is now the mobile fallback-title case. * fix(native-chat): let an admitted caller close a chat after the setting is off Turning `experimentalStructuredNativeChat` off revoked admission for every `agentSession.*` method, including `close`. A chat opened while the setting was on stays mounted, so its owner was left with a live provider child and an X button that answered `structured_agent_session_unsupported`. Split the surface by what a method does to work in flight rather than by how it sounds, and write that rule where the gate lives so the next method lands on the right side: starting, extending, retaining or reading needs admission; stopping or retiring work the caller already owns does not. Moves `close` and `cancel` onto the cleanup gate alongside `unsubscribe` and `release`. The tightening is unchanged - the cleanup gate still demands the negotiated wire capability and never creates a host, so an incapable client still cannot see the surface and no method that starts work is reachable with the setting off. Extracts the dispatcher harness and the method-to-gate table into fixtures so the new admission suite can share them without a max-lines disable. * Drop a duplicate lastActivityAt key carried in from main The main commit this branch merged (fb322046e8) had two lastActivityAt properties in the same object literal at both journal stubs, which fails TS1117 and oxlint. Upstream has since kept only the later value; match it. Not introduced here, but merged in, so it has to be fixed here. --------- Co-authored-by: Merge Sim --- src/main/ipc/runtime.test.ts | 1 + ...ude-structured-session-integration.test.ts | 1 + ...ion-tab-agent-capability-mutations.test.ts | 4 +- ...ession-tab-agent-status-projection.test.ts | 57 +++- .../session-tab-agent-status-projection.ts | 2 +- .../rpc/methods/session-tab-close-methods.ts | 8 +- .../methods/session-tab-mutation-methods.ts | 8 +- .../rpc/methods/session-tabs-inventory.ts | 12 +- .../session-tabs-snapshot.test-fixture.ts | 23 ++ .../session-tabs-structured-restore.test.ts | 109 ++++--- .../runtime/rpc/methods/session-tabs.test.ts | 25 +- src/main/runtime/rpc/methods/session-tabs.ts | 6 +- ...structured-agent-session-admission.test.ts | 112 +++++++ ...ession-gate-classification.test-fixture.ts | 79 +++++ .../methods/structured-agent-session-gate.ts | 38 ++- .../structured-agent-session-hold.test.ts | 51 +++ .../methods/structured-agent-session-hold.ts | 3 +- .../structured-agent-session-policy.test.ts | 101 ++++++ .../structured-agent-session-policy.ts | 27 +- ...ed-agent-session-precommit-refusal.test.ts | 3 + ...ructured-agent-session-rpc.test-fixture.ts | 270 ++++++++++++++++ .../methods/structured-agent-session.test.ts | 301 ++++-------------- .../rpc/methods/structured-agent-session.ts | 12 +- ...d-agent-session-integration-replay.test.ts | 1 + ...ructured-agent-session-integration.test.ts | 1 + ...ss-version-agent-session-wire.unit.test.ts | 1 + 26 files changed, 886 insertions(+), 370 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-snapshot.test-fixture.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 07010087363..3e4e34161a4 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -147,6 +147,7 @@ describe('registerRuntimeHandlers', () => { } const runtime = { getRuntimeId: vi.fn().mockReturnValue('runtime-1'), + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), restoreStructuredAgentSessionTabs: vi.fn(async () => undefined), listMobileSessionTabs: vi.fn(async () => ({ worktree: 'workspace-1', diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index e9cba45ffa9..d464d87e8f7 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -391,6 +391,7 @@ beforeEach(async () => { } const runtime = { getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ ...ensureParams(1), diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 183f981ccee..0a1076bd8f6 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -180,7 +180,9 @@ function createFixture( getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), getClientSettings: () => ({ - experimentalStructuredNativeChat: options.structuredNativeChatEnabled === true + // Why: defaults on, so a fixture that says nothing about the setting exercises capability + // gating alone; callers opt into the off case explicitly. + experimentalStructuredNativeChat: options.structuredNativeChatEnabled !== false }), ...calls } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index cf68f7739c0..61bb30bdbcf 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -87,7 +87,9 @@ describe('projectSessionTabAgentStatus', () => { } ] } - const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', []) + // A paired client that never negotiated the capability, with the setting on: mobile keeps an + // unrenderable row under a fallback title, so only a non-mobile old client still loses them. + const oldClient = projectSessionTabAgentStatus(snapshot, 'runtime', [], true) expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal']) expect(oldClient.activeTabId).toBe('tab-1::leaf-1') expect(oldClient.activeTabType).toBe('terminal') @@ -96,11 +98,6 @@ describe('projectSessionTabAgentStatus', () => { expect(oldClient.tabGroups).toHaveLength(1) expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) - expect( - projectSessionTabAgentStatus(snapshot, 'mobile', [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) - ).toEqual(oldClient) expect( projectSessionTabAgentStatus( snapshot, @@ -118,10 +115,25 @@ describe('projectSessionTabAgentStatus', () => { ) expect(capableMobile).toBe(snapshot) - const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) + const capable = projectSessionTabAgentStatus( + snapshot, + 'runtime', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) expect(capable).toBe(snapshot) + + // The host setting is policy for every caller, so a capable desktop client with the + // setting off sees the same projection an old client does. + expect( + projectSessionTabAgentStatus( + snapshot, + 'runtime', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + false + ) + ).toEqual(oldClient) + expect(projectSessionTabAgentStatus(snapshot, undefined, undefined, false)).toEqual(oldClient) }) const claudeSnapshot = { @@ -276,8 +288,10 @@ describe('projectSessionTabAgentStatus', () => { ) it('keeps Claude rows on the local renderer, which negotiates nothing', () => { - expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot) - expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined, true)).toBe( + claudeSnapshot + ) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [], true)).toBe(claudeSnapshot) }) it('leaves Codex rows untouched whether or not the Claude capability is present', () => { @@ -295,11 +309,11 @@ describe('projectSessionTabAgentStatus', () => { ) } } - expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly) + expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined, true)).toBe(codexOnly) }) it('withholds session boundaries from legacy paired clients', () => { - const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) + const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [], true) expect(projected.tabs[0]).not.toHaveProperty('agentStatus') }) @@ -308,7 +322,12 @@ describe('projectSessionTabAgentStatus', () => { const snapshot = makeSnapshot(true) expect( - projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY]) + projectSessionTabAgentStatus( + snapshot, + 'runtime', + [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY], + true + ) ).toBe(snapshot) }) @@ -317,8 +336,12 @@ describe('projectSessionTabAgentStatus', () => { const mobileBoundary = makeSnapshot(true) const runtimeCompletion = makeSnapshot(false) - expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary) - expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary) - expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion) + expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined, true)).toBe( + localBoundary + ) + expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [], true)).toBe(mobileBoundary) + expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [], true)).toBe( + runtimeCompletion + ) }) }) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 4496fdc5435..e2aa9ae7b00 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -55,7 +55,7 @@ export function projectSessionTabAgentStatus[2], - structuredNativeChatEnabled?: boolean + structuredNativeChatEnabled: boolean ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( projectSessionTabAgentStatus( @@ -41,12 +41,6 @@ export function projectSessionTabsForClient( ) } -function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined { - return context.clientKind === 'mobile' - ? isStructuredNativeChatEnabled(context.runtime) - : undefined -} - function projectInventory( inventory: SessionTabsInventory, context: RpcContext @@ -57,7 +51,7 @@ function projectInventory( snapshot, context.clientKind, context.clientCapabilities, - structuredNativeChatEnabledForContext(context) + isStructuredNativeChatEnabled(context.runtime) ) ), ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) @@ -128,7 +122,7 @@ export async function subscribeSessionTabsInventory( snapshot, context.clientKind, context.clientCapabilities, - structuredNativeChatEnabledForContext(context) + isStructuredNativeChatEnabled(context.runtime) ) as SessionTabsChange const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => { if (snapshot.navigationIntent === undefined) { diff --git a/src/main/runtime/rpc/methods/session-tabs-snapshot.test-fixture.ts b/src/main/runtime/rpc/methods/session-tabs-snapshot.test-fixture.ts new file mode 100644 index 00000000000..1346512e52d --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-snapshot.test-fixture.ts @@ -0,0 +1,23 @@ +export function visibleSnapshot() { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal' as const, + tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready' as const, + terminal: 'pty-1', + isActive: true + } + ] + } +} diff --git a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts index 083f334285e..c520294edba 100644 --- a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -1,22 +1,79 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, type Mock } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' +import { visibleSnapshot } from './session-tabs-snapshot.test-fixture' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } } +function makeRuntime(experimentalStructuredNativeChat: boolean): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService +} + +describe('structured session tab restoration follows one rule for every caller', () => { + it('does not restore for the desktop renderer while the host setting is off', async () => { + const runtime = makeRuntime(false) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) + + it('restores for the desktop renderer once the host setting is on', async () => { + const runtime = makeRuntime(true) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) + + it('restores for an in-process caller on the same setting that admits remote clients', async () => { + const restoreCallsBySetting = new Map() + for (const enabled of [false, true]) { + const runtime = makeRuntime(enabled) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + await dispatcher.dispatch(makeRequest('session.tabs.list', { worktree: 'id:wt-1' })) + + restoreCallsBySetting.set( + enabled, + (runtime.restoreStructuredAgentSessionTabs as unknown as Mock).mock.calls.length + ) + } + + expect(restoreCallsBySetting.get(false)).toBe(0) + expect(restoreCallsBySetting.get(true)).toBe(1) + }) +}) + describe('session tab structured restore gating', () => { it('does not restore structured tabs for mobile while the host setting is off', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService + const runtime = makeRuntime(false) const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) const response = await dispatcher.dispatch( @@ -34,12 +91,7 @@ describe('session tab structured restore gating', () => { // Why: an old build has no capability to advertise, and skipping the restore left it with // nothing to project after a desktop restart — neither the chat nor its fallback row. it('restores structured tabs for a mobile client that advertises no capability', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService + const runtime = makeRuntime(true) const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) const response = await dispatcher.dispatch( @@ -52,12 +104,7 @@ describe('session tab structured restore gating', () => { }) it('restores structured tabs for mobile once the setting is present', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), - restoreStructuredAgentSessionTabs: vi.fn(), - listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) - } as unknown as OrcaRuntimeService + const runtime = makeRuntime(true) const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) const response = await dispatcher.dispatch( @@ -72,27 +119,3 @@ describe('session tab structured restore gating', () => { expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) }) }) - -function visibleSnapshot() { - return { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1::leaf-1', - activeTabType: 'terminal' as const, - tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], - tabs: [ - { - type: 'terminal' as const, - id: 'tab-1::leaf-1', - parentTabId: 'tab-1', - leafId: 'leaf-1', - title: 'Terminal', - status: 'ready' as const, - terminal: 'pty-1', - isActive: true - } - ] - } -} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index be61fc55edf..f295d2626da 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -4,6 +4,7 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' +import { visibleSnapshot } from './session-tabs-snapshot.test-fixture' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -816,27 +817,3 @@ describe('session tab RPC methods', () => { ) }) }) - -function visibleSnapshot() { - return { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'tab-1::leaf-1', - activeTabType: 'terminal' as const, - tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }], - tabs: [ - { - type: 'terminal' as const, - id: 'tab-1::leaf-1', - parentTabId: 'tab-1', - leafId: 'leaf-1', - title: 'Terminal', - status: 'ready' as const, - terminal: 'pty-1', - isActive: true - } - ] - } -} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 34d50a2a76b..6296c462a39 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -28,7 +28,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, clientCapabilities, - clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + isStructuredNativeChatEnabled(runtime) ) } }), @@ -121,7 +121,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ initial, clientKind, clientCapabilities, - clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + isStructuredNativeChatEnabled(runtime) ) }) initialized = true @@ -137,7 +137,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ snapshot, clientKind, clientCapabilities, - clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + isStructuredNativeChatEnabled(runtime) ) }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts new file mode 100644 index 00000000000..de62b6b5b52 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -0,0 +1,112 @@ +// Admission can be revoked while sessions are still open: the host setting is turned off with a +// chat already on screen. What the caller may still do to that chat is the rule this suite pins. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + ADMISSION_METHODS, + CLEANUP_METHODS +} from './structured-agent-session-gate-classification.test-fixture' +import { + call, + clearStructuredHostStub, + envelope, + hostCalls, + installStructuredHostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) + +afterEach(() => { + clearStructuredHostStub() +}) + +describe('admission revoked while a session is still open', () => { + // The host setting is admission control. Turning it off must not strand a chat that was opened + // while it was on: the pane is still mounted, so its close has to land. + const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) } + + it.each(CLEANUP_METHODS)( + 'still serves $method after the host setting is turned off', + async ({ method, params, hostCall }) => { + const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) + + expect(response).toMatchObject({ ok: true }) + // `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its + // result payload is the observable effect. + if (hostCall === 'unsubscribe') { + expect(response).toMatchObject({ result: { unsubscribed: true } }) + } else { + expect(hostCalls[hostCall]).toHaveBeenCalled() + } + } + ) + + it('stops the provider child when closing a chat the setting no longer admits', async () => { + const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT, { + ...SETTING_OFF + }) + + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + // The durable tab has to be retired too, or the chat comes back on the next sync. + expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) + }) + + it('cancels an in-flight turn the setting no longer admits', async () => { + const response = await call( + 'agentSession.cancel', + { envelope: envelope(), turnId: 'turn-1' }, + STRUCTURED_CLIENT, + SETTING_OFF + ) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledOnce() + }) + + it.each(['runtime', 'mobile'] as const)( + 'lets a %s client close a chat it already owns', + async (clientKind) => { + const response = await call( + 'agentSession.close', + { sessionId: SESSION }, + { clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }, + SETTING_OFF + ) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + } + ) + + it('lets an in-process caller close, which is how terminal disposal retires a chat', async () => { + const response = await call( + 'agentSession.close', + { sessionId: SESSION }, + undefined, + SETTING_OFF + ) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + }) + + it.each(ADMISSION_METHODS)( + 'keeps $method refused once the setting is off', + async ({ method, params }) => { + const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) + + // Asserting the gate's own code, not merely `ok: false`: a params-validation failure would + // pass a bare falsy check and hide a gate that had stopped refusing. + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts new file mode 100644 index 00000000000..07616a9d843 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -0,0 +1,79 @@ +// The method-to-gate classification from `structured-agent-session-gate.ts`, as a table the +// suites iterate. Adding an `agentSession.*` method means adding it to exactly one of these. + +import { + attachParams, + envelope, + sendParams, + SESSION +} from './structured-agent-session-rpc.test-fixture' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' + +/** Stops or retires work the caller already owns, so admission may already have been revoked. */ +export const CLEANUP_METHODS = [ + { + method: 'agentSession.close', + params: { sessionId: SESSION }, + hostCall: 'close' + }, + { + method: 'agentSession.cancel', + params: { envelope: envelope(), turnId: 'turn-1' }, + hostCall: 'cancel' + }, + { + method: 'agentSession.release', + params: { sessionId: SESSION, holderId: 'surface-1' }, + hostCall: 'release' + }, + { + method: 'agentSession.unsubscribe', + params: { sessionId: SESSION }, + hostCall: 'unsubscribe' + } +] as const + +/** Starts, extends, retains or reads work, so every one stays refused once the setting is off. */ +export const ADMISSION_METHODS = [ + { method: 'agentSession.createSupport', params: { worktree: 'id:workspace-1', agent: 'codex' } }, + { + method: 'agentSession.create', + params: { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree: 'id:workspace-1', agent: 'codex' } + }) + }), + worktree: 'id:workspace-1', + agent: 'codex' + } + }, + { method: 'agentSession.ensure', params: attachParams() }, + { method: 'agentSession.send', params: sendParams() }, + { + method: 'agentSession.respondToApproval', + params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } + }, + { + method: 'agentSession.respondToQuestion', + params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'yes' } + }, + { + method: 'agentSession.setOption', + params: { envelope: envelope(), key: 'model', value: 'gpt-live' } + }, + { + method: 'agentSession.requestHandoff', + params: { envelope: envelope(), direction: 'to-tui', mode: 'now' } + }, + { method: 'agentSession.handoffStatus', params: { sessionId: SESSION } }, + { method: 'agentSession.options', params: { sessionId: SESSION } }, + { method: 'agentSession.history', params: { sessionId: SESSION, direction: 'tail' } }, + { method: 'agentSession.subscribe', params: { sessionId: SESSION } }, + { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, + { method: 'agentSession.reveal', params: { sessionId: SESSION } }, + { method: 'agentSession.subscribeStatus', params: null } +] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index d918614ed47..de83820e9c3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -12,7 +12,10 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + supportsStructuredAgentSessionCapability, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' /** * In-process callers are the same build as the host, so they carry no negotiated @@ -37,6 +40,39 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo return host } +/** + * WHICH GATE DOES A NEW `agentSession.*` METHOD GET? + * + * The host setting is admission control, and admission can be revoked while sessions are still + * open. So the surface splits by what a method does to work in flight, not by how dangerous it + * sounds: + * + * - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means + * no new turns, no new holds, no new reads. create, send, ensure, setOption, requestHandoff, + * subscribe, hold, reveal, history, options and the status stream all live here. + * - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close, + * cancel, unsubscribe and release live here. + * + * Cleanup keeps working after the setting is turned off because the alternative strands the user: + * a session opened while the setting was on stays open, and refusing its close leaves a chat with + * a live provider child that its own owner can no longer shut down. Stopping is never the thing + * the policy exists to prevent. + * + * Cleanup is not an escape hatch. It still demands the negotiated wire capability, so a client + * that never advertised the surface still cannot see it, and it never creates a host — it can + * only retire what already exists. + */ +export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { + if (!supportsStructuredAgentSessionCapability(ctx)) { + throw new Error('structured_agent_session_unsupported') + } + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + return host +} + /** Builds the host for the calls that address a session by durable record rather than by live * state: attach, which is the only way a session comes into being, plus hold and reveal, which * each reach for a record on disk this process may not have opened yet. Every other method diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 61bb3849bc7..4e6dfdf45bf 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -41,6 +41,7 @@ let runtime: OrcaRuntimeService let dispatcher: RpcDispatcher let closeSession: Mock> let requests = 0 +let structuredNativeChatEnabled = true async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -57,6 +58,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) resetHostTestOperationIds() requests = 0 + structuredNativeChatEnabled = true closeSession = vi.fn(async () => true) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ @@ -86,6 +88,13 @@ beforeEach(async () => { }) setStructuredAgentSessionHost(host) runtime = new OrcaRuntimeService() + // The structured surface is settings-gated for every caller, in-process included. + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + ({ experimentalStructuredNativeChat: structuredNativeChatEnabled }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ ok: true @@ -121,6 +130,23 @@ describe('a client that holds a session', () => { expect(closeSession).toHaveBeenCalledWith(SESSION) }) + it('releases its hold and cleanup after the setting is disabled', async () => { + const release = vi.spyOn(host, 'release') + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + structuredNativeChatEnabled = false + + expect( + await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true }) + const releaseCallsAfterRpc = release.mock.calls.length + runtime.cleanupSubscriptionsForConnection(CONNECTION) + + expect(releaseCallsAfterRpc).toBe(2) + expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc) + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + it('does not report success when no provider child can be acquired', async () => { const response = await call('agentSession.hold', { sessionId: 'session-missing', @@ -213,6 +239,31 @@ describe('a client that disappears without cleanup', () => { expect(closeSession).toHaveBeenCalledWith(SESSION) }) + it('unsubscribes and releases stream retention after the setting is disabled', async () => { + await dispatcher.dispatchStreaming( + { + id: 'stream-disabled-cleanup', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + CLIENT + ) + expect(host.isHeld(SESSION)).toBe(true) + structuredNativeChatEnabled = false + + expect( + await call('agentSession.unsubscribe', { + sessionId: SESSION, + subscriptionId: 'stream-disabled-cleanup' + }) + ).toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + it('does not let a stream alone resume a released session', async () => { await host.close(SESSION) expect(host.hasSession(SESSION)).toBe(false) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 346082bd576..280804711e6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -12,6 +12,7 @@ import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled, + requireStructuredCleanupHost, requireStructuredHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' @@ -53,7 +54,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [ name: 'agentSession.release', params: HoldParams, handler: async (params, ctx) => { - const host = requireStructuredHost(ctx) + const host = requireStructuredCleanupHost(ctx) const holderKey = holderKeyFor(ctx, params.holderId) host.release(params.sessionId, holderKey) // Retires the backstop too; its release is a no-op against a holder already gone. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts new file mode 100644 index 00000000000..6c765119375 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' + +function runtimeWithSetting( + experimentalStructuredNativeChat: boolean +): Pick { + return { + getClientSettings: () => ({ experimentalStructuredNativeChat }) + } as unknown as Pick +} + +const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + +/** Every caller shape that reaches the policy: desktop renderer, paired phone, in-process. */ +const CALLERS = [ + { name: 'desktop renderer', clientKind: 'runtime' as const, clientCapabilities: CAPABLE }, + { name: 'paired mobile', clientKind: 'mobile' as const, clientCapabilities: CAPABLE }, + { name: 'in-process', clientKind: undefined, clientCapabilities: undefined } +] + +describe('supportsStructuredAgentSessions', () => { + it.each([true, false])('admits every caller alike when the setting is %s', (enabled) => { + const decisions = CALLERS.map((caller) => + supportsStructuredAgentSessions({ + clientKind: caller.clientKind, + clientCapabilities: caller.clientCapabilities, + runtime: runtimeWithSetting(enabled) + }) + ) + + expect(decisions).toEqual([enabled, enabled, enabled]) + }) + + it('admits a capability-less in-process caller, which negotiates nothing', () => { + expect( + supportsStructuredAgentSessions({ + clientKind: undefined, + clientCapabilities: undefined, + runtime: runtimeWithSetting(true) + }) + ).toBe(true) + }) + + it('still refuses a remote client that did not advertise the capability', () => { + for (const clientKind of ['runtime', 'mobile'] as const) { + expect( + supportsStructuredAgentSessions({ + clientKind, + clientCapabilities: [], + runtime: runtimeWithSetting(true) + }) + ).toBe(false) + } + }) + + it('leaves desktop launch admission unchanged, because launches require the setting anyway', () => { + // `agent-launch-routing.ts` refuses to route a structured launch unless + // `experimentalStructuredNativeChat` is on, so the only state a desktop launch can + // reach the host in is setting-on — which admits exactly as it did before. + expect( + supportsStructuredAgentSessions({ + clientKind: 'runtime', + clientCapabilities: CAPABLE, + runtime: runtimeWithSetting(true) + }) + ).toBe(true) + }) + + it('reads the setting from the caller-supplied value when no runtime is available', () => { + expect( + supportsStructuredAgentSessions({ + clientKind: 'runtime', + clientCapabilities: CAPABLE, + structuredNativeChatEnabled: true + }) + ).toBe(true) + expect( + supportsStructuredAgentSessions({ + clientKind: 'runtime', + clientCapabilities: CAPABLE, + structuredNativeChatEnabled: false + }) + ).toBe(false) + }) + + it('treats an unreadable settings store as off rather than admitting', () => { + expect( + supportsStructuredAgentSessions({ + clientKind: 'runtime', + clientCapabilities: CAPABLE, + runtime: { + getClientSettings: () => { + throw new Error('settings unavailable') + } + } as unknown as Pick + }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 4fe38474ec6..46a1ee34c45 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -20,18 +20,24 @@ export function isStructuredNativeChatEnabled( } } -export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean { - if (context.clientKind === undefined) { - return true - } - const hasCapability = +export function supportsStructuredAgentSessionCapability( + context: Pick +): boolean { + return ( + context.clientKind === undefined || context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true - if (!hasCapability) { + ) +} + +/** + * One rule for every caller. The host setting is policy and applies to desktop, mobile and + * in-process callers alike; the negotiated capability is a wire term, so it is asked of remote + * clients only — in-process callers are the same build as the host and never negotiate one. + */ +export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean { + if (!supportsStructuredAgentSessionCapability(context)) { return false } - if (context.clientKind !== 'mobile') { - return true - } return ( context.structuredNativeChatEnabled === true || (context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false) @@ -41,7 +47,8 @@ export function supportsStructuredAgentSessions(context: StructuredPolicyContext export function structuredNativeChatProjectionEnabled(args: { clientKind: 'mobile' | 'runtime' | undefined clientCapabilities: readonly RuntimeCapability[] | undefined - structuredNativeChatEnabled?: boolean + // Required so no call site can silently project as if the host setting were off. + structuredNativeChatEnabled: boolean }): boolean { return supportsStructuredAgentSessions(args) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index 1a62045c85b..f34ecb5d6cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -71,6 +71,9 @@ async function create( ): Promise { const runtime = { getRuntimeId: () => 'runtime-1', + // The structured surface is settings-gated for every caller; these fixtures probe the + // pre-commit boundary, which only runs once the gate admits the call. + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts new file mode 100644 index 00000000000..360af5d4d31 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -0,0 +1,270 @@ +// The `agentSession.*` dispatcher harness, shared by the suites that exercise the wire +// boundary. `hostCalls` and `runtimeCalls` keep one identity for the process and are +// repopulated per test, so a suite can read `hostCalls.close` without re-importing it. + +import { vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSubscriber +} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +export const SESSION = 'session-alpha' +export const FINGERPRINT = 'f'.repeat(64) +export const OPERATION = '1800000000000-00000000000000000000000000000001' + +export function envelope(overrides: Record = {}) { + return { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: 1, + payloadFingerprint: FINGERPRINT, + ...overrides + } +} + +export function sendParams(overrides: Record = {}) { + return { + envelope: envelope(), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...overrides + } +} + +export function attachParams(overrides: Record = {}) { + return { + envelope: envelope({ expectedRuntimeFence: null }), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + ...overrides + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +export const hostCalls: Record> = {} +export const runtimeCalls: Record> = {} + +function reset(record: Record>): void { + for (const key of Object.keys(record)) { + delete record[key] + } +} + +export const STATUS_SESSION = 'session-status' +export const STATUS_ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] } + }, + { + itemId: 'turn-1', + sequence: 2, + revision: 1, + observedAt: 2, + body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } } + } +] + +/** One indexed session over a journal that reads back fixed items; the projection is real. */ +function statusFeed(): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + STATUS_SESSION, + { + journal: { + isReadOnly: false, + lastActivityAt: () => 2, + snapshot: () => ({ items: STATUS_ITEMS }) + } as unknown as AgentSessionJournal, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + } + ] + ]), + getRecord: () => null, + now: () => 1_000 + }) +} + +export function hostStub(): StructuredAgentSessionHost { + reset(hostCalls) + Object.assign(hostCalls, { + attach: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + sessionId: SESSION, + fence: 1, + page: { + sessionId: SESSION, + epoch: 'epoch-a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-a', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + close: vi.fn(async () => undefined), + revealSession: vi.fn(async () => ({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex' as const, + readable: true + })), + setSessionTabVisibility: vi.fn(async () => undefined), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + status: { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + } + } + })), + supportsCreate: vi.fn(() => true), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), + readOptions: vi.fn(async () => ({ + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + current: { model: 'gpt-live' } + })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + // A real feed, so the snapshot this method hands back is a genuine projection rather + // than a shape the stub restated. + subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => + statusFeed().subscribe(subscriber) + ), + unsubscribe: vi.fn(), + release: vi.fn() + }) + return hostCalls as unknown as StructuredAgentSessionHost +} + +export function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { + reset(runtimeCalls) + Object.assign(runtimeCalls, { + getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: params.agent, + agent: params.agent, + accountHome: { + variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex' + }, + options: + params.agent === 'claude' + ? { model: 'opus', effort: 'high' } + : { model: 'gpt-5.6-sol', effort: 'medium' }, + runtimeKind: 'native' + })), + publishStructuredAgentSessionTab: vi.fn() + }) + const runtime = { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn(), + ...runtimeCalls, + ...runtimeOverrides + } + return new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +} + +/** The reply path is the only one that carries a client's negotiated identity, + * which is exactly what the capability gate reads. */ +export async function call( + method: string, + params: unknown, + client?: { + clientId?: string + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: string[] + }, + runtimeOverrides: Record = {} +): Promise { + const replies: RpcResponse[] = [] + await dispatcher(runtimeOverrides).dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +export const STRUCTURED_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} +export const STRUCTURED_MOBILE_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +/** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */ +export function installStructuredHostStub(): void { + setStructuredAgentSessionHost(hostStub()) +} + +export function clearStructuredHostStub(): void { + setStructuredAgentSessionHost(null) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 5a38ae4ce2d..13e2383e667 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -1,15 +1,8 @@ // The wire boundary: who may see `agentSession.*` at all, and what shapes it -// accepts once they can. +// accepts once they can. The dispatcher harness lives in the shared fixture. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { - StructuredAgentSessionStatusFeed, - type StructuredAgentSessionStatusSubscriber -} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -17,252 +10,31 @@ import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcRequest, RpcResponse } from '../core' -import { RpcDispatcher } from '../dispatcher' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { ALL_RPC_METHODS } from './index' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' - -const SESSION = 'session-alpha' -const FINGERPRINT = 'f'.repeat(64) -const OPERATION = '1800000000000-00000000000000000000000000000001' - -function envelope(overrides: Record = {}) { - return { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: 1, - payloadFingerprint: FINGERPRINT, - ...overrides - } -} - -function sendParams(overrides: Record = {}) { - return { - envelope: envelope(), - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - ...overrides - } -} - -function attachParams(overrides: Record = {}) { - return { - envelope: envelope({ expectedRuntimeFence: null }), - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: 'thread-1' }, - ...overrides - } -} - -function request(method: string, params: unknown): RpcRequest { - return { id: 'request-1', authToken: 'token', method, params } -} - -let hostCalls: Record> -let runtimeCalls: Record> - -const STATUS_SESSION = 'session-status' -const STATUS_ITEMS: AgentJournalRenderItem[] = [ - { - itemId: 'user-1', - sequence: 1, - revision: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] } - }, - { - itemId: 'turn-1', - sequence: 2, - revision: 1, - observedAt: 2, - body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } } - } -] - -/** One indexed session over a journal that reads back fixed items; the projection is real. */ -function statusFeed(): StructuredAgentSessionStatusFeed { - return new StructuredAgentSessionStatusFeed({ - sessions: new Map([ - [ - STATUS_SESSION, - { - journal: { - isReadOnly: false, - lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }) - } as unknown as AgentSessionJournal, - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } - } - ] - ]), - getRecord: () => null, - now: () => 1_000 - }) -} - -function hostStub(): StructuredAgentSessionHost { - hostCalls = { - attach: vi.fn(async () => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-a', sequence: 0 }, - value: { - sessionId: SESSION, - fence: 1, - page: { - sessionId: SESSION, - epoch: 'epoch-a', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { - oldest: null, - newest: null, - nextCursor: { epoch: 'epoch-a', sequence: 0 } - }, - liveCursor: { epoch: 'epoch-a', sequence: 0 }, - hasOlder: false, - hasNewer: false - }, - unconfirmedClientMessageIds: [] - } - })), - send: vi.fn(async () => ({ ok: true, replayed: false })), - cancel: vi.fn(async () => ({ ok: true, replayed: false })), - close: vi.fn(async () => undefined), - revealSession: vi.fn(async () => ({ - sessionId: SESSION, - workspaceId: 'workspace-1', - agent: 'codex' as const, - readable: true - })), - setSessionTabVisibility: vi.fn(async () => undefined), - respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), - setOption: vi.fn(async () => ({ ok: true, replayed: false })), - requestHandoff: vi.fn(async () => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-a', sequence: 0 }, - value: { - status: { - owner: 'native', - direction: null, - phase: 'idle', - stage: null, - operationId: null - } - } - })), - supportsCreate: vi.fn(() => true), - handoffStatus: vi.fn(async () => ({ owner: 'native' })), - readOptions: vi.fn(async () => ({ - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - current: { model: 'gpt-live' } - })), - history: vi.fn(() => ({ ok: true, page: { items: [] } })), - subscribe: vi.fn(() => () => undefined), - // A real feed, so the snapshot this method hands back is a genuine projection rather - // than a shape the stub restated. - subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => - statusFeed().subscribe(subscriber) - ), - unsubscribe: vi.fn() - } - return hostCalls as unknown as StructuredAgentSessionHost -} - -function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { - runtimeCalls = { - getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), - resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ - envelope: params.envelope, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: params.agent, - agent: params.agent, - accountHome: { - variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex' - }, - options: - params.agent === 'claude' - ? { model: 'opus', effort: 'high' } - : { model: 'gpt-5.6-sol', effort: 'medium' }, - runtimeKind: 'native' - })), - publishStructuredAgentSessionTab: vi.fn() - } - const runtime = { - getRuntimeId: () => 'runtime-1', - registerSubscriptionCleanup: vi.fn(), - cleanupSubscription: vi.fn(), - cleanupSubscriptionsByPrefix: vi.fn(), - ...runtimeCalls, - ...runtimeOverrides - } - return new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS - }) -} - -/** The reply path is the only one that carries a client's negotiated identity, - * which is exactly what the capability gate reads. */ -async function call( - method: string, - params: unknown, - client?: { - clientId?: string - clientKind?: 'mobile' | 'runtime' - clientCapabilities?: string[] - }, - runtimeOverrides: Record = {} -): Promise { - const replies: RpcResponse[] = [] - await dispatcher(runtimeOverrides).dispatchStreaming( - request(method, params), - (raw) => replies.push(JSON.parse(raw) as RpcResponse), - client - ) - const first = replies[0] - if (!first) { - throw new Error(`no reply for ${method}`) - } - return first -} - -const STRUCTURED_CLIENT = { - clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] -} -const STRUCTURED_MOBILE_CLIENT = { - clientKind: 'mobile' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] -} +import { CLEANUP_METHODS } from './structured-agent-session-gate-classification.test-fixture' +import { + attachParams, + call, + clearStructuredHostStub, + envelope, + hostCalls, + installStructuredHostStub, + runtimeCalls, + SESSION, + sendParams, + STATUS_SESSION, + STRUCTURED_CLIENT, + STRUCTURED_MOBILE_CLIENT +} from './structured-agent-session-rpc.test-fixture' beforeEach(() => { - setStructuredAgentSessionHost(hostStub()) + installStructuredHostStub() }) afterEach(() => { - setStructuredAgentSessionHost(null) + clearStructuredHostStub() }) describe('agentSession.reveal', () => { @@ -454,6 +226,41 @@ describe('capability gating', () => { expect(hostCalls.send).toHaveBeenCalledTimes(1) }) + it.each(CLEANUP_METHODS)( + 'keeps $method hidden from remote clients without the capability', + async ({ method, params, hostCall }) => { + const response = await call(method, params, { + clientKind: 'runtime', + clientCapabilities: [] + }) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls[hostCall]).not.toHaveBeenCalled() + } + ) + + it.each(CLEANUP_METHODS)( + 'does not install a host for cleanup-only method $method', + async ({ method, params }) => { + const ensureHost = vi.fn() + setStructuredAgentSessionHost(null) + + const response = await call(method, params, STRUCTURED_CLIENT, { + getClientSettings: () => ({ experimentalStructuredNativeChat: false }), + ensureStructuredAgentSessionHost: ensureHost + }) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(ensureHost).not.toHaveBeenCalled() + } + ) + it('serves an in-process caller, which negotiates no capabilities at all', async () => { const response = await call('agentSession.send', sendParams()) expect(response).toMatchObject({ ok: true }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index f086fa7ed66..ba3a5d7d6a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -14,6 +14,7 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext import { ensureStructuredHostInstalled as ensureHostInstalled, requireStructuredCapability, + requireStructuredCleanupHost, requireStructuredHost as requireHost, structuredCallerFor as callerFor, supportsStructuredSessions @@ -178,9 +179,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) }), defineMethod({ + // Stopping a turn, so it stays available after admission is revoked: see the gate's rule. name: 'agentSession.cancel', params: CancelParams, - handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), defineMethod({ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the @@ -188,7 +190,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ name: 'agentSession.close', params: OptionsParams, handler: async (params, ctx) => { - const host = requireHost(ctx) + // Cleanup gate: turning the host setting off must not strand an open chat whose owner can + // then never close it. See the rule on `requireStructuredCleanupHost`. + const host = requireStructuredCleanupHost(ctx) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) @@ -284,7 +288,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ name: 'agentSession.unsubscribe', params: UnsubscribeParams, handler: async (params, ctx) => { - requireHost(ctx) + // Why: cleanup must stay available after the setting is disabled, so an admitted caller can + // retire resources it already owns; the base still comes from main's shared helper. + requireStructuredCleanupHost(ctx) const base = subscriptionBaseFor(ctx, params.sessionId) if (params.subscriptionId) { ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index e5baa032341..990aa293ee4 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -242,6 +242,7 @@ beforeEach(async () => { configuredCodexProfile = 'configured' const runtime = { getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async () => { const { diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2982a6530b2..aa14aaa5639 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -290,6 +290,7 @@ beforeEach(async () => { configuredCodexProfile = 'configured' const runtime = { getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async () => { const { diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index ef35eefc7f2..e939a479f58 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -262,6 +262,7 @@ function runtimeStub(): unknown { const cleanups = new Map void>() return { getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), ensureStructuredAgentSessionHost: async () => undefined, getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async () => { From fa5ef9988596987c425b83da4a3c3041d19d1a9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:34:50 -0700 Subject: [PATCH 4/5] fix(native-chat): settle structured chat turns stranded by a restart (#19122) * fix: settle structured chat turns after restart * fix: preserve unconfirmed turn cancellation state * test: preserve unconfirmed turn lifecycle * test: narrow unconfirmed cancellation coverage * fix: keep intentional TUI closes out of recovery * test: keep branch rename journal mock current * fix: settle dead TUI handoffs before reacquire * fix: preserve handoff stage after retry settlement --------- Co-authored-by: Merge Sim --- ...-agent-session-handoff-flow-runner.test.ts | 1 + ...-agent-session-handoff-owner-close.test.ts | 52 ++++++ ...tured-agent-session-handoff-owner-close.ts | 3 +- ...tructured-agent-session-handoff-reverse.ts | 7 + ...-agent-session-handoff-test-coordinator.ts | 1 + .../structured-agent-session-handoff-types.ts | 1 + .../structured-agent-session-handoff.test.ts | 2 + .../structured-agent-session-host-handoff.ts | 8 + .../structured-agent-session-host.ts | 2 +- ...-session-live-tui-restart-survival.test.ts | 2 + ...ed-agent-session-proven-dead-retry.test.ts | 24 +++ ...ed-agent-session-readable-restorer.test.ts | 1 + ...uctured-agent-session-readable-restorer.ts | 4 + ...ured-agent-session-restart-restore.test.ts | 99 +++++++++++- ...tructured-agent-session-restart-restore.ts | 5 + .../structured-agent-session-reveal.test.ts | 1 + .../structured-agent-session-reveal.ts | 8 +- ...ructured-agent-session-settlement-retry.ts | 35 ++-- .../structured-agent-session-turns.test.ts | 46 ++++++ ...tructured-agent-session-unexpected-exit.ts | 6 +- ...t-session-wedged-profile-migration.test.ts | 149 +++++++++++++++++- ...-session-eviction-settlement-latch.test.ts | 115 ++++++++++++++ ...agent-session-handoff-lease-transitions.ts | 2 +- .../agent-session-lease-transitions.ts | 9 +- .../runtime/agent-session-record-store.ts | 4 +- ...nt-session-restart-handoff-adjudication.ts | 3 + ...agent-session-restart-lease-transitions.ts | 8 +- .../agent-session-lease-adjudication.ts | 15 +- 28 files changed, 586 insertions(+), 27 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts create mode 100644 src/main/runtime/agent-session-eviction-settlement-latch.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts index 20402c8c05e..286a0dca059 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts @@ -70,6 +70,7 @@ async function failingFlowRunner( throw new Error('unused') }, importTuiHistory: async () => {}, + retryPendingSettlement: async () => true, publish: () => {}, schedule: async () => { throw new Error('scheduling failed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts new file mode 100644 index 00000000000..a947b5e8ecc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' +import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' + +const NOW = 1_800_000_000_000 + +describe('closeRetainedTuiOwner', () => { + it('does not latch unexpected-exit settlement after an intentional close', async () => { + let record = agentSessionRecordFixture(agentSessionLeaseFixture()) + const closeTuiOwner = vi.fn(async () => ({})) + const releaseOwner = vi.fn() + const owner = { + terminal: { handle: 'terminal-1', tabId: 'tab-1', paneKey: 'pane-1', ptyId: 'pty-1' }, + process: record.lease.ownerProcess!, + link: record.providerHandleChain[0]! + } + const deps = { + store: { + transitionHandoff: async ( + _sessionId: string, + transition: (current: typeof record) => typeof record + ) => { + record = transition(record) + return record + } + }, + transport: { closeTuiOwner }, + now: () => NOW + } as unknown as StructuredAgentSessionHandoffDeps + + await closeRetainedTuiOwner({ + sessionId: record.sessionId, + deps, + owner: () => owner, + requireRecord: () => record, + releaseOwner + }) + + expect(closeTuiOwner).toHaveBeenCalledWith(owner) + expect(releaseOwner).toHaveBeenCalledWith(record.sessionId) + expect(record.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed' } + }) + expect(record.lease.settlementRetryRequired).toBeUndefined() + expect(record.lease.settlementRetryId).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts index 56cbe4cdc53..634eab51423 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts @@ -26,7 +26,8 @@ export async function closeRetainedTuiOwner(input: { record: current, expectedFence: record.lease.runtimeFence, probe: { outcome: 'exit-observed' }, - now: input.deps.now() + now: input.deps.now(), + journalSettlement: 'not-required' }) ) input.releaseOwner(input.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index ebfca81525c..1dcd4904895 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -95,6 +95,13 @@ export async function handoffStructuredSessionToNative( ...(transcriptPath ? { transcriptPath } : {}) }) } + if (record.lease.settlementRetryRequired) { + const settled = await deps.retryPendingSettlement(sessionId) + if (!settled) { + throw new Error('The provider-exit terminal journal settlement is still pending.') + } + record = context.requireRecord(sessionId) + } const spawnToken = randomUUID() record = await reserveStoredAgentSessionHandoffOwner(deps.store, { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts index e5dd478f719..81ef23aeb3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -73,6 +73,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator( { fence, recovered: true } ) }, + retryPendingSettlement: async () => true, publish: (_sessionId, status) => input.statuses.push(status), schedule: async (_sessionId, task) => task(), now: () => input.now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 7155826702d..218db8c539c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -81,6 +81,7 @@ export type StructuredAgentSessionHandoffDeps = { fence: number transcriptPath?: string }) => Promise + retryPendingSettlement: (sessionId: string) => Promise prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise activateTuiHistoryCatchup?: (sessionId: string) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index f0f410b66aa..beca21cb63a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -179,6 +179,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator { { fence, recovered: true } ) }, + retryPendingSettlement: async () => true, prepareTuiHistoryCatchup, recoverTuiHistoryCatchup, activateTuiHistoryCatchup, @@ -274,6 +275,7 @@ describe('structured session handoff failure handling', () => { }), acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), importTuiHistory: vi.fn(async () => undefined), + retryPendingSettlement: vi.fn(async () => true), prepareTuiHistoryCatchup, recoverTuiHistoryCatchup, activateTuiHistoryCatchup, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index abd2268c809..cb316850e5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' type HostHandoffAccess = { session: (sessionId: string) => StructuredAgentSessionHostSession @@ -92,6 +93,13 @@ export function createStructuredAgentSessionHostHandoff( acquireNativeStop: async (sessionId, turnId, fence) => (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, importTuiHistory: (input) => importTuiHistory(deps, host, input), + retryPendingSettlement: (sessionId) => + retryLoadedStructuredAgentSessionSettlement({ + deps, + sessionId, + session: host.session(sessionId), + now: host.now + }), prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence), recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence), activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId), 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 14ca9c5b7b5..22557e87c52 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 @@ -127,7 +127,7 @@ export class StructuredAgentSessionHost { ), evict: (sessionId) => this.close(sessionId) }) - this.restore = createStructuredAgentSessionHostRestore(deps, { + this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts index 90b68194c4f..3a8de86c3de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts @@ -104,6 +104,7 @@ describe('structured session live TUI restart survival', () => { suspendNative: vi.fn(), acquireNative: vi.fn(), importTuiHistory: vi.fn(), + retryPendingSettlement: vi.fn(async () => true), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW @@ -224,6 +225,7 @@ describe('structured session live TUI restart survival', () => { suspendNative: vi.fn(), acquireNative: vi.fn(), importTuiHistory: vi.fn(), + retryPendingSettlement: vi.fn(async () => true), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts index 3caba894cb9..aed78f3e5b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts @@ -3,12 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' const NOW = 1_800_000_000_000 const SESSION = 'session-proven-dead-retry' @@ -89,6 +91,15 @@ describe('structured session proven-dead TUI retry', () => { }, journalDir: join(root, 'journal') }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'running-turn' }, + { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? tuiFence } + ) const closeTuiOwner = vi.fn>() const coordinator = new StructuredAgentSessionHandoffCoordinator({ @@ -134,6 +145,17 @@ describe('structured session proven-dead TUI retry', () => { }, acquireNativeStop: vi.fn(async () => true), importTuiHistory: vi.fn(), + retryPendingSettlement: (sessionId) => + retryLoadedStructuredAgentSessionSettlement({ + deps: { store }, + sessionId, + session: { + journal, + fence: store.getRecord(sessionId)?.lease.runtimeFence ?? 1, + acquisitionGeneration: null + }, + now: () => NOW + }), publish: vi.fn(), schedule: async (_sessionId, task) => task(), now: () => NOW @@ -174,5 +196,7 @@ describe('structured session proven-dead TUI retry', () => { claimStatus: 'live', handoffStage: null }) + expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined() + expect(activeStructuredAgentSessionTurnId(journal.snapshot().items)).toBe(null) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 8859365b117..1bb03e95b2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -27,6 +27,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, + retrySettlement: async () => true, restoreHandoff: async () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index e3b96dbd36a..a0bb32ad737 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -20,6 +20,10 @@ export class StructuredAgentSessionReadableRestorer { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + retrySettlement: ( + sessionId: string, + params: RestoredStructuredAgentSessionRead['params'] + ) => Promise restoreHandoff: (sessionId: string) => Promise } ) {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index d0dcd38b986..d6a4a4397e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' const { restoreRead } = vi.hoisted(() => ({ restoreRead: vi.fn() @@ -9,7 +10,10 @@ vi.mock('./structured-agent-session-read-restore', () => ({ restoreStructuredAgentSessionRead: restoreRead })) -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { + restoreOneStructuredAgentSessionRead, + restoreStructuredAgentSessionsOnRestart +} from './structured-agent-session-restart-restore' describe('restart journal restoration', () => { beforeEach(() => restoreRead.mockReset()) @@ -45,6 +49,7 @@ describe('restart journal restoration', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, + retrySettlement: async () => true, restoreHandoff: async () => undefined }) @@ -56,4 +61,96 @@ describe('restart journal restoration', () => { expect(restoreRead).toHaveBeenCalledTimes(records.length) expect(peak).toBe(4) }) + + it('runs pending settlement retry after recovery resolution and before handoff', async () => { + const calls: string[] = [] + const params: AgentSessionAttachParams = { + envelope: { + sessionId: 'session-1', + clientOperationId: 'read-restore:session-1', + expectedRuntimeFence: 4, + payloadFingerprint: 'fingerprint' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + runtimeKind: 'native' + } + restoreRead.mockResolvedValue({ + journal: {}, + params, + fence: 4, + hasProviderChild: false, + acquisitionGeneration: null + }) + + await restoreOneStructuredAgentSessionRead( + { + store: {} as never, + journalRoot: '/tmp/journals', + reconcile: async () => null, + resolveRecovery: async () => { + calls.push('resolveRecovery') + }, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => { + calls.push('onReadable') + }, + retrySettlement: async (_sessionId, restoredParams) => { + calls.push( + restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' + ) + return true + }, + restoreHandoff: async () => { + calls.push('restoreHandoff') + } + }, + 'session-1' + ) + + expect(calls).toEqual([ + 'resolveRecovery', + 'onReadable', + 'retrySettlement:restored-params', + 'restoreHandoff' + ]) + }) + + it('does not rerun settlement retry when a second restore finds the session already open', async () => { + const retrySettlement = vi.fn(async () => true) + const restoreHandoff = vi.fn(async () => undefined) + restoreRead.mockResolvedValue({ + journal: {}, + params: {}, + fence: 4, + hasProviderChild: false, + acquisitionGeneration: null + }) + + await restoreOneStructuredAgentSessionRead( + { + store: {} as never, + journalRoot: '/tmp/journals', + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => true, + onReadable: () => undefined, + retrySettlement, + restoreHandoff + }, + 'session-1' + ) + + expect(retrySettlement).not.toHaveBeenCalled() + expect(restoreHandoff).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 174aac7d72b..7e697efbdc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -30,6 +30,10 @@ export type StructuredAgentSessionReadRestoreDeps = { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + retrySettlement: ( + sessionId: string, + params: RestoredStructuredAgentSessionRead['params'] + ) => Promise restoreHandoff: (sessionId: string) => Promise } @@ -64,6 +68,7 @@ export async function restoreOneStructuredAgentSessionRead( return } input.onReadable(sessionId, restored) + await input.retrySettlement(sessionId, restored.params) await input.restoreHandoff(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index e2ef20503d6..8d99ea9098f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -58,6 +58,7 @@ function harness( serialize, hasSession: (sessionId) => live.has(sessionId), onReadable: (sessionId, restored) => live.set(sessionId, restored), + retrySettlement: async () => true, restoreHandoff }) return { restorer, live, restoreHandoff, serializedIds } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 41774a9d719..d940fb323bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -16,8 +16,10 @@ import { StructuredAgentSessionReadableRestorer } from './structured-agent-sessi import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import type { StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' +import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' /** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ export async function revealStructuredAgentSession( @@ -55,9 +57,11 @@ export async function revealStructuredAgentSession( */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, + sessions: Map, + now: () => number, wiring: Omit< ConstructorParameters[0], - 'store' | 'journalRoot' | 'supportsRecord' + 'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement' > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise @@ -67,6 +71,8 @@ export function createStructuredAgentSessionHostRestore( store: deps.store, journalRoot: deps.journalRoot, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + retrySettlement: (sessionId, params) => + retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }), ...wiring }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index fc60d6c4696..9fc68a9fca2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -46,15 +46,27 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { hasProviderChild: false, acquisitionGeneration: null } as StructuredAgentSessionHostSession) + return retryLoadedStructuredAgentSessionSettlement({ + deps: input.deps, + sessionId: input.sessionId, + session: retrySession, + now: input.now + }) +} + +export async function retryLoadedStructuredAgentSessionSettlement(input: { + deps: Pick + sessionId: string + session: Pick + now: () => number +}): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { + return true + } + const retrySession = input.session retrySession.fence = record.lease.runtimeFence - const context: StructuredAgentSessionUnexpectedExitContext = { - store: input.deps.store, - sessions: input.sessions, - flushLifecycle: async () => ({ ok: true as const }), - publishFence: () => undefined, - hasResumeCapableHolder: () => false, - serialize: async (_id: string, task: () => Promise) => task(), - now: input.now, + const context: Pick = { onBarrierError: (id, error) => input.deps.onEventSinkError?.({ sessionId: id, error }) } const ok = await retryUnexpectedExitSettlement({ @@ -65,7 +77,7 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { reason: record.lease.deathEvidence?.detail ?? 'provider exited', cause: 'unexpected-exit', fence: record.lease.runtimeFence, - acquisitionGeneration: current?.acquisitionGeneration ?? 'recovery' + acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery' }, session: retrySession, stableSettlementId: record.lease.settlementRetryId @@ -81,11 +93,14 @@ export async function retryPendingStructuredAgentSessionSettlement(input: { ) { throw new Error('agent_session_checkpoint_stale') } + // A dead-TUI retry still needs its stopped-owner stage; recovery-only stages end here. + const preserveHandoff = latest.lease.handoffStage === 'old-owner-stopped' return { ...latest, lease: { ...latest.lease, - handoffStage: null, + handoffStage: preserveHandoff ? latest.lease.handoffStage : null, + handoffOperationId: preserveHandoff ? latest.lease.handoffOperationId : null, settlementRetryRequired: undefined, settlementRetryId: undefined, lastRenewedAt: input.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 31df2c44551..aa0785da31a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -74,6 +74,52 @@ describe('performCancel', () => { ]) }) + it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + await journal.appendItem( + { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + }, + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { fence: 1 } + ) + const ctx: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + cancelTurn: vi.fn(async () => ({ cancelled: false })) + } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + + const result = await performCancel(ctx, { + clientOperationId: 'cancel-unconfirmed-1', + turnId: 'turn-1' + }) + + expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + { + kind: 'status', + text: 'Agent is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { kind: 'status', text: 'The provider had already finished this turn.' } + ]) + }) + it('stops background tasks without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-')) const journal = await journals.open({ identity: IDENTITY, journalDir: root }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index af7f2ccfde3..87fe0cbbeec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -161,9 +161,9 @@ export function isStructuredAgentSessionRecoveryTicketCurrent( } export async function retryUnexpectedExitSettlement(input: { - context: StructuredAgentSessionUnexpectedExitContext + context: Pick event: UnexpectedExitLifecycleEvent - session: StructuredAgentSessionHostSession + session: Pick stableSettlementId: string }): Promise { try { @@ -189,7 +189,7 @@ export async function retryUnexpectedExitSettlement(input: { function unexpectedExitFallbackMutations( event: UnexpectedExitLifecycleEvent, - session: StructuredAgentSessionHostSession, + session: Pick, stableSettlementId: string ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 4af342227c2..dfeaa650129 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionClaimStatus, AgentSessionHandoffStage, @@ -25,6 +26,9 @@ import type { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -126,7 +130,8 @@ function openHost(overrides: Partial = {}): void dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), - setOption: vi.fn() + setOption: vi.fn(), + supportsCreate: () => true } as unknown as StructuredAgentSessionAdapter, journalRoot: root, claimKeyId: 'key-1', @@ -174,7 +179,149 @@ function isAcquirable(lease: NonNullable>['le ) } +async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + hostId: LOCATION.executionHostId, + agent: provider, + providerHandle: + provider === 'codex' + ? { kind: 'codex', threadId: THREAD } + : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + }, + journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION }) + }) + await journal.appendItem( + provider === 'codex' + ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, + { + kind: 'status', + text: 'Agent is working...', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }, + { fence: 13 } + ) + await journal.close() +} + +function restoredJournal(): AgentSessionJournal { + const restored = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION) + if (!restored) { + throw new Error('expected a restored session journal') + } + return restored.journal +} + describe('already-wedged profiles become usable on load', () => { + it.each(['codex', 'claude'] as const)( + 'settles a wedged %s journal on boot without opening a provider child', + async (provider) => { + const record = wedgedRecord({ + claimStatus: 'live', + handoffStage: null, + ownerProcess: DEAD_OWNER + }) + const providerRecord: AgentSessionRecord = + provider === 'codex' + ? record + : { + ...record, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + lease: { ...record.lease, provenHandleLinkId: 'claude-13-link' }, + providerHandleChain: [ + { + linkId: 'claude-13-link', + handle: { + provider: 'claude', + sessionId: 'provider-session-alpha-1', + leafUuid: null + }, + origin: 'created', + mintedAtFence: 13, + observedAt: NOW - 10_000 + } + ] + } + await seedStore(providerRecord) + await seedRunningTurn(provider) + openHost() + + await host.restoreReadableSessions() + + expect(host.hasSession(SESSION)).toBe(true) + const firstCursor = restoredJournal().cursor() + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + settlementRetryRequired: undefined, + settlementRetryId: undefined + }) + expect(acquire).not.toHaveBeenCalled() + + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + openHost() + await host.restoreReadableSessions() + + expect(restoredJournal().cursor()).toEqual(firstCursor) + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + } + ) + + it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => { + await seedStore( + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) + ) + await seedRunningTurn() + openHost() + + await host.hold(SESSION, 'desktop-chat:restart') + + expect(acquire).toHaveBeenCalledOnce() + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + settlementRetryRequired: undefined, + settlementRetryId: undefined + }) + }) + + it('settles an observed-exit latch through attach before the boot sweep', async () => { + const record = wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' }) + record.lease.settlementRetryRequired = true + record.lease.settlementRetryId = `provider-exit:${SESSION}:12:generation-1` + record.lease.deathEvidence = { + kind: 'exit-observed', + detail: 'provider exited: transport closed', + observedAt: NOW - 1_000 + } + await seedStore(record) + await seedRunningTurn() + openHost() + + expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + + expect(acquire).toHaveBeenCalledOnce() + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + settlementRetryRequired: undefined, + settlementRetryId: undefined + }) + }) + it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => { // A crash can leave a conflicted current-schema row in manual recovery; positive death proof // must make it acquirable again without discarding the provider handle. diff --git a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts new file mode 100644 index 00000000000..c6ae4fc2ffa --- /dev/null +++ b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { evictAgentSessionOwner } from './agent-session-lease-transitions' +import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' + +const NOW = 1_800_000_000_000 + +describe('proven-dead agent session eviction settlement', () => { + it('latches restart eviction with a stable id while keeping the lease resumable', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) + ) + + const evicted = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + expect(evicted.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 8, + handoffStage: null, + settlementRetryRequired: true, + settlementRetryId: 'restart-eviction:session-alpha-1:8', + deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host' } + }) + }) + + it('latches recovery eviction from the same evicted disposition', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) + ) + + const evicted = evictAgentSessionOwner({ + record, + expectedFence: 7, + probe: { outcome: 'identity-mismatch', field: 'process-start-time' }, + now: NOW, + journalSettlement: 'required' + }) + + expect(evicted.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 8, + handoffStage: null, + settlementRetryRequired: true, + settlementRetryId: 'restart-eviction:session-alpha-1:8', + deathEvidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time' } + }) + }) + + it('never latches an indeterminate owner', () => { + const restartRecord = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) + ) + const recovered = applyAgentSessionRestartAdjudication({ + record: restartRecord, + probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, + now: NOW + }) + const recoveryRecord = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) + ) + + expect(recovered.lease).toMatchObject({ + handoffStage: 'recovering', + ownerProcess: { pid: 4242 } + }) + expect(recovered.lease).not.toHaveProperty('settlementRetryRequired') + expect(recovered.lease).not.toHaveProperty('settlementRetryId') + expect(() => + evictAgentSessionOwner({ + record: recoveryRecord, + expectedFence: 7, + probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, + now: NOW, + journalSettlement: 'required' + }) + ).toThrow('agent_session_ownership_unknown') + expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryRequired') + expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryId') + }) + + it('preserves a null handoff stage when the latch survives another restart', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: null, + settlementRetryRequired: true, + settlementRetryId: 'restart-eviction:session-alpha-1:8', + unreconciled: true + }) + ) + + const restored = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, + now: NOW + }) + + expect(restored.lease).toMatchObject({ + handoffStage: null, + settlementRetryRequired: true, + settlementRetryId: 'restart-eviction:session-alpha-1:8', + unreconciled: false + }) + }) +}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts index 894d7643abc..2987d06e21c 100644 --- a/src/main/runtime/agent-session-handoff-lease-transitions.ts +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -28,7 +28,7 @@ export function recoverDeadTuiOwnerForHandoff(args: { ) { throw new Error('agent_session_ownership_unknown') } - const evicted = evictAgentSessionOwner(args) + const evicted = evictAgentSessionOwner({ ...args, journalSettlement: 'required' }) return withLease(evicted, { ...evicted.lease, handoffStage: 'old-owner-stopped', diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index bcb0f2adf53..28617817647 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -8,6 +8,7 @@ import { adjudicateAgentSessionRestart, + agentSessionRestartEvictionSettlementId, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -207,6 +208,7 @@ export function evictAgentSessionOwner(args: { expectedFence: number probe: AgentSessionOwnerProbe now: number + journalSettlement: 'required' | 'not-required' }): AgentSessionRecord { const { record } = args assertFence(record.lease, args.expectedFence) @@ -232,6 +234,7 @@ export function evictAgentSessionOwner(args: { if (adjudication.disposition !== 'evicted') { throw new Error('agent_session_ownership_unknown') } + const settlementRequired = args.journalSettlement === 'required' return withLease(record, { ...record.lease, runtimeFence: adjudication.nextFence, @@ -242,7 +245,11 @@ export function evictAgentSessionOwner(args: { claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: adjudication.evidence + deathEvidence: adjudication.evidence, + settlementRetryRequired: settlementRequired ? true : undefined, + settlementRetryId: settlementRequired + ? agentSessionRestartEvictionSettlementId(record.lease, adjudication) + : undefined }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 577baa16b94..4325410ed81 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -254,7 +254,9 @@ export class AgentSessionRecordStore { probe: AgentSessionOwnerProbe now: number }): Promise { - return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) + return this.mutate(args.sessionId, (record) => + evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' }) + ) } async transitionHandoff( diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts index 99849248ace..3f78fa2e456 100644 --- a/src/main/runtime/agent-session-restart-handoff-adjudication.ts +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.ts @@ -17,6 +17,9 @@ export function adjudicateRestartedAgentSessionHandoff( if (adjudication.disposition === 'readopt') { return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) } + if (adjudication.disposition === 'settlement-pending') { + return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) + } if (adjudication.disposition === 'free') { return updateLease(record, { ...record.lease, diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index 93e6c4333a7..a50fd4cd94e 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -8,6 +8,7 @@ import { adjudicateAgentSessionRestart, + agentSessionRestartEvictionSettlementId, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { @@ -50,6 +51,9 @@ export function applyAgentSessionRestartAdjudication(args: { // Why: re-adoption is not a new generation, so the fence does not move. return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) } + if (adjudication.disposition === 'settlement-pending') { + return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) + } if (adjudication.disposition === 'free') { // Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing // the stage restores it without moving the fence or touching the recorded death evidence. @@ -74,7 +78,9 @@ export function applyAgentSessionRestartAdjudication(args: { unreconciled: false, lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: adjudication.evidence + deathEvidence: adjudication.evidence, + settlementRetryRequired: true, + settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication) }) } const stage: AgentSessionHandoffStage = diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index cff6eef6ca2..0181b440c90 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -47,12 +47,21 @@ export type AgentSessionAcquisitionDecision = export type AgentSessionRestartAdjudication = | { disposition: 'readopt' } + /** A journal settlement latch survives restart without changing its handoff stage. */ + | { disposition: 'settlement-pending' } /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ | { disposition: 'free'; reason: string } | { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence } | { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string } | { disposition: 'conflicted'; reason: string } +export function agentSessionRestartEvictionSettlementId( + lease: Pick, + eviction: Extract +): string { + return `restart-eviction:${lease.sessionId}:${eviction.nextFence}` +} + /** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */ const STAGES_ADMITTING_NEW_OWNER: ReadonlySet = new Set([ 'old-owner-stopped', @@ -201,11 +210,7 @@ export function adjudicateAgentSessionRestart(args: { if (lease.settlementRetryRequired) { // A watched provider death can leave terminal rows unsettled. This latch is not owner // uncertainty and must survive restart until the journal settlement is durably accepted. - return { - disposition: 'recovering', - stage: 'recovering', - reason: 'provider-exit settlement requires retry' - } + return { disposition: 'settlement-pending' } } if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') { // Why: the spawn token is minted before the child and is the only thing a child could be From 2ccf35b13580c470a24e5c19eff7d48d9647c0f1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:41:49 -0700 Subject: [PATCH 5/5] fix: avoid quadratic trimming during fullscreen terminal redraws (#19214) --- config/reliability-gates.jsonc | 69 +++++++++++++++++++ src/main/runtime/terminal-tail-buffer.ts | 2 +- .../runtime/terminal-tail-redraw-buffer.ts | 2 +- .../runtime/terminal-tail-whitespace.test.ts | 32 +++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/terminal-tail-whitespace.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2bcba7cb737..72bcb4b4d7f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,75 @@ } }, "gates": [ + { + "id": "terminal-performance.padded-fullscreen-redraw", + "title": "Fullscreen redraw padding does not stall terminal delivery", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "runtime-unit-and-electron-cdp", + "surfaces": ["terminal transcript preview", "fullscreen TUI scrolling"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "The trim operation is platform-independent and preserves the same spaces/tabs policy for all providers. Real Pi 0.84.2 was exercised in a hidden macOS Electron renderer through CDP using a folder workspace.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/14770"], + "invariant": "Transcript preview trimming preserves internal whitespace and terminal read contents without quadratic main-process work on padded fullscreen redraws.", + "oracle": "Preserve 32,000 spaces before a marker while trimming trailing spaces/tabs in both retained-row and carried-prefix redraw paths; four redraws must finish within 500 ms. Existing tail equivalence tests preserve cursor, retention, and pagination behavior.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts" + ], + "testFiles": [ + "src/main/runtime/terminal-tail-whitespace.test.ts", + "src/main/runtime/terminal-tail-buffer.test.ts", + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/terminal-tail-whitespace.test.ts", + "assertions": [ + "handles padded redraws across %i retained rows without stalling", + "preserves terminal text while trimming spaces and tabs: %j" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts", + "result": "passed", + "durationSeconds": 3.96, + "summary": "17 tests passed. Before the fix both padding budget cases failed, taking approximately 1.7 seconds each." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Three unit test files; padding cases allow 500 ms for four redraws." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Initial local red/green validation; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Both padding budget cases fail with regex trimming and pass with the existing linear trim. A 60-event CDP wheel stream in Pi fullscreen had about 2.1 seconds of output tail before the fix and 14 ms after rebuilding." + }, + "performanceBudget": { + "required": true, + "evidence": "The main CPU profile attributed 3.1 seconds to redraw-row whitespace trimming. Reusing the linear trim adds no timers, caches, provider calls, or output dropping." + }, + "knownGaps": [ + "The user manually compared the fixed dev app with production and confirmed improved responsiveness. A live Terminal.app comparison was not exercised; timing measurements used CDP wheel events.", + "Linux, Windows, and live SSH rendering were not exercised; the shared trimming behavior is covered by unit tests." + ], + "promotionCriteria": [ + "Complete CI soak requirements and retain the padding budget and tail equivalence oracles." + ], + "demotionRule": "Keep experimental until CI soak is stable; investigate any budget failure without weakening transcript preservation." + }, { "id": "ssh.localhost-terminal-agent-hooks", "title": "Localhost SSH terminal and agent hooks reach the owning pane", diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index 141b14da6ec..b3e15d1f375 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -293,7 +293,7 @@ function appendNormalizedToMultilineTailBuffer( const line = rewritten[index]! const lastChar = line.charCodeAt(line.length - 1) if (lastChar === 32 || lastChar === 9) { - rewritten[index] = line.replace(/[ \t]+$/g, '') + rewritten[index] = trimTerminalLineRight(line) } } for (const line of windowed.lines) { diff --git a/src/main/runtime/terminal-tail-redraw-buffer.ts b/src/main/runtime/terminal-tail-redraw-buffer.ts index cf90605fcd1..7ebb06753dd 100644 --- a/src/main/runtime/terminal-tail-redraw-buffer.ts +++ b/src/main/runtime/terminal-tail-redraw-buffer.ts @@ -185,7 +185,7 @@ function finalizeRetainedTerminalRows( newlyCompletedLines: string[] } { let truncated = initialTruncated - let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) + let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) })) if (retainedRows.length > MAX_TAIL_LINES + 1) { const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) diff --git a/src/main/runtime/terminal-tail-whitespace.test.ts b/src/main/runtime/terminal-tail-whitespace.test.ts new file mode 100644 index 00000000000..280d3a02d50 --- /dev/null +++ b/src/main/runtime/terminal-tail-whitespace.test.ts @@ -0,0 +1,32 @@ +import { performance } from 'node:perf_hooks' +import { describe, expect, it } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { trimTerminalLineRight } from './terminal-tail-line-controls' + +describe('terminal redraw whitespace', () => { + it.each([ + ['hello \t', 'hello'], + [' \thello \t world \t', ' \thello \t world'], + [' \t', ''], + ['hello\u00a0 \t', 'hello\u00a0'], + ['hello\n', 'hello\n'] + ])('preserves terminal text while trimming spaces and tabs: %j', (input, expected) => { + expect(trimTerminalLineRight(input)).toBe(expected) + }) + + it.each([2, 20])('handles padded redraws across %i retained rows without stalling', (rows) => { + const padded = `${' '.repeat(32_000)}marker \t` + const previousLines = Array.from({ length: rows }, (_, index) => + index === 0 ? padded : `row ${index}` + ) + const start = performance.now() + let result: ReturnType | undefined + for (let frame = 0; frame < 4; frame += 1) { + result = appendNormalizedToTailBuffer(previousLines, 'footer', '\x1b[1A\rupdated') + } + const elapsedMs = performance.now() - start + expect(result?.lines[0]).toBe(`${' '.repeat(32_000)}marker`) + // Interior padding made the trailing-whitespace regex backtrack quadratically. + expect(elapsedMs).toBeLessThan(500) + }) +})