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..ee2212329d3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -0,0 +1,185 @@ +// Adopting a history row imports its transcript into the new session's journal. +// +// The import runs AFTER the provider is acquired, because for a create there is no earlier moment +// — the journal does not exist until the child does. That ordering is what makes the failure cases +// here load-bearing: by then the provider has already resumed and holds the conversation in +// context, so an attach that succeeds with an empty journal would show the user a blank chat beside +// an agent that can already answer from history. + +import { mkdtemp, rm, writeFile } 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 } from './structured-agent-session-attach-flow' + +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 + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +/** 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 + } + } + 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, sessionAdapter: StructuredAgentSessionAdapter) { + return performAttach({ + store: await AgentSessionRecordStore.open({ + directory: join(root!, 'store'), + hostId: 'local' + }), + 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('fails the attach when the adopted transcript cannot be read', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-missing-')) + const sessionAdapter = adapter() + + // A post-acquisition failure throws rather than answering a refusal — the same path a journal + // failure already takes — so the caller learns the outcome is unknown, not that nothing ran. + await expect(attach(join(root, 'does-not-exist.jsonl'), sessionAdapter)).rejects.toThrow( + /ENOENT|no such file/ + ) + // The provider had already resumed, so its acquisition is released rather than left holding a + // conversation no surface will ever show. + expect(sessionAdapter.releaseAcquisition).toHaveBeenCalled() + }) + + it('fails the attach when the adopted transcript decodes to no messages', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-empty-')) + const transcriptPath = join(root, 'empty.jsonl') + // Well-formed but conversation-free: the row promised turns and the provider resumed them, so + // an empty journal here is a disagreement, not an empty chat. + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' } + })}\n`, + 'utf8' + ) + const sessionAdapter = adapter() + + await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow( + 'agent_session_identity_required' + ) + expect(sessionAdapter.releaseAcquisition).toHaveBeenCalled() + }) +}) 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..33e1143d425 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -36,6 +36,7 @@ import type { 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 { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -181,6 +182,7 @@ export async function performAttach( journalRoot: input.journalRoot, adapter: input.adapter }) + await importAdoptedTranscript(params, attached, record) await input.onAttached(attached, acquisitionGeneration) await store.recordOperationOutcome({ callerKey: input.callerKey, @@ -206,6 +208,48 @@ export async function performAttach( } } +/** + * Fill an adopting session's journal with the conversation so far. + * + * Runs here, and only here, because for a create there is no earlier moment: the provider is + * acquired before the journal exists. It must still land before `onAttached`, which binds the event + * sink and publishes the session — after that, streamed rows and client sends would race the import. + * + * A failure throws, and that is deliberate. By this point the provider has already resumed and holds + * the conversation in context; leaving the user an empty journal beside a context-carrying agent is + * the exact "claims continuity the provider never gave" inversion this feature must not produce. + * The throw reaches `settlePostAcquisitionAttachFailure`, which tears the child down, settles the + * operation failed, and publishes no tab. + */ +async function importAdoptedTranscript( + params: AgentSessionAttachParams, + attached: AttachedJournal, + record: AgentSessionRecord +): Promise { + const adopt = params.adopt + if (!adopt) { + return + } + 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') + } +} + async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, record: AgentSessionRecord, 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 59a5bfe0b8e..a3becd2c636 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, @@ -58,6 +63,18 @@ export type AgentSessionAttachParams = { options?: Readonly> /** 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 + transcriptPath: string + } } /** Host-supplied half of the reservation. */ @@ -84,6 +101,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 } } @@ -182,6 +203,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 @@ -200,6 +253,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..55340fe3055 --- /dev/null +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { + findConflictingStructuredAdoption, + resolveStructuredAgentSessionAdoption, + structuredAdoptionConflictError, + type StructuredAgentSessionAdoptionOwnership +} from './structured-agent-session-history-adoption' + +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('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..445a50f71a2 --- /dev/null +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -0,0 +1,102 @@ +// 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 { AgentSessionLease } 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 +} + +/** + * 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..2a8b84c7654 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,35 @@ export function applyAgentSessionReservation( }) } +/** + * Refuse an adoption whose conversation another record already holds. + * + * Exempts the request's own session id: a retry of a create that already committed re-runs every + * pre-commit check, and by then the record it created holds the conversation itself. Without the + * exemption the replay would refuse as a conflict instead of replaying. + */ +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 +231,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..b5488c924af 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 @@ -6,12 +6,19 @@ import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' +import { + findConflictingStructuredAdoption, + resolveStructuredAgentSessionAdoption, + structuredAdoptionConflictError +} from '../native-chat/structured-agent-session-history-adoption' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' @@ -111,6 +118,7 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca envelope: { sessionId: string; clientOperationId: string } worktree: string agent: 'claude' | 'codex' + resumeFrom?: { providerSessionId: string } }): Promise { if (input.agent === 'claude') { return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => { @@ -139,11 +147,72 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca }) } + /** Where an adopted conversation may live, most-preferred first. The selected account is tried + * before the system default so a row present in both resolves the way a fresh create would. */ + protected structuredAdoptionAccountHomeCandidates( + agent: 'claude' | 'codex', + selectedAccountHomePath: string + ): string[] { + if (agent === 'claude') { + return [selectedAccountHomePath, join(homedir(), '.claude')] + } + // Managed per-account homes are enumerated from the store rather than taken from the row: a + // Codex conversation recorded under one account must still be findable when another is + // selected, and reading the row's own path back would reintroduce the client-supplied path + // this whole derivation exists to avoid. + const managedHomes = (this.requireStore().getSettings().codexManagedAccounts ?? []).map( + (account) => account.managedHomePath + ) + return [ + selectedAccountHomePath, + ...managedHomes, + getOrcaManagedCodexHomePath(), + getSystemCodexHomePath() + ] + } + + protected async resolveStructuredAgentSessionAdoptionForCreate(input: { + agent: 'claude' | 'codex' + providerSessionId: string + selfSessionId: string + selectedAccountHomePath: string + }) { + const host = getStructuredAgentSessionHost() + const conflict = host + ? findConflictingStructuredAdoption({ + agent: input.agent, + providerSessionId: input.providerSessionId, + selfSessionId: input.selfSessionId, + ownership: listStructuredProviderSessionOwnership(host.deps.store.listRecords()) + }) + : null + if (conflict) { + throw structuredAdoptionConflictError(conflict) + } + return resolveStructuredAgentSessionAdoption({ + agent: input.agent, + providerSessionId: input.providerSessionId, + candidateAccountHomes: this.structuredAdoptionAccountHomeCandidates( + input.agent, + input.selectedAccountHomePath + ), + resolveTranscript: async ({ agent, providerSessionId, accountHomePath }) => + resolveSessionFilePath( + agent, + providerSessionId, + agent === 'claude' + ? { claudeProjectsDir: join(accountHomePath, 'projects') } + : { codexSessionsDirs: [join(accountHomePath, 'sessions')] } + ) + }) + } + protected async resolveStructuredAgentSessionIntent( input: { envelope: { sessionId: string; clientOperationId: string } worktree: string agent: 'claude' | 'codex' + resumeFrom?: { providerSessionId: string } }, resolveAccountHomePath: (context: { workspacePath: string @@ -168,6 +237,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca ) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + 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 this.resolveStructuredAgentSessionAdoptionForCreate({ + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + selectedAccountHomePath + }) + : null return { envelope: { sessionId: input.envelope.sessionId, @@ -180,9 +266,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-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 233809d40ff..069c5b615ef 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.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 9ca3c632a83..0f654e4cfae 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -104,7 +104,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) { diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e5a18bc3014..ecc981f2a36 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -29,7 +29,21 @@ import { resolveAiVaultSessionResumeActions, resolveAiVaultSessionResumeState } from './ai-vault-session-resume' -import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' +import { + resolveAiVaultTargetWorkspacePath, + useAiVaultSessionLaunchActions +} from './ai-vault-session-launch-actions' +import { + resolveAiVaultSessionResumeInChatEligibility, + type AiVaultResumeInChatEligibility +} from './ai-vault-session-resume-in-chat' +import { structuredAgentLaunchSupported } from '@/lib/agent-launch-routing' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { useAiVaultSessionWorktreeMap, withAiVaultCurrentWorktreeStatus @@ -287,6 +301,51 @@ 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 => { + const resumeState = getSessionResumeState(session) + const targetWorkspaceId = resumeState.usesSessionWorktree + ? resumeState.worktreeId + : (resumeState.worktreeId ?? effectiveActiveWorktreeId) + const targetWorkspacePath = targetWorkspaceId + ? resolveAiVaultTargetWorkspacePath(resumeTargetState, targetWorkspaceId) + : null + return resolveAiVaultSessionResumeInChatEligibility({ + session, + targetWorkspaceId, + targetWorkspacePath, + structuredRouteAvailable: + isAgentSessionHandleProvider(session.agent) && + Boolean(targetWorkspaceId) && + structuredAgentLaunchSupported({ + agent: session.agent, + 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 + ) + }) + }, + [effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings] + ) + const handleScopeChange = useCallback((nextScope: AiVaultScope) => { preferredScopeRef.current = nextScope userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE @@ -366,7 +425,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 ? (