diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index b5a545e1048d..cb3b0cbca8e3 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -163,7 +163,10 @@ export function buildWindowsAgentHookPostCommand( } // Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both. -export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string { +export function buildWindowsAgentHookCurlPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { return [ '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', `"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`, @@ -176,6 +179,7 @@ export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): s '--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"', '--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"', '--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"', + ...extraFormLines, '--data-urlencode "payload@-"', '>nul 2>&1' ].join(' ') diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 12ef7e9c6e95..e059a1874c34 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -16,6 +16,7 @@ export const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } export type Body = { paneKey: string launchToken?: string + sessionNonce?: string tabId?: string worktreeId?: string env?: string diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 58ad669784be..6fe1be6af0d4 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -126,6 +126,8 @@ type PersistedAgentHookEventPayload = Omit< | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + // Why: a one-shot resume nonce is meaningless after the process it named exits. + | 'sessionNonce' // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. | 'observation' @@ -3122,6 +3124,8 @@ export class AgentHookServer { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, + // Why: a one-shot resume nonce is meaningless after the process it named exits. + sessionNonce: _sessionNonce, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). observation: _observation, launchToken, diff --git a/src/main/codex/adopted-codex-tui-readiness.ts b/src/main/codex/adopted-codex-tui-readiness.ts new file mode 100644 index 000000000000..60a642aca88c --- /dev/null +++ b/src/main/codex/adopted-codex-tui-readiness.ts @@ -0,0 +1,115 @@ +// Off-screen readiness proof for a Codex TUI resumed inside a pane Orca already +// owns. Adoption stops the pane's Codex and types a `codex resume` line into the +// bare shell that is left; nothing on screen can tell that shell apart from a +// Codex that has finished starting. The CLI's own SessionStart hook can: it is +// emitted by the resumed process itself, carries the conversation it reopened, +// and reaches Orca over the hook endpoint instead of the terminal buffer. +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' + +export type AdoptedCodexReadinessEvent = Pick< + AgentHookEventPayload, + 'paneKey' | 'source' | 'hookEventName' | 'isReplay' | 'sessionNonce' | 'providerSession' +> + +export type AdoptedCodexReadinessVerdict = 'ignore' | 'ready' | 'wrong-session' + +export type AdoptedCodexReadinessTarget = { + paneKey: string + threadId: string + sessionNonce: string +} + +export const ADOPTED_CODEX_READINESS_TIMEOUT_MS = 30_000 + +export function classifyAdoptedCodexReadinessEvent( + event: AdoptedCodexReadinessEvent, + target: AdoptedCodexReadinessTarget +): AdoptedCodexReadinessVerdict { + if ( + event.source !== 'codex' || + event.hookEventName !== 'SessionStart' || + event.isReplay === true || + event.paneKey !== target.paneKey + ) { + return 'ignore' + } + // A hook script installed by an older Orca omits the nonce entirely. Pane + thread + // still bind it, so degrade rather than strand the return path — but a nonce that + // IS present and disagrees names a different invocation, which is the stale + // SessionStart this gate exists to reject. + if (event.sessionNonce !== undefined && event.sessionNonce !== target.sessionNonce) { + return 'ignore' + } + const observedThreadId = event.providerSession?.id + if (!observedThreadId) { + return 'ignore' + } + if (observedThreadId === target.threadId) { + return 'ready' + } + // Only our own nonce makes a mismatch conclusive; without one this could be any + // Codex sharing the pane's env. + return event.sessionNonce === target.sessionNonce ? 'wrong-session' : 'ignore' +} + +export type AdoptedCodexReadinessWatch = { + /** Resolves when the resumed Codex proves itself; rejects on timeout or a wrong thread. */ + readonly settled: Promise + dispose(): void +} + +/** + * Starts listening BEFORE the resume command is written — a fast Codex can emit + * SessionStart before the write call even returns. + */ +export function beginAdoptedCodexReadinessWatch(input: { + subscribe: (listener: (event: AdoptedCodexReadinessEvent) => void) => () => void + target: AdoptedCodexReadinessTarget + timeoutMs?: number + setTimer?: (callback: () => void, ms: number) => unknown + clearTimer?: (timer: unknown) => void +}): AdoptedCodexReadinessWatch { + const setTimer = input.setTimer ?? ((callback, ms) => setTimeout(callback, ms)) + const clearTimer = input.clearTimer ?? ((timer) => clearTimeout(timer as never)) + let finish: ((error: Error | null) => void) | null = null + const settled = new Promise((resolve, reject) => { + finish = (error) => { + if (error) { + reject(error) + } else { + resolve() + } + } + }) + let unsubscribe: (() => void) | null = null + let timer: unknown = null + const settle = (error: Error | null): void => { + if (!finish) { + return + } + const complete = finish + finish = null + if (timer !== null) { + clearTimer(timer) + timer = null + } + unsubscribe?.() + unsubscribe = null + complete(error) + } + unsubscribe = input.subscribe((event) => { + const verdict = classifyAdoptedCodexReadinessEvent(event, input.target) + if (verdict === 'ready') { + settle(null) + } else if (verdict === 'wrong-session') { + settle(new Error('The agent terminal resumed a different Codex session.')) + } + }) + timer = setTimer(() => { + settle(new Error('The agent terminal did not report the resumed Codex session.')) + }, input.timeoutMs ?? ADOPTED_CODEX_READINESS_TIMEOUT_MS) + return { + settled, + dispose: () => settle(new Error('The agent terminal stopped resuming Codex.')) + } +} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 4aa166200152..8687830d0623 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -121,6 +121,23 @@ function rejection(promise: Promise): Promise { } describe('openCodexAppServerConnection', () => { + it('advertises the experimental API required for rollout-path resume', async () => { + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + expect(written[0]).toMatchObject({ + method: 'initialize', + params: { capabilities: { experimentalApi: true } } + }) + await connection.close() + }) + it('completes the handshake and keeps the child alive across calls', async () => { const notifications: { method: string; params: unknown }[] = [] const connection = await openFakeServer({ diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index db7ce14f905f..b5c6980921c5 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -334,7 +334,7 @@ export async function openCodexAppServerConnection( { clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }, capabilities: { - experimentalApi: false, + experimentalApi: true, requestAttestation: false, mcpServerOpenaiFormElicitation: false, extensions: {} diff --git a/src/main/codex/codex-pane-launch-home.test.ts b/src/main/codex/codex-pane-launch-home.test.ts new file mode 100644 index 000000000000..d4c1847eb320 --- /dev/null +++ b/src/main/codex/codex-pane-launch-home.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import type { CodexPaneAccountRecord } from './codex-pane-account-registry' +import { + describeCodexPaneLaunchHomeFailure, + resolveCodexPaneLaunchHome +} from './codex-pane-launch-home' + +const SYSTEM_HOME = '/Users/dev/.codex' +const SHARED_HOME = '/Users/dev/Library/Application Support/orca/codex-runtime-home/home' +const ACCOUNT_HOME = '/Users/dev/Library/Application Support/orca/codex-accounts/acct-1/home' + +const SETTINGS = { + codexManagedAccounts: [ + { + id: 'acct-1', + email: 'dev@example.com', + managedHomePath: ACCOUNT_HOME, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] +} + +function resolve(record: CodexPaneAccountRecord | null) { + return resolveCodexPaneLaunchHome({ + record, + settings: SETTINGS, + systemCodexHomePath: SYSTEM_HOME, + sharedRuntimeCodexHomePath: SHARED_HOME + }) +} + +describe('resolveCodexPaneLaunchHome', () => { + it('names the account home a managed pane launched under', () => { + expect( + resolve({ selectionKey: 'host', accountId: 'acct-1', homeRoute: 'account-home' }) + ).toEqual({ kind: 'attributed', path: ACCOUNT_HOME }) + }) + + it('routes real-home and shared-home panes to their own homes', () => { + expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' })).toEqual({ + kind: 'attributed', + path: SYSTEM_HOME + }) + expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'shared-home' })).toEqual({ + kind: 'attributed', + path: SHARED_HOME + }) + }) + + it('prefers a recorded pane-local override over the route it was classified as', () => { + expect( + resolve({ + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home', + environmentHomeOverride: { codexHome: '/tmp/isolated/home' } + }) + ).toEqual({ kind: 'attributed', path: '/tmp/isolated/home' }) + expect( + resolve({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home', + shellStartupHomeOverride: { home: '/Users/dev', codexHome: '/tmp/shell/home' } + }) + ).toEqual({ kind: 'attributed', path: '/tmp/shell/home' }) + }) + + it('declines rather than guessing when the pane cannot be attributed', () => { + expect(resolve(null)).toEqual({ kind: 'unrecorded' }) + // A WSL lane can never answer for the local pane adoption asks about. + expect(resolve({ selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' })).toEqual( + { kind: 'unrecorded' } + ) + // Recorded before route provenance existed, so the launch home is unknown. + expect(resolve({ selectionKey: 'host', accountId: null })).toEqual({ kind: 'unnameable-home' }) + expect(resolve({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' })).toEqual({ + kind: 'unnameable-home' + }) + expect( + resolve({ selectionKey: 'host', accountId: 'acct-gone', homeRoute: 'account-home' }) + ).toEqual({ kind: 'unknown-account', accountId: 'acct-gone' }) + }) + + it('names the account in the failure the user is shown', () => { + expect( + describeCodexPaneLaunchHomeFailure({ kind: 'unknown-account', accountId: 'acct-gone' }) + ).toContain('acct-gone') + expect(describeCodexPaneLaunchHomeFailure({ kind: 'unrecorded' })).toContain( + 'which Codex account' + ) + }) +}) diff --git a/src/main/codex/codex-pane-launch-home.ts b/src/main/codex/codex-pane-launch-home.ts new file mode 100644 index 000000000000..56600a653a54 --- /dev/null +++ b/src/main/codex/codex-pane-launch-home.ts @@ -0,0 +1,78 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getCodexSelectionTargetForAccount } from '../codex-accounts/runtime-selection' +import type { CodexPaneAccountRecord } from './codex-pane-account-registry' + +type CodexPaneLaunchHomeSettings = Pick + +export type CodexPaneLaunchHomeResolution = + | { kind: 'attributed'; path: string } + | { kind: 'unrecorded' } + | { kind: 'unknown-account'; accountId: string } + | { kind: 'unnameable-home' } + +/** + * Resolves the CODEX_HOME a live host pane's Codex is ALREADY running under. + * + * Why this is not the selected account's home: `CODEX_HOME` is baked into a + * shell's environment at spawn, and an automatic resume deliberately pins it to + * the home that OWNS the session rather than to the current selection. Only that + * home's `sessions` directory can hold the pane's rollout, so anything reading + * the live pane's transcript must ask which account it launched under — the + * record read here is the one `resolveCodexPaneLaunchAccount` wrote at spawn. + * + * Declines rather than guessing: probing the wrong account's home reports a + * broken session when the real cause is an account mismatch. + */ +export function resolveCodexPaneLaunchHome(args: { + record: CodexPaneAccountRecord | null + settings: CodexPaneLaunchHomeSettings + systemCodexHomePath: string + sharedRuntimeCodexHomePath: string +}): CodexPaneLaunchHomeResolution { + const record = args.record + if (!record || record.selectionKey !== 'host') { + return { kind: 'unrecorded' } + } + // Pane-local overrides carry the literal CODEX_HOME the launch resolved, so + // they answer before the route, which only classifies it. + const overrideHome = + record.environmentHomeOverride?.codexHome ?? record.shellStartupHomeOverride?.codexHome + if (overrideHome) { + return { kind: 'attributed', path: overrideHome } + } + if (record.homeRoute === 'real-home') { + return { kind: 'attributed', path: args.systemCodexHomePath } + } + if (record.homeRoute === 'shared-home') { + return { kind: 'attributed', path: args.sharedRuntimeCodexHomePath } + } + if (record.homeRoute !== 'account-home') { + // `custom-home` recorded a home Orca cannot re-derive; an absent route predates + // provenance; `wsl-home` cannot belong to a host pane. + return { kind: 'unnameable-home' } + } + if (!record.accountId) { + return { kind: 'unnameable-home' } + } + const account = args.settings.codexManagedAccounts?.find( + (candidate) => + candidate.id === record.accountId && + getCodexSelectionTargetForAccount(candidate).runtime === 'host' + ) + return account + ? { kind: 'attributed', path: account.managedHomePath } + : { kind: 'unknown-account', accountId: record.accountId } +} + +/** Names the account cause, so a mismatch never reads as a broken session. */ +export function describeCodexPaneLaunchHomeFailure( + resolution: Exclude +): string { + if (resolution.kind === 'unknown-account') { + return `This terminal runs Codex under managed account ${resolution.accountId}, which is no longer configured, so its Codex session cannot be located.` + } + if (resolution.kind === 'unnameable-home') { + return 'This terminal runs Codex under a CODEX_HOME Orca cannot identify, so its Codex session cannot be located.' + } + return 'Orca has no record of which Codex account this terminal launched under, so its Codex session cannot be located. Start a new Codex chat instead.' +} diff --git a/src/main/codex/codex-session-nonce-wire.test.ts b/src/main/codex/codex-session-nonce-wire.test.ts new file mode 100644 index 000000000000..5583f0eca115 --- /dev/null +++ b/src/main/codex/codex-session-nonce-wire.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { buildBody, PANE } from '../agent-hooks/server.test-fixtures' +import { classifyAdoptedCodexReadinessEvent } from './adopted-codex-tui-readiness' +import { getCodexManagedHookInstallMaterial } from './hook-service' + +describe('Codex session-nonce hook wire', () => { + it('posts the nonce env var back as a form field', () => { + const { script } = getCodexManagedHookInstallMaterial() + expect(script).toContain(`sessionNonce=`) + expect(script).toContain(AGENT_HOOK_SESSION_NONCE_ENV_VAR) + }) + + it('keeps the nonce post fire-and-forget: no extra request, no extra wait', () => { + const { script } = getCodexManagedHookInstallMaterial() + // Why this is asserted at all: Codex blocks synchronously on hooks, so a second + // POST or a longer budget for the nonce would stall every resume by seconds. + expect(script.match(/-X POST/g)).toHaveLength(1) + expect(script).not.toMatch(/--max-time "?[3-9]/) + }) + + it('carries the nonce from the form body onto the normalized event', () => { + const event = _internals.normalizeHookPayload( + 'codex', + buildBody( + { hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }, + { sessionNonce: 'nonce-1' } + ), + 'production' + ) + expect(event?.sessionNonce).toBe('nonce-1') + expect(event?.hookEventName).toBe('SessionStart') + expect(event?.providerSession?.id).toBe('thread-1') + }) + + it('delivers the nonce over the real hook endpoint to enriched-status subscribers', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const seen: unknown[] = [] + const unsubscribe = server.subscribeEnrichedStatus((event) => seen.push(event)) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }, + { sessionNonce: 'nonce-1' } + ) + ) + }) + expect(response.status).toBe(204) + } finally { + unsubscribe() + server.stop() + } + // The seam the runtime's readiness watch actually sits on: a SessionStart that + // travelled the wire end to end must classify as proof. + expect(seen).toHaveLength(1) + expect( + classifyAdoptedCodexReadinessEvent(seen[0] as never, { + paneKey: PANE, + threadId: 'thread-1', + sessionNonce: 'nonce-1' + }) + ).toBe('ready') + }) + + it('leaves the nonce undefined when an older installed script omits it', () => { + const event = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }), + 'production' + ) + expect(event?.sessionNonce).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 0f1331fbcb4a..464c53f41a2a 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -82,6 +82,39 @@ function translatorWith(tap = recorder(), window = manualWindow()) { } describe('codex journal translation', () => { + it('projects turns restored by thread/resume into durable conversation rows', () => { + const { translator, tap } = translatorWith() + + translator.restoreThread(THREAD_ID, { + turns: [ + { + id: 'turn-restored', + items: [ + { + type: 'userMessage', + id: 'user-restored', + content: [{ type: 'text', text: 'existing question' }] + }, + { type: 'agentMessage', id: 'agent-restored', text: 'existing answer' } + ] + } + ] + }) + + expect(tap.rows.map((row) => row.body)).toEqual([ + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'existing question' }] + }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'existing answer' }] + } + ]) + }) + it('durably opens and closes the primary turn cancellation lifecycle', () => { const tap = recorder() const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 5b5c99aa11e5..f31a27187588 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -42,6 +42,7 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => void + restoreThread: (threadId: string, thread: Record) => void flush: () => void dispose: () => void } @@ -229,6 +230,23 @@ export function createCodexJournalTranslator( } return { + restoreThread: (threadId, thread) => { + const turns = Array.isArray(thread.turns) ? thread.turns : [] + for (const rawTurn of turns) { + const turn = readRecord(rawTurn) + const turnId = readString(turn, 'id') + if (!turnId) { + continue + } + currentTurnIds.set(threadId, turnId) + for (const item of Array.isArray(turn.items) ? turn.items : []) { + handleItemEvent({ threadId, method: 'item/completed', params: { turnId, item } }) + } + currentTurnIds.delete(threadId) + ordinals.forgetTurn(threadId, turnId) + } + streams.flush() + }, handle: (event) => { if (event.type === 'ended') { streams.flush() diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 82e9e4d744db..3883930bdf9f 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' @@ -27,12 +27,14 @@ function record(overrides: Partial = {}): AgentSessionRecord function resolverFor( value: AgentSessionRecord | null, - resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}` + resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, + resolveRollout: () => Promise = async () => null ) { return createCodexStructuredLaunchResolver({ store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath, - resolveCommand: () => '/usr/local/bin/codex' + resolveCommand: () => '/usr/local/bin/codex', + resolveRollout }) } @@ -62,6 +64,22 @@ describe('codex structured launch resolution', () => { expect(launch.resumeThreadId).toBe('thread-current') }) + it('pins resume to the rollout file that proved the durable thread', async () => { + const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-current' } } + ] as AgentSessionRecord['providerHandleChain'] + }), + async (id) => `/repos/${id}`, + resolveRollout + )({ identity: IDENTITY }) + + expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current') + expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl') + }) + it('refuses a session pinned to another host rather than starting a second writer here', async () => { await expect( resolverFor( diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 3a28a1ba7ffc..ae68be38d3f3 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -13,6 +13,7 @@ import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { getSpawnArgsForWindows } from '../win32-utils' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' +import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' const CODEX_APP_SERVER_ARGS = ['app-server'] @@ -25,6 +26,7 @@ export type CodexStructuredLaunchResolverDeps = { resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise + resolveRollout?: typeof resolvePinnedCodexRolloutProof } export function createCodexStructuredLaunchResolver( @@ -59,6 +61,7 @@ export function createCodexStructuredLaunchResolver( }) const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, CODEX_APP_SERVER_ARGS) const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null return { command: spawnCmd, args: spawnArgs, @@ -67,7 +70,15 @@ export function createCodexStructuredLaunchResolver( ...(environment ? { env: { ...environment } as Record } : {}), // An empty chain is a session that has never proved a thread, so it // starts one; anything else resumes the last link this session proved. - resumeThreadId: head?.handle.provider === 'codex' ? head.handle.threadId : null + resumeThreadId, + ...(resumeThreadId + ? { + resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( + accountHome.path, + resumeThreadId + ) + } + : {}) } } } diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 5672c7dbd70d..2259303a4b81 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -49,6 +49,7 @@ export async function codexProcessIdentity( export function codexProviderHandleLink(input: { threadId: string resumed: boolean + origin?: 'adopted' fence: number linkId?: string observedAt: number @@ -56,7 +57,7 @@ export function codexProviderHandleLink(input: { return { linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128), handle: { provider: 'codex', threadId: input.threadId }, - origin: input.resumed ? 'resumed' : 'created', + origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, observedAt: input.observedAt } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 0f41bbd51567..79cee9725223 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -170,7 +170,10 @@ describe('CodexStructuredSessionAdapter.acquire', () => { it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() - const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) + const adapter = adapterFor(codex, { + resumeThreadId: 'thread-proven', + resumePath: '/rollouts/thread-proven.jsonl' + }) const acquisition = await adapter.acquire({ identity: identityFor('session-1'), @@ -180,7 +183,11 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].calls[0]).toEqual({ method: 'thread/resume', - params: { threadId: 'thread-proven', cwd: '/work/repo' } + params: { + threadId: 'thread-proven', + cwd: '/work/repo', + path: '/rollouts/thread-proven.jsonl' + } }) expect(acquisition.link.origin).toBe('resumed') expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a9112230525f..24b656ce4fbd 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -116,6 +116,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap const opened = await openCodexThread(connection, launch, this.deps.requestTimeoutMs) this.acquisitions.assertCurrent(sessionId, attempt) primaryThreadId = opened.threadId + translator?.restoreThread(opened.threadId, opened.thread ?? {}) const process = await codexProcessIdentity( { ...input, pid: connection.pid }, this.deps.readProcessStartTime diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index ceb37d92ac22..fbe401032dde 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -13,6 +13,7 @@ export type CodexStructuredLaunch = { cwd: string codexHome: string | null resumeThreadId: string | null + resumePath?: string | null env?: Record } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 77e50da77790..fe977d6a37d6 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -10,6 +10,7 @@ import { readCodexThreadId, readCodexThreadPath } from './codex-structured-threa export type CodexOpenedThread = { threadId: string + thread?: Record /** Rollout file Codex named, when it named one. */ historyPath: string | null model?: string @@ -22,13 +23,17 @@ function nonEmptyString(value: unknown): string | null { export async function openCodexThread( connection: CodexAppServerConnection, - launch: { cwd: string; resumeThreadId: string | null }, + launch: { cwd: string; resumeThreadId: string | null; resumePath?: string | null }, timeoutMs: number | undefined ): Promise { const opened = await connection.request( launch.resumeThreadId ? 'thread/resume' : 'thread/start', launch.resumeThreadId - ? { threadId: launch.resumeThreadId, cwd: launch.cwd } + ? { + threadId: launch.resumeThreadId, + cwd: launch.cwd, + ...(launch.resumePath ? { path: launch.resumePath } : {}) + } : { cwd: launch.cwd }, { timeoutMs } ) @@ -40,10 +45,15 @@ export async function openCodexThread( throw new Error(`codex app-server resumed ${threadId} instead of ${launch.resumeThreadId}`) } const result = opened as Record + const thread = + typeof result.thread === 'object' && result.thread !== null + ? (result.thread as Record) + : {} const model = nonEmptyString(result.model) const effort = nonEmptyString(result.reasoningEffort) return { threadId, + thread, historyPath: readCodexThreadPath(opened), ...(model ? { model } : {}), ...(effort ? { effort } : {}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts index 843c6701df4e..957e835a655b 100644 --- a/src/main/codex/codex-tui-rollout-proof.test.ts +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -6,6 +6,7 @@ import { codexTuiStatusProbeInput, parseCodexTuiStatusSessionId, proveCodexTuiRollout, + resolveLiveCodexTuiRollout, resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' @@ -124,4 +125,30 @@ describe('Codex TUI rollout proof', () => { '\u001b' ]) }) + + it('discovers the live thread and resolves its pinned rollout', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => '/pinned/sessions/rollout.jsonl') + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD, transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(resolveRollout).toHaveBeenCalledWith('/pinned', THREAD) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\r', + '\u001b' + ]) + }) }) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts index 96bfd6b7695b..4572b1d68ba3 100644 --- a/src/main/codex/codex-tui-rollout-proof.ts +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -117,6 +117,48 @@ export async function proveCodexTuiRollout(input: { throw new Error('The agent terminal did not prove the expected Codex rollout.') } +export async function resolveLiveCodexTuiRollout(input: { + codexHome: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ threadId: string; transcriptPath: string }> { + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const output = input.readOutput() + if (output.lastOutputAt !== baselineOutputAt) { + const threadId = parseCodexTuiStatusSessionId(output.text) + if (threadId) { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + const transcriptPath = await resolveRollout(input.codexHome, threadId) + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + if (!transcriptPath) { + throw new Error('Could not find the rollout for this Codex conversation.') + } + return { threadId, transcriptPath } + } + } + await delay(100) + } + throw new Error('The agent terminal did not publish a resumable Codex conversation.') +} + async function readCodexRolloutSessionMetaId(filePath: string): Promise { // A listed rollout may vanish before it is read — Codex prunes and rewrites // these files. One missing file must not abort the whole scan. diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 3bf087b14d99..18b698c31011 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -73,6 +73,7 @@ import { snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' import { getCodexLedgerTrustedHash, @@ -140,6 +141,8 @@ type MirroredRuntimeUserHookTrustEntry = { enabled: boolean } +const CODEX_SESSION_NONCE_FORM_LINE_WINDOWS = `--data-urlencode "sessionNonce=%${AGENT_HOOK_SESSION_NONCE_ENV_VAR}%"` + function getManagedScriptPath(): string { return getSharedManagedScriptPath(getCodexManagedScriptFileName()) } @@ -772,7 +775,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - buildWindowsAgentHookCurlPostCommand('codex'), + buildWindowsAgentHookCurlPostCommand('codex', [CODEX_SESSION_NONCE_FORM_LINE_WINDOWS]), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -824,6 +827,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ` --data-urlencode "sessionNonce=\${${AGENT_HOOK_SESSION_NONCE_ENV_VAR}}" \\`, ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', diff --git a/src/main/index.ts b/src/main/index.ts index 5fabdadf94ae..bbd013da3df8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2725,6 +2725,9 @@ void app.whenReady().then(async () => { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + // Why: the adopted-Codex return path proves readiness from the resumed CLI's own + // SessionStart hook rather than the pane's screen, so it needs a live event tap. + subscribeAgentHookEvents: (listener) => agentHookServer.subscribeEnrichedStatus(listener), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 9b06723e7263..ffda31293104 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -98,6 +98,80 @@ describe('registerPtyHandlers', () => { } } }) + it('records route provenance when degraded daemon spawns fall back locally', async () => { + setLocalPtyProvider({ + routesFreshSpawnsToLocalProvider: true, + spawn: vi.fn(async () => ({ id: 'pty-degraded-local' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + launchAgent: 'codex' + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-degraded-local', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + }) + it('records degraded-local provenance through the runtime spawn controller', async () => { + type RuntimeSpawnController = { + spawn(args: Record): Promise<{ id: string }> + } + setLocalPtyProvider({ + routesFreshSpawnsToLocalProvider: true, + spawn: vi.fn(async () => ({ id: 'pty-runtime-degraded-local' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + () => TEST_CODEX_HOME, + getSettings as never + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + launchAgent: 'codex' + }) + + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-runtime-degraded-local', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + }) it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => { setLocalPtyProvider({ spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 83cca11e12cc..ee032a0dd2c4 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1476,15 +1476,13 @@ function codexHomePathsEqual(left: string | null, right: string): boolean { return codexHomeSelectionsEqual(left, right) } -// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon -// keeps that shell alive across app restarts, so the launch account is the only -// way to tell later that a pane still runs Codex as the previously selected -// account. A reattach inherits that baked environment rather than choosing one, -// so re-recording it under the current selection would erase the very evidence -// that the pane is stale. +// Why: CODEX_HOME is fixed in a shell's environment at spawn, so adoption and +// daemon reattach both need the launch account. A reattach inherits that baked +// environment rather than choosing one, so re-recording it under the current +// selection would erase the evidence that the pane is stale. function recordCodexPaneAccountForSpawn(args: { ptyId: string | undefined - isDaemonHostSpawn: boolean + isLocalExecutionHostSpawn: boolean isReattach: boolean pinnedByResume: boolean launchCodexHomePath: string | null @@ -1492,7 +1490,7 @@ function recordCodexPaneAccountForSpawn(args: { target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { - if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { + if (!args.ptyId || !args.isLocalExecutionHostSpawn || args.isReattach) { return } const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) @@ -5347,7 +5345,7 @@ export function registerPtyHandlers( } recordCodexPaneAccountForSpawn({ ptyId: result.id, - isDaemonHostSpawn, + isLocalExecutionHostSpawn: !args.connectionId, isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, @@ -6929,7 +6927,7 @@ export function registerPtyHandlers( }) recordCodexPaneAccountForSpawn({ ptyId: result.id, - isDaemonHostSpawn, + isLocalExecutionHostSpawn: !args.connectionId, isReattach: result.isReattach === true, pinnedByResume: codexResumeHomeSelected, launchCodexHomePath: selectedCodexHomePath, diff --git a/src/main/native-chat/agent-session-wire/structured-adopted-tui-owner.ts b/src/main/native-chat/agent-session-wire/structured-adopted-tui-owner.ts new file mode 100644 index 000000000000..38f18f3ebeff --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-adopted-tui-owner.ts @@ -0,0 +1,25 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' + +export function registerAdoptedStructuredTuiOwner(input: { + record: AgentSessionRecord + owner: StructuredTuiOwner + hostLabel?: string + retain: () => void + publish: (status: AgentSessionHandoffStatus) => void +}): void { + if (input.record.lease.runtimeKind !== 'tui' || input.record.lease.claimStatus !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + input.retain() + input.publish({ + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: input.owner.terminal, + ...(input.hostLabel ? { hostLabel: input.hostLabel } : {}) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts new file mode 100644 index 000000000000..f7438d43ca6f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -0,0 +1,21 @@ +// What attach needs from the host, named so the host cannot grow attach's dependencies unnoticed. + +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +export type StructuredAgentSessionAttachContext = { + deps: StructuredAgentSessionHostDeps + runtimeState: StructuredAgentSessionHostRuntimeState + sessions: Map + subscribers: AgentSessionSubscribers + tasks: StructuredAgentSessionTaskQueue + reconcileLeases: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts new file mode 100644 index 000000000000..07da0ce0d5cd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -0,0 +1,77 @@ +// The host's attach, lifted out of the host class. +// +// Attach is the one operation that touches every collaborator the host owns — the lease +// reconciler, the recovery resolver, the event sink, the journal, the subscriber set and the task +// queue — so leaving it inline made the host grow every time any of them did. The host keeps the +// state; this owns the ordering between them. + +import { randomUUID } from 'node:crypto' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' + +export function attachStructuredAgentSession( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise> { + const sessionId = params.envelope.sessionId + const attaching = context.serialize(sessionId, async () => { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return refuseAgentSessionMutation(unreconciled) + } + await context.runtimeState.resolveRecovery(sessionId) + const eventSink = context.runtimeState.eventSinkFor(sessionId) + const attached = await performAttach({ + store: context.deps.store, + adapter: context.deps.adapter, + journalRoot: context.deps.journalRoot, + eventSink: eventSink.sink, + onAcquiring: () => eventSink.unbind(), + beforeJournalOpen: async () => { + eventSink.unbind() + await eventSink.drained() + }, + authority: { + spawnToken: context.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: context.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: await context.runtimeState.probeOwner(sessionId) + }, + callerKey, + params, + now: () => context.now(), + onAttached: (attached) => { + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previousFence = context.sessions.get(sessionId)?.fence + context.sessions.set(sessionId, { journal: attached.journal, params, fence }) + if (attached.recovery) { + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } + eventSink.bind({ + journal: attached.journal, + fence, + publish: () => context.subscribers.publish(sessionId, attached.journal) + }) + } + }) + // Why: a failed attach that left no session behind must not strand a bound sink; the runtime + // caches one per session id and would hand this same closed instance to the next attempt. + if (!attached.ok && !context.sessions.has(sessionId)) { + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + } + return attached + }) + return context.tasks.trackAttach(attaching) +} 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 8378cbc721c8..0769212bbfd5 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 @@ -78,11 +78,13 @@ export async function handoffStructuredSessionToNative( throw new Error('agent_session_operation_conflict') } deps.stopTuiHistoryCatchup?.(sessionId) - await deps.importTuiHistory({ - sessionId, - fence: record.lease.runtimeFence, - ...(transcriptPath ? { transcriptPath } : {}) - }) + if (owner?.historySource !== 'provider-resume') { + await deps.importTuiHistory({ + sessionId, + fence: record.lease.runtimeFence, + ...(transcriptPath ? { transcriptPath } : {}) + }) + } const spawnToken = randomUUID() record = await reserveStoredAgentSessionHandoffOwner(deps.store, { sessionId, @@ -116,7 +118,8 @@ export async function handoffStructuredSessionToNative( context.releaseOwner(sessionId) deps.transport?.revealNativeSession?.({ workspaceId: record.location.workspaceId, - sessionId + sessionId, + ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) }) context.setStatus(sessionId, { owner: 'native', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts new file mode 100644 index 000000000000..753b29941160 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts @@ -0,0 +1,46 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { registerAdoptedStructuredTuiOwner } from './structured-adopted-tui-owner' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffState { + private readonly statuses = new Map() + private readonly tuiOwners = new Map() + + constructor( + private readonly deps: { + requireRecord: (sessionId: string) => AgentSessionRecord + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + hostLabel?: string + } + ) {} + + status = (sessionId: string): AgentSessionHandoffStatus => + this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.deps.requireRecord(sessionId)) + + cachedStatus = (sessionId: string): AgentSessionHandoffStatus | undefined => + this.statuses.get(sessionId) + + owner = (sessionId: string): StructuredTuiOwner | undefined => this.tuiOwners.get(sessionId) + + retainOwner = (sessionId: string, owner: StructuredTuiOwner): void => { + this.tuiOwners.set(sessionId, owner) + } + + releaseOwner = (sessionId: string): void => void this.tuiOwners.delete(sessionId) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => { + this.statuses.set(sessionId, status) + this.deps.publish(sessionId, status) + } + + adoptTuiOwner = (sessionId: string, owner: StructuredTuiOwner): void => + registerAdoptedStructuredTuiOwner({ + record: this.deps.requireRecord(sessionId), + owner, + hostLabel: this.deps.hostLabel, + retain: () => this.retainOwner(sessionId, owner), + publish: (status) => this.setStatus(sessionId, status) + }) +} 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 e3d125f7e04c..71c13f9a90c9 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 @@ -12,6 +12,10 @@ export type StructuredTuiOwner = { process: AgentSessionProcessIdentity link: AgentSessionProviderHandleLink transcriptPath?: string + /** Codex app-server resume, not row-by-row legacy import, restores this owner's history. */ + historySource?: 'provider-resume' + /** This owner came from an existing terminal view rather than a structured-session tab. */ + adoptedTerminal?: true } export class StructuredTuiLaunchCleanupError extends Error { @@ -40,7 +44,11 @@ export type StructuredAgentSessionHandoffTransport = { probeRecoveredOwner?(record: AgentSessionRecord): Promise<'live' | 'dead' | 'unknown'> stopRecoveredOwner(record: AgentSessionRecord): Promise closeTuiOwner?(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> - revealNativeSession?(input: { workspaceId: string; sessionId: string }): void + revealNativeSession?(input: { + workspaceId: string + sessionId: string + adoptedTerminal?: true + }): void waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> waitForTuiIdleOrExit( owner: StructuredTuiOwner, 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 e14041e1d0de..dbae06a771a7 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 @@ -36,34 +36,24 @@ let tuiIdle: boolean let tuiReadiness: 'idle' | 'exited' | null let importFailure: Error | null let nativeAcquireFailure: Error | null -let launchTui: ReturnType> -let waitForTuiExit: ReturnType< - typeof vi.fn -> -let closeTuiOwner: ReturnType< - typeof vi.fn> -> -let waitForTuiIdleOrExit: ReturnType< - typeof vi.fn -> -let reproveTuiOwner: ReturnType< - typeof vi.fn -> -let stopFailedTuiLaunch: ReturnType< - typeof vi.fn> +type TransportMock = ReturnType< + typeof vi.fn< + Extract, (...args: never[]) => unknown> + > > +let launchTui: TransportMock<'launchTui'> +let waitForTuiExit: TransportMock<'waitForTuiExit'> +let closeTuiOwner: TransportMock<'closeTuiOwner'> +let waitForTuiIdleOrExit: TransportMock<'waitForTuiIdleOrExit'> +let reproveTuiOwner: TransportMock<'reproveTuiOwner'> +let stopFailedTuiLaunch: TransportMock<'stopFailedTuiLaunch'> let acquireNativeStop: ReturnType Promise>> let acquireNativeCalls: number -let stopRecoveredOwner: ReturnType< - typeof vi.fn -> +let stopRecoveredOwner: TransportMock<'stopRecoveredOwner'> let operations: number -let prepareTuiHistoryCatchup: ReturnType< - typeof vi.fn<(sessionId: string, fence: number) => Promise> -> -let recoverTuiHistoryCatchup: ReturnType< - typeof vi.fn<(sessionId: string, fence: number) => Promise> -> +type HistoryCatchup = (sessionId: string, fence: number) => Promise +let prepareTuiHistoryCatchup: ReturnType> +let recoverTuiHistoryCatchup: ReturnType> let activateTuiHistoryCatchup: ReturnType Promise>> let stopTuiHistoryCatchup: ReturnType void>> @@ -646,8 +636,7 @@ describe('structured session ownership handoff', () => { it('re-proves the current TUI handle before accepting its exit and importing history', async () => { await moveToTui() - const reverse = request('to-native', 'now') - expect(await submit(reverse)).toMatchObject({ ok: true }) + await expectAccepted(request('to-native', 'now')) await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) expect(reproveTuiOwner).toHaveBeenCalledOnce() @@ -656,6 +645,20 @@ describe('structured session ownership handoff', () => { ) }) + it('does not acquire native ownership until TUI exit proof resolves', async () => { + // oxfmt-ignore + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ ...makeTuiOwner(fence, spawnToken), historySource: 'provider-resume' })) + await moveToTui() + const exitProof = Promise.withResolvers<{ transcriptPath?: string }>() + closeTuiOwner.mockImplementationOnce(() => exitProof.promise) + await expectAccepted(request('to-native', 'now')) + await vi.waitFor(() => expect(closeTuiOwner).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(acquireNativeCalls).toBe(0) + exitProof.resolve({}) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + }) + it('does not close or import when the current TUI handle cannot be re-proved', async () => { await moveToTui() reproveTuiOwner.mockRejectedValueOnce(new Error('provider handle changed')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 156a8cc5dde0..b523cbc453c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -44,16 +44,18 @@ import type { StructuredAgentSessionHandoffFlowContext, StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' export class StructuredAgentSessionHandoffCoordinator { - private readonly statuses = new Map() private readonly queue = new StructuredAgentSessionHandoffQueue() - private readonly tuiOwners = new Map() private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner + private readonly state: StructuredAgentSessionHandoffState constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + // oxfmt-ignore + this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ deps, operationGuard: this.operationGuard, @@ -62,8 +64,13 @@ export class StructuredAgentSessionHandoffCoordinator { }) } - status = (sessionId: string): AgentSessionHandoffStatus => - this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId)) + status = (sessionId: string) => this.state.status(sessionId) + + adoptTuiOwner = (sessionId: string, owner: StructuredTuiOwner): void => + this.state.adoptTuiOwner(sessionId, owner) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => + this.state.setStatus(sessionId, status) drain = (): Promise => this.flowRunner.drain() @@ -73,8 +80,8 @@ export class StructuredAgentSessionHandoffCoordinator { deps: this.deps, requireRecord: (id) => this.requireRecord(id), flowContext: () => this.flowContext(), - retainOwner: (id, owner) => this.tuiOwners.set(id, owner), - setStatus: (id, status) => this.setStatus(id, status) + retainOwner: this.state.retainOwner, + setStatus: this.state.setStatus }, sessionId ) @@ -85,7 +92,7 @@ export class StructuredAgentSessionHandoffCoordinator { params: AgentSessionHandoffRequest ): Promise> { let record = this.requireRecord(params.envelope.sessionId) - const currentStatus = this.statuses.get(record.sessionId) + const currentStatus = this.state.cachedStatus(record.sessionId) const admission = await admitStructuredHandoffRequest({ deps: this.deps, operationGuard: this.operationGuard, @@ -106,7 +113,7 @@ export class StructuredAgentSessionHandoffCoordinator { } const { fingerprint } = admission record = this.requireRecord(params.envelope.sessionId) - const admittedStatus = this.statuses.get(record.sessionId) + const admittedStatus = this.state.cachedStatus(record.sessionId) const action = params.action ?? 'start' if (action === 'cancel-queued') { if ( @@ -126,7 +133,7 @@ export class StructuredAgentSessionHandoffCoordinator { status: 'failed', code: 'agent_session_operation_conflict' }) - this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + this.state.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) await this.deps.store.recordOperationOutcome({ callerKey, operationId: params.envelope.clientOperationId, @@ -159,7 +166,7 @@ export class StructuredAgentSessionHandoffCoordinator { fingerprint, requireRecord: (sessionId) => this.requireRecord(sessionId), restore: (sessionId) => this.restore(sessionId), - setStatus: (sessionId, status) => this.setStatus(sessionId, status) + setStatus: this.state.setStatus }) this.flowRunner.track(recovery) return this.success(record.sessionId, false) @@ -198,7 +205,7 @@ export class StructuredAgentSessionHandoffCoordinator { const turnId = activeStructuredAgentSessionTurnId( this.deps.session(record.sessionId).journal.snapshot().items ) - const tuiOwner = this.tuiOwners.get(record.sessionId) + const tuiOwner = this.state.owner(record.sessionId) const busy = turnId !== null || (expectedOwner === 'tui' && @@ -220,7 +227,7 @@ export class StructuredAgentSessionHandoffCoordinator { tuiOwner, status: () => this.status(record.sessionId), requireRecord: () => this.requireRecord(record.sessionId), - setStatus: (status) => this.setStatus(record.sessionId, status), + setStatus: (status) => this.state.setStatus(record.sessionId, status), begin: (next, exited) => this.begin(callerKey, next, null, fingerprint, exited), refuse: (latest) => this.refuseQueued(params, latest) }) @@ -263,7 +270,7 @@ export class StructuredAgentSessionHandoffCoordinator { status: 'failed', code: 'agent_session_checkpoint_stale' }) - .then(() => this.setStatus(record.sessionId, idleStructuredHandoffStatus(record))) + .then(() => this.state.setStatus(record.sessionId, idleStructuredHandoffStatus(record))) .catch((error) => this.fail(params, error)) this.flowRunner.track(settlement) } @@ -287,28 +294,22 @@ export class StructuredAgentSessionHandoffCoordinator { private flowContext(): StructuredAgentSessionHandoffFlowContext { return createStructuredHandoffFlowContext({ deps: this.deps, - owner: (sessionId) => this.tuiOwners.get(sessionId), - retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner), - releaseOwner: (sessionId) => void this.tuiOwners.delete(sessionId), - setStatus: (sessionId, status) => this.setStatus(sessionId, status), + owner: this.state.owner, + retainOwner: this.state.retainOwner, + releaseOwner: this.state.releaseOwner, + setStatus: this.state.setStatus, requireRecord: (sessionId) => this.requireRecord(sessionId) }) } private fail(params: AgentSessionHandoffRequest, error: unknown): void { const record = this.requireRecord(params.envelope.sessionId) - this.setStatus( + this.state.setStatus( record.sessionId, failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) ) } - setStatus(sessionId: string, status: AgentSessionHandoffStatus): void { - this.statuses.set(sessionId, status) - this.deps.publish(sessionId, status) - } - - private requireRecord(sessionId: string): AgentSessionRecord { - return requireStructuredHandoffRecord(this.deps, sessionId) - } + private requireRecord = (sessionId: string): AgentSessionRecord => + requireStructuredHandoffRecord(this.deps, 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 b38b1b98e8f2..ef3ea367ff2e 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 @@ -1,7 +1,6 @@ // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. -import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { @@ -21,11 +20,9 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' import { admitAndRunAgentSessionMutation, - AGENT_SESSION_NOT_ATTACHED, - refuseAgentSessionMutation + AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import { @@ -50,11 +47,24 @@ import { import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + adoptStructuredTuiOwner, + type StructuredTuiAdoptionRequest +} from './structured-agent-session-tui-adoption' +import { + releaseStructuredTuiAdoptionReservation, + reserveStructuredTuiAdoption, + type StructuredTuiAdoptionReservationRelease, + type StructuredTuiAdoptionReservationRequest, + type StructuredTuiAdoptionReservationResult +} from './structured-agent-session-tui-adoption-reservation' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -116,21 +126,18 @@ export class StructuredAgentSessionHost { hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean { - return providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - } + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => + providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) listSessionTabs() { return listStructuredAgentSessionTabs(this.sessions) } - restoreReadableSessions(): Promise { - return this.restartRestore.run(() => this.readableRestorer.restore()) - } + restoreReadableSessions = (): Promise => + this.restartRestore.run(() => this.readableRestorer.restore()) - private serialize(sessionId: string, task: () => Promise): Promise { - return this.tasks.serialize(sessionId, task) - } + private serialize = (sessionId: string, task: () => Promise): Promise => + this.tasks.serialize(sessionId, task) private restoreRenewedHandoff(sessionId: string): Promise { return this.serialize(sessionId, async () => { @@ -140,67 +147,58 @@ export class StructuredAgentSessionHost { }) } + /** What attach needs from this host, named so its dependencies cannot grow unnoticed. */ + private attachContext(): StructuredAgentSessionAttachContext { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + subscribers: this.subscribers, + tasks: this.tasks, + reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + } + } + attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams ): Promise> { - const attaching = this.serialize(params.envelope.sessionId, async () => { - const sessionId = params.envelope.sessionId - const unreconciled = await this.reconcileLeases(sessionId) - if (unreconciled) { - return refuseAgentSessionMutation(unreconciled) - } - await this.runtimeState.resolveRecovery(sessionId) - const eventSink = this.runtimeState.eventSinkFor(sessionId) - const attached = await performAttach({ - store: this.deps.store, - adapter: this.deps.adapter, - journalRoot: this.deps.journalRoot, - eventSink: eventSink.sink, - onAcquiring: () => eventSink.unbind(), - beforeJournalOpen: async () => { - eventSink.unbind() - await eventSink.drained() - }, - authority: { - spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), - claimKeyId: this.deps.claimKeyId, - handoffOperationId: params.envelope.clientOperationId, - probe: await this.runtimeState.probeOwner(sessionId) - }, - callerKey: caller.callerKey, - params, - now: () => this.now(), - onAttached: (attached) => { - const fence = this.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 - const previousFence = this.sessions.get(sessionId)?.fence - this.sessions.set(sessionId, { journal: attached.journal, params, fence }) - if (attached.recovery) { - this.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) - } else if (previousFence !== undefined && previousFence !== fence) { - this.subscribers.snapshot(sessionId, attached.journal, fence) - } else { - this.subscribers.publish(sessionId, attached.journal) - } - eventSink.bind({ - journal: attached.journal, - fence, - publish: () => this.subscribers.publish(sessionId, attached.journal) - }) - } - }) - if (!attached.ok && !this.sessions.has(sessionId)) { - eventSink.close() - this.runtimeState.discardEventSink(sessionId) - } - return attached - }) - return this.tasks.trackAttach(attaching) + return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } - flushStreamedEvents(sessionId: string): Promise { - return this.runtimeState.flushEventSink(sessionId) - } + /** Reserve the lease an adopted TUI must hold before the write gate will admit its proof. */ + reserveAdoptedTuiOwner = ( + input: StructuredTuiAdoptionReservationRequest + ): Promise => + this.serialize(input.sessionId, () => + reserveStructuredTuiAdoption({ ...input, deps: this.deps, now: this.now }) + ) + + /** Hand back a reservation whose proof never landed, so the next attempt is not refused by it. */ + releaseAdoptedTuiReservation = (input: StructuredTuiAdoptionReservationRelease): Promise => + this.serialize(input.sessionId, () => + releaseStructuredTuiAdoptionReservation({ ...input, deps: this.deps, now: this.now }) + ) + + adoptTuiOwner = ( + input: StructuredTuiAdoptionRequest + ): Promise> => + this.serialize(input.params.envelope.sessionId, () => + adoptStructuredTuiOwner({ + ...input, + deps: this.deps, + now: this.now, + publish: (sessionId, session) => this.sessions.set(sessionId, session), + retain: (sessionId, owner) => this.handoffs.adoptTuiOwner(sessionId, owner), + snapshot: (sessionId, session) => + this.subscribers.snapshot(sessionId, session.journal, session.fence) + }) + ) + + flushStreamedEvents = (sessionId: string): Promise => + this.runtimeState.flushEventSink(sessionId) async flushAllStreamedEvents(): Promise { this.runtimeState.stopLeaseRenewal() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts new file mode 100644 index 000000000000..498a5b959624 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts @@ -0,0 +1,169 @@ +/** + * The reservation an adopted Codex TUI needs BEFORE it may talk to the provider. + * + * Adoption proves the running Codex by typing `/status` into its own pane, and + * `agentSessionPtyWriteGate.admitProof` admits that probe only against a durable reservation that + * already names this session, this runtime kind, and this spawn token. Reserving after the proof — + * the shape this replaces — meant the probe was never admitted, so no adoption could ever succeed. + * + * The reservation is therefore created first and released again when the proof does not land, so a + * failed attempt leaves nothing behind that would refuse the next one. + */ + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionLaunchEnv, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { classifyStoreFailure } from './structured-agent-session-attach' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps +} from './structured-agent-session-host-types' + +export type StructuredTuiAdoptionReservationRequest = { + caller: StructuredAgentSessionCaller + sessionId: string + clientOperationId: string + /** The adoption call's own payload fingerprint; the durable operation row is keyed by it. */ + fingerprint: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + accountHome: AgentSessionAccountHome + spawnToken: string + claimKeyId: string + launchEnv?: AgentSessionLaunchEnv +} + +export type StructuredTuiAdoptionReservationResult = + | { + ok: true + fence: number + /** The token the proof must carry: a replayed call keeps the one already on the record. */ + spawnToken: string + } + | { ok: false; refusal: AgentSessionWireRefusal } + +export type StructuredTuiAdoptionReservationRelease = { + sessionId: string + fence: number + spawnToken: string +} + +/** The exact lease shape `admitProof` accepts as `provingReservation`. */ +export function isStructuredTuiAdoptionReservation( + record: AgentSessionRecord, + spawnToken: string +): boolean { + const { lease } = record + return ( + lease.runtimeKind === 'tui' && + lease.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + (lease.ownerProcess === null || lease.ownerProcess.spawnToken === spawnToken) && + lease.reservedSpawnToken === spawnToken && + !lease.unreconciled + ) +} + +export async function reserveStructuredTuiAdoption( + input: StructuredTuiAdoptionReservationRequest & { + deps: Pick + now: () => number + } +): Promise { + const { deps, sessionId } = input + const existing = deps.store.getRecord(sessionId) + try { + const reserved = await deps.store.reserveOwner({ + sessionId, + location: input.location, + provider: input.provider, + accountHome: input.accountHome, + ...(input.launchEnv ? { launchEnv: input.launchEnv } : {}), + runtimeKind: 'tui', + // An adoption never declares a fence, so the compare-and-swap runs against what the store + // holds right now and the probe below is what refuses when that record still has an owner. + expectedFence: existing?.lease.runtimeFence ?? null, + spawnToken: input.spawnToken, + claimKeyId: input.claimKeyId, + // Why null: a retry mints a fresh operation id, and a non-null one here would make the + // reservation left behind by the previous attempt refuse it as a different operation. + handoffOperationId: null, + probe: await previousOwnerProbe(input, existing), + operation: { + callerKey: input.caller.callerKey, + operationId: input.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now() + }) + const spawnToken = reserved.record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + return { ok: true, fence: reserved.record.lease.runtimeFence, spawnToken } + } catch (error) { + const current = deps.store.getRecord(sessionId) + return { + ok: false, + refusal: classifyStoreFailure(error, current?.lease.runtimeFence ?? null, current) + } + } +} + +/** + * Undo a reservation whose proof never landed. Adoption only attributes its synthetic token to an + * existing process, so clearing a partially committed identity truthfully restores the durable + * "nothing spawned under this token" proof needed by the next attempt. + */ +export async function releaseStructuredTuiAdoptionReservation( + input: StructuredTuiAdoptionReservationRelease & { + deps: Pick + now: () => number + } +): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if ( + !record || + record.lease.runtimeFence !== input.fence || + !isStructuredTuiAdoptionReservation(record, input.spawnToken) + ) { + // Superseded, already proven, or gone: whatever holds the record now is not this attempt's. + return + } + const now = input.now() + await input.deps.store.transitionHandoff(input.sessionId, (current) => { + if ( + current.lease.runtimeFence !== input.fence || + !isStructuredTuiAdoptionReservation(current, input.spawnToken) + ) { + return current + } + return { + ...current, + lease: { ...current.lease, ownerProcess: null, processlessAt: now }, + updatedAt: now + } + }) +} + +function previousOwnerProbe( + input: { deps: Pick }, + existing: AgentSessionRecord | null +): Promise | AgentSessionOwnerProbe { + if (!existing) { + return { outcome: 'reservation-unused' } + } + // Fails closed: without a prober the store must not be told the previous owner is gone. + return ( + input.deps.probeOwner?.(existing) ?? { + outcome: 'indeterminate', + reason: 'this host cannot probe the recorded owner' + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts new file mode 100644 index 000000000000..f0eaf49da548 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts @@ -0,0 +1,148 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: ReturnType> + +function params(): AgentSessionAttachParams { + return hostTestAttachParams(null, { runtimeKind: 'tui' }) +} + +function owner(): StructuredTuiOwner { + return { + terminal: { + handle: 'term-adopted', + tabId: 'tab-adopted', + paneKey: 'tab-adopted:leaf-adopted', + ptyId: 'pty-adopted' + }, + process: { + hostId: 'local', + pid: 5252, + processStartTimeMs: NOW - 100, + spawnToken: 'adopted-spawn' + }, + link: { + linkId: 'adopted-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: NOW + }, + transcriptPath: '/home/dev/.codex/sessions/rollout.jsonl', + historySource: 'provider-resume', + adoptedTerminal: true + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-tui-adoption-')) + resetHostTestOperationIds() + acquire = vi.fn() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** What the runtime does before it may type `/status` into the pane it is adopting. */ +async function reserveAdoption(attach: AgentSessionAttachParams, spawnToken: string) { + return host.reserveAdoptedTuiOwner({ + caller: CALLER, + sessionId: attach.envelope.sessionId, + clientOperationId: attach.envelope.clientOperationId, + fingerprint: attach.envelope.payloadFingerprint, + location: attach.location, + provider: 'codex', + accountHome: attach.accountHome, + spawnToken, + claimKeyId: 'key-1' + }) +} + +describe('structured TUI adoption', () => { + it('mints a durable TUI owner without starting a native provider', async () => { + const adoptedOwner = owner() + const attach = params() + await expect(reserveAdoption(attach, adoptedOwner.process.spawnToken)).resolves.toMatchObject({ + ok: true, + fence: 1 + }) + + const result = await host.adoptTuiOwner({ + caller: CALLER, + params: attach, + owner: adoptedOwner + }) + + expect(result).toMatchObject({ ok: true, replayed: false, fence: 1 }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)).toMatchObject({ + providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD } }], + lease: { runtimeKind: 'tui', claimStatus: 'live', ownerProcess: adoptedOwner.process } + }) + await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + owner: 'tui', + phase: 'idle', + terminal: adoptedOwner.terminal + }) + }) + + it('refuses input whose durable identity fingerprint changed', async () => { + const invalid = params() + invalid.envelope.payloadFingerprint = '0'.repeat(64) + + const result = await host.adoptTuiOwner({ caller: CALLER, params: invalid, owner: owner() }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + expect(store.getRecord(SESSION)).toBeNull() + expect(acquire).not.toHaveBeenCalled() + }) + + // The reservation is the only way in: adoption has no second mode that acquires the lease + // itself, so nothing here may invent an owner for a session the store has never heard of. + it('refuses to adopt a session that holds no reservation', async () => { + await expect( + host.adoptTuiOwner({ caller: CALLER, params: params(), owner: owner() }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(store.getRecord(SESSION)).toBeNull() + expect(acquire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts new file mode 100644 index 000000000000..f8aeacf3250f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts @@ -0,0 +1,131 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { admitAttachOrRefuse, attachJournal } from './structured-agent-session-attach' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { isStructuredTuiAdoptionReservation } from './structured-agent-session-tui-adoption-reservation' + +export type StructuredTuiAdoptionRequest = { + caller: StructuredAgentSessionCaller + params: AgentSessionAttachParams + owner: StructuredTuiOwner + onOwnerProven?: () => void +} + +export function adoptStructuredTuiOwner( + input: StructuredTuiAdoptionRequest & { + deps: StructuredAgentSessionHostDeps + now: () => number + publish: (sessionId: string, session: StructuredAgentSessionHostSession) => void + retain: (sessionId: string, owner: StructuredTuiOwner) => void + snapshot: (sessionId: string, session: StructuredAgentSessionHostSession) => void + } +): Promise> { + return adopt(input) +} + +async function adopt( + input: Parameters[0] +): Promise> { + const { params, owner } = input + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + let record = input.deps.store.getRecord(sessionId) + if (!record) { + // Adoption reserves the lease before it may prove the pane, and nothing deletes a record, so + // this call always runs against one. Refusing beats reserving here: a second acquisition mode + // that no caller can enter is coverage that proves nothing. + throw new Error('agent_session_ownership_unknown') + } + let replayed = true + if (isStructuredTuiAdoptionReservation(record, owner.process.spawnToken)) { + // This adoption's own pre-proof reservation: turn it into an owner rather than reserve again. + replayed = false + record = await proveAdoptedTuiOwner(input, record) + } else { + assertMatchingTuiOwner(record, owner) + } + input.onOwnerProven?.() + const attached = await attachJournal({ + record, + params, + journalRoot: input.deps.journalRoot, + adapter: input.deps.adapter + }) + const session = { journal: attached.journal, params, fence: record.lease.runtimeFence } + input.publish(sessionId, session) + input.retain(sessionId, owner) + input.snapshot(sessionId, session) + await input.deps.store.recordOperationOutcome({ + callerKey: input.caller.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + return { + ok: true, + replayed, + fence: record.lease.runtimeFence, + cursor: attached.journal.cursor(), + value: { + sessionId, + fence: record.lease.runtimeFence, + snapshot: attached.journal.snapshot(), + unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds + } + } +} + +/** Steps 4 and 5 of acquisition against a reservation that already exists at this fence. */ +async function proveAdoptedTuiOwner( + input: Parameters[0], + record: AgentSessionRecord +): Promise { + const { sessionId } = record + const fence = record.lease.runtimeFence + if (record.lease.ownerProcess === null) { + await input.deps.store.commitProcessIdentity({ + sessionId, + fence, + process: input.owner.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, input.owner.process)) { + throw new Error('agent_session_conflict') + } + return input.deps.store.proveOwner({ + sessionId, + fence, + link: input.owner.link, + now: input.now() + }) +} + +function assertMatchingTuiOwner( + record: NonNullable>, + owner: StructuredTuiOwner +): void { + const head = record.providerHandleChain.at(-1)?.handle + const adopted = owner.link.handle + if ( + record.lease.runtimeKind !== 'tui' || + record.lease.claimStatus !== 'live' || + !isDeepStrictEqual(record.lease.ownerProcess, owner.process) || + head?.provider !== 'codex' || + adopted.provider !== 'codex' || + head.threadId !== adopted.threadId + ) { + throw new Error('agent_session_conflict') + } +} diff --git a/src/main/runtime/adopted-codex-tui-stop.test.ts b/src/main/runtime/adopted-codex-tui-stop.test.ts new file mode 100644 index 000000000000..7df892eaa5c1 --- /dev/null +++ b/src/main/runtime/adopted-codex-tui-stop.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { stopAdoptedCodexTui } from './adopted-codex-tui-stop' + +const IDENTITY = { + hostId: 'local', + pid: 5252, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'adopted-spawn' +} + +const matched: AgentSessionOwnerProbe = { + outcome: 'identity-matched', + matchedOn: ['process-start-time'] +} + +describe('stopAdoptedCodexTui', () => { + it('signals only after re-proving identity and returns only after proven absence', async () => { + const events: string[] = [] + const proofs: AgentSessionOwnerProbe[] = [matched, matched, { outcome: 'pid-absent' }] + + await stopAdoptedCodexTui({ + identity: IDENTITY, + probe: vi.fn(async () => { + events.push('probe') + return proofs.shift()! + }), + signal: vi.fn((_pid, signal) => events.push(signal)), + sleep: vi.fn(async () => undefined), + attempts: 3 + }) + + expect(events).toEqual(['probe', 'SIGTERM', 'probe', 'probe']) + }) + + it('refuses to signal when the recorded process cannot be re-proved', async () => { + const signal = vi.fn() + const indeterminate: AgentSessionOwnerProbe = { + outcome: 'indeterminate', + reason: 'start time unavailable' + } + + await expect( + stopAdoptedCodexTui({ + identity: IDENTITY, + probe: vi.fn(async () => indeterminate), + signal + }) + ).rejects.toThrow('could not be re-proved') + expect(signal).not.toHaveBeenCalled() + }) + + it('refuses native ownership while the TUI remains live', async () => { + const signal = vi.fn() + + await expect( + stopAdoptedCodexTui({ + identity: IDENTITY, + probe: vi.fn(async () => matched), + signal, + sleep: vi.fn(async () => undefined), + attempts: 4 + }) + ).rejects.toThrow('exit could not be proven') + expect(signal).toHaveBeenNthCalledWith(1, IDENTITY.pid, 'SIGTERM') + expect(signal).toHaveBeenNthCalledWith(2, IDENTITY.pid, 'SIGKILL') + }) +}) diff --git a/src/main/runtime/adopted-codex-tui-stop.ts b/src/main/runtime/adopted-codex-tui-stop.ts new file mode 100644 index 000000000000..b06169dcc81f --- /dev/null +++ b/src/main/runtime/adopted-codex-tui-stop.ts @@ -0,0 +1,60 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' + +const STOP_ATTEMPTS = 50 +const STOP_POLL_MS = 100 + +function provesAbsence(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +function provesIdentity(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'identity-matched' && proof.matchedOn.length > 0 +} + +export async function stopAdoptedCodexTui(input: { + identity: AgentSessionProcessIdentity + probe?: (identity: AgentSessionProcessIdentity) => Promise + signal?: (pid: number, signal: NodeJS.Signals) => void + sleep?: (delayMs: number) => Promise + attempts?: number + pollMs?: number +}): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const signal = input.signal ?? process.kill + const initial = await probe(input.identity) + if (provesAbsence(initial)) { + return + } + if (!provesIdentity(initial)) { + throw new Error( + 'The Codex TUI process could not be re-proved; structured chat was not started.' + ) + } + + try { + signal(input.identity.pid, 'SIGTERM') + } catch (error) { + if (provesAbsence(await probe(input.identity))) { + return + } + throw error + } + + const attempts = input.attempts ?? STOP_ATTEMPTS + const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) + for (let attempt = 0; attempt < attempts; attempt += 1) { + const proof = await probe(input.identity) + if (provesAbsence(proof)) { + return + } + if (attempt === 2 && provesIdentity(proof)) { + signal(input.identity.pid, 'SIGKILL') + } + if (attempt + 1 < attempts) { + await sleep(input.pollMs ?? STOP_POLL_MS) + } + } + throw new Error('The Codex TUI exit could not be proven; structured chat was not started.') +} diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts index 7d3ef8e65764..94ad79c051bb 100644 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -76,6 +76,48 @@ describe('binding lifecycle', () => { }) }) +describe('overlapping adoption attempts on one pane', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + }) + + it('keeps the newer attempt bound when the one it superseded gives up', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-b')).toBe(true) + + // Both attempts carry the same session, so only the spawn token can tell this release apart. + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-b')).toBe(true) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) + + it('leaves a settled owner pane alone when a later attempt fails', () => { + gate.bindPty(PTY_ID, SESSION_ID) + + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-late')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-late')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles a proven attempt so no later attempt can release its pane', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(true) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles nothing once the pane is gone', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + gate.unbindPty(PTY_ID) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) +}) + describe('admission through the store', () => { beforeEach(() => { gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts index f252e6f938b2..8fb078eb3b97 100644 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -31,8 +31,17 @@ const ADMITTED_UNBOUND: AgentSessionPtyWriteAdmission = { runtimeFence: null } +/** + * A pane binding, plus the adoption attempt that is still proving it. + * + * `attemptToken` is null once an owner is proven — a settled binding no attempt may take away. + * While it is non-null the binding belongs to that one in-flight attempt, which is the only thing + * that tells two overlapping adoptions apart: both carry the same sessionId. + */ +type BoundPane = { sessionId: string; attemptToken: string | null } + export class AgentSessionPtyWriteGate { - private readonly sessionIdByPtyId = new Map() + private readonly panesByPtyId = new Map() private lookup: AgentSessionRecordLookup | null = null /** Point the gate at the durable store. Until this is called nothing can be enforced. */ @@ -42,24 +51,62 @@ export class AgentSessionPtyWriteGate { detachRecordLookup(): void { this.lookup = null - this.sessionIdByPtyId.clear() + this.panesByPtyId.clear() } bindPty(ptyId: string, sessionId: string): void { - this.sessionIdByPtyId.set(ptyId, sessionId) + this.panesByPtyId.set(ptyId, { sessionId, attemptToken: null }) } unbindPty(ptyId: string): void { - this.sessionIdByPtyId.delete(ptyId) + this.panesByPtyId.delete(ptyId) + } + + /** + * Claim the pane for one adoption attempt, identified by the spawn token its reservation minted. + * A settled owner keeps the pane; another attempt's claim is superseded, because the newest + * reservation is the one the record now names. Callers must already have refused a pane bound to + * a different session. + */ + bindPtyForAttempt(ptyId: string, sessionId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current && current.attemptToken === null) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId, attemptToken }) + return true + } + + /** Promote this attempt's claim to a settled binding once its owner is proven. */ + settlePtyAttempt(ptyId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId: current.sessionId, attemptToken: null }) + return true + } + + /** + * Compare-and-clear: hand the pane back only while this attempt still holds it. A losing attempt + * that unbinds by pty alone rips the pane out from under the attempt that superseded it, and then + * that one's proof is refused too — both fail where one should have won. + */ + releasePtyAttempt(ptyId: string, attemptToken: string): boolean { + if (this.panesByPtyId.get(ptyId)?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.delete(ptyId) + return true } boundSessionId(ptyId: string): string | null { - return this.sessionIdByPtyId.get(ptyId) ?? null + return this.panesByPtyId.get(ptyId)?.sessionId ?? null } /** False while no PTY is bound, which is every write path in today's builds. */ get enforcing(): boolean { - return this.lookup !== null && this.sessionIdByPtyId.size > 0 + return this.lookup !== null && this.panesByPtyId.size > 0 } admit(ptyId: string): AgentSessionPtyWriteAdmission { @@ -117,11 +164,11 @@ export class AgentSessionPtyWriteGate { } private binding(ptyId: string): AgentSessionPtyBinding | null { - const sessionId = this.sessionIdByPtyId.get(ptyId) - if (sessionId === undefined) { + const pane = this.panesByPtyId.get(ptyId) + if (pane === undefined) { return null } - return { sessionId, record: this.lookup?.(sessionId) ?? null } + return { sessionId: pane.sessionId, record: this.lookup?.(pane.sessionId) ?? null } } } diff --git a/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts b/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts new file mode 100644 index 000000000000..5e1f07a7d33a --- /dev/null +++ b/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AdoptedCodexReadinessEvent } from '../codex/adopted-codex-tui-readiness' +import { OrcaRuntimeService } from './orca-runtime' + +const { proveCodexTuiRollout, readStructuredTuiProcessIdentity, resolvePinnedCodexRolloutProof } = + vi.hoisted(() => ({ + proveCodexTuiRollout: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn(), + resolvePinnedCodexRolloutProof: vi.fn() + })) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('../codex/codex-tui-rollout-proof', () => ({ + proveCodexTuiRollout, + resolveLiveCodexTuiRollout: vi.fn(), + resolvePinnedCodexRolloutProof +})) + +const WORKTREE_ID = 'repo-1::/tmp/adopted-readiness' +const PANE_KEY = 'tab-adopt:leaf-adopt' +const THREAD_ID = 'thread-adopt' +const SESSION_ID = 'session-adopt' +const TRANSCRIPT = '/tmp/codex-home/rollout.jsonl' + +/** The tail a bare shell leaves behind once adoption has stopped the pane's Codex. */ +const READY_SHELL_TAIL = ['dev@host ~/repo %'] + +function returnToTerminalHarness(settingsOverrides: Record = {}) { + const listeners = new Set<(event: AdoptedCodexReadinessEvent) => void>() + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + ...settingsOverrides + }) + } as never, + undefined, + { + subscribeAgentHookEvents: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } + ) + const writeAgentSessionProof = vi.fn(() => true) + runtime.setPtyController({ + writeAgentSessionProof, + listProcesses: async () => [ + { id: 'pty-adopt', incarnationId: 'inc-adopt', rootProcessId: 31337 } + ] + } as never) + + const internal = runtime as unknown as { + ptysById: Map + adoptedStructuredTuiOwners: Map + resolveTerminalWorkspaceLaunchScope(): Promise + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + } + internal.ptysById.set('pty-adopt', { + ptyId: 'pty-adopt', + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt', + // Decoys: exactly the two signals a `tui-idle` wait would settle on. + lastAgentStatus: 'idle', + tailBuffer: READY_SHELL_TAIL, + tailPartialLine: '', + preview: READY_SHELL_TAIL.join('\n'), + lastOutputAt: 1_700_000_000_000 + }) + internal.adoptedStructuredTuiOwners.set(SESSION_ID, { + terminal: { + handle: 'term-adopt', + tabId: 'tab-adopt', + paneKey: PANE_KEY, + ptyId: 'pty-adopt' + }, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 10, spawnToken: 'spawn-adopt' }, + link: { + linkId: 'link-adopt', + handle: { provider: 'codex', threadId: THREAD_ID }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: 1 + }, + historySource: 'provider-resume', + adoptedTerminal: true + }) + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/adopted-readiness', + connectionId: null, + repo: null, + folderWorkspace: null + })) + + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 5252, + processStartTimeMs: 20, + spawnToken: 'spawn-adopt' + }) + resolvePinnedCodexRolloutProof.mockResolvedValue(TRANSCRIPT) + + const launch = (): Promise<{ transcriptPath?: string }> => + internal.createStructuredAgentSessionHandoffTransport().launchTui({ + record: { + sessionId: SESSION_ID, + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + options: null, + providerHandleChain: [{ handle: { provider: 'codex', threadId: THREAD_ID }, observedAt: 1 }] + } as never, + fence: 3, + spawnToken: 'spawn-adopt' + }) as Promise<{ transcriptPath?: string }> + + const writtenNonce = (): string => { + const written = writeAgentSessionProof.mock.calls + .map((call) => (call as unknown as [string, string])[1]) + .join('') + const match = new RegExp(`${AGENT_HOOK_SESSION_NONCE_ENV_VAR}[=:']*([0-9a-f-]{36})`).exec( + written + ) + if (!match?.[1]) { + throw new Error(`no session nonce in written command: ${written}`) + } + return match[1] + } + + const emit = (event: Partial): void => { + const full = { + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SessionStart', + providerSession: { key: 'session_id', id: THREAD_ID }, + ...event + } as AdoptedCodexReadinessEvent + for (const listener of Array.from(listeners)) { + listener(full) + } + } + + return { runtime, launch, emit, writtenNonce, writeAgentSessionProof } +} + +/** Resolves to `'pending'` unless `promise` settles first. */ +async function settlesBefore(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise<'pending'>((resolve) => { + setTimeout(() => resolve('pending'), 0) + }) + ]) +} + +describe('returning an adopted Codex session to its terminal', () => { + it('does not accept a ready shell as the resumed Codex, only its SessionStart hook', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + + // The pane now looks exactly like a settled agent terminal: an idle status + // left over from the Codex adoption stopped, and a shell prompt on screen. + expect(await settlesBefore(stillWaiting)).toBe('pending') + expect(proveCodexTuiRollout).not.toHaveBeenCalled() + + rig.emit({ sessionNonce: rig.writtenNonce() }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('stamps a one-shot nonce into the resume command and ignores a SessionStart without it', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + const nonce = rig.writtenNonce() + + // A SessionStart from an earlier Codex in this pane: same pane, same thread, + // a nonce Orca minted for some other invocation. + rig.emit({ sessionNonce: `stale-${nonce}` }) + expect(await settlesBefore(stillWaiting)).toBe('pending') + + rig.emit({ sessionNonce: nonce }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('ignores non-SessionStart codex hooks and replayed ones', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + const sessionNonce = rig.writtenNonce() + + rig.emit({ sessionNonce, hookEventName: 'Stop' }) + rig.emit({ sessionNonce, hookEventName: 'UserPromptSubmit' }) + rig.emit({ sessionNonce, isReplay: true }) + rig.emit({ sessionNonce, source: 'claude' }) + rig.emit({ sessionNonce, paneKey: 'tab-other:leaf-other' }) + expect(await settlesBefore(stillWaiting)).toBe('pending') + + rig.emit({ sessionNonce }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('rejects when the nonce Orca minted comes back bound to another conversation', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const settled = owner.catch((error: Error) => error.message) + await settlesBefore(settled) + + rig.emit({ + sessionNonce: rig.writtenNonce(), + providerSession: { key: 'session_id', id: 'thread-other' } + }) + await expect(settled).resolves.toContain('resumed a different Codex session') + }) + + it('says why it cannot confirm the session when agent status hooks are off', async () => { + const rig = returnToTerminalHarness({ agentStatusHooksEnabled: false }) + await expect(rig.launch()).rejects.toThrow('Agent status hooks are off') + // Nothing may be typed at the pane when the proof can never arrive. + expect(rig.writeAgentSessionProof).not.toHaveBeenCalled() + }) + + it('never types a screen probe at the pane', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + rig.emit({ sessionNonce: rig.writtenNonce() }) + await owner + + const written = rig.writeAgentSessionProof.mock.calls.map( + (call) => (call as unknown as [string, string])[1] + ) + expect(written).toHaveLength(1) + expect(written[0]).toContain('codex') + expect(written[0]).not.toContain('/status') + // Bracketed paste and the bare/kitty submit keys the old proof sent. + expect(written[0]).not.toContain('[200~') + expect(written[0]).not.toContain('') + expect(proveCodexTuiRollout).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts new file mode 100644 index 000000000000..bcf9cf3b992f --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts @@ -0,0 +1,320 @@ +/** + * Adoption against the REAL account-home resolution. + * + * Why this rig exists: every other adoption test stubs the intent resolver with a hardcoded + * `accountHome`, so none of them could see that adoption resolved the CURRENTLY SELECTED Codex + * account's home instead of the one the pane's Codex is actually running under. Here the pane's + * home comes from the real pane-account registry and the real settings, and the rollout fixture + * exists ONLY under the pane's home — so probing the selected account's home fails the proof. + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation' +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 { + _internals as codexPaneAccountRegistryInternals, + type CodexPaneAccountRecord +} from '../codex/codex-pane-account-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-runtime' +import { OrcaRuntimeService } from './orca-runtime' + +const { readStructuredTuiProcessIdentity } = vi.hoisted(() => ({ + readStructuredTuiProcessIdentity: vi.fn() +})) +// The only OS-dependent step in the flow; the rollout proof and the account-home resolution stay real. +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) + +const WORKTREE_ID = 'repo-1::/tmp/structured-adoption-home' +const PTY_ID = 'pty-adopt' +const PANE_KEY = 'tab-adopt:leaf-adopt' +const THREAD_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' +const SESSION_ID = `codex_${THREAD_ID.replaceAll('-', '_')}` +const PANE_ACCOUNT_ID = 'codex-account-pane' +const SELECTED_ACCOUNT_ID = 'codex-account-selected' + +type AdoptionRig = { + runtime: OrcaRuntimeService + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + proofWrites: string[] +} + +let root: string +let paneAccountHome: string +let selectedAccountHome: string +let sharedRuntimeHome: string +let rig: AdoptionRig + +async function writeRolloutFixture(codexHome: string, threadId: string): Promise { + const dir = join(codexHome, 'sessions', '2026', '08', '19') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, `rollout-2026-08-19T10-00-00-${threadId}.jsonl`), + `${JSON.stringify({ type: 'session_meta', payload: { id: threadId } })}\n`, + 'utf8' + ) +} + +/** Writes the registry file the real `getCodexPaneAccount` reads at adoption time. */ +async function recordPaneAccount(record: CodexPaneAccountRecord | null): Promise { + await writeFile( + join(root, 'codex-pane-accounts.json'), + `${JSON.stringify({ version: 2, panes: record ? { [PTY_ID]: record } : {} })}\n`, + 'utf8' + ) + codexPaneAccountRegistryInternals.resetCache() +} + +function buildSettings() { + const account = (id: string, managedHomePath: string) => ({ + id, + email: `${id}@example.com`, + managedHomePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }) + return { + // Why a configured CODEX_HOME: it is what the create intent resolves to in this rig, so an + // adoption that follows the selection probes THIS home instead of the pane's. + agentDefaultEnv: { codex: { CODEX_HOME: selectedAccountHome } }, + activeCodexManagedAccountId: SELECTED_ACCOUNT_ID, + codexManagedAccounts: [ + account(PANE_ACCOUNT_ID, paneAccountHome), + account(SELECTED_ACCOUNT_ID, selectedAccountHome) + ] + } +} + +function installProofPty(rigState: AdoptionRig, ptyRecord: { lastOutputAt: number }) { + return (ptyId: string, data: string, authority: { sessionId: string; spawnToken: string }) => { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + return false + } + rigState.proofWrites.push(data) + if (data === '\r') { + const internal = rigState.runtime as unknown as { + ptysById: Map + } + const pty = internal.ptysById.get(PTY_ID)! + pty.tailBuffer = [`Session ID: ${THREAD_ID}`] + pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000 + } + return true + } +} + +async function buildRig(): Promise { + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'agent-sessions'), + hostId: 'local' + }) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + supportsCreate: () => true, + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalRoot: join(root, 'journals'), + claimKeyId: 'key-1', + probeOwner: createStructuredAgentSessionOwnerProbe( + 'local', + async () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), + async () => [] + ) + }) + setStructuredAgentSessionHost(host) + + const runtime = new OrcaRuntimeService({ getSettings: () => buildSettings() } as never) + const state: AdoptionRig = { runtime, store, host, proofWrites: [] } + const ptyRecord = { + ptyId: PTY_ID, + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt', + tailBuffer: [] as string[], + tailPartialLine: '', + preview: '', + lastOutputAt: 1_000 + } + const internal = runtime as unknown as { + ptysById: Map + resolveRuntimeFileTarget(): Promise + resolveStructuredAgentSessionLocation(): Promise + ensureStructuredAgentSessionHost(): Promise + issueStructuredTuiPtyHandle(): string + } + internal.ptysById.set(PTY_ID, ptyRecord) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + connectionId: null, + worktree: { id: WORKTREE_ID, path: join(root, 'workspace') } + })) + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + })) + // The rig owns the host; installing the real one would replace it with an on-disk singleton. + internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) + internal.issueStructuredTuiPtyHandle = vi.fn(() => 'term-adopt') + runtime.setPtyController({ + listProcesses: async () => [{ id: PTY_ID, incarnationId: 'inc-adopt', rootProcessId: 31337 }], + writeAgentSessionProof: installProofPty(state, ptyRecord) + } as never) + return state +} + +function adoptInput() { + return { + envelope: { + sessionId: SESSION_ID, + clientOperationId: createStructuredAgentSessionOperationId(() => randomUUID()), + expectedRuntimeFence: null as null, + payloadFingerprint: 'a'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + ptyId: PTY_ID, + threadId: THREAD_ID + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-home-')) + vi.stubEnv('ORCA_USER_DATA_PATH', root) + paneAccountHome = join(root, 'codex-accounts', PANE_ACCOUNT_ID, 'home') + selectedAccountHome = join(root, 'codex-accounts', SELECTED_ACCOUNT_ID, 'home') + sharedRuntimeHome = join(root, 'codex-runtime-home', 'home') + await recordPaneAccount({ + selectionKey: 'host', + accountId: PANE_ACCOUNT_ID, + homeRoute: 'account-home' + }) + readStructuredTuiProcessIdentity.mockImplementation( + async (input: { hostId: string; spawnToken: string }) => ({ + hostId: input.hostId, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }) + ) + rig = await buildRig() +}) + +afterEach(async () => { + agentSessionPtyWriteGate.unbindPty(PTY_ID) + agentSessionPtyWriteGate.detachRecordLookup() + setStructuredAgentSessionHost(null) + codexPaneAccountRegistryInternals.resetCache() + vi.unstubAllEnvs() + await rig.host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured Codex adoption resolves the account home from the pane', () => { + it('adopts a local-fallback pane from its shared-home launch provenance', async () => { + await recordPaneAccount({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + await writeRolloutFixture(sharedRuntimeHome, THREAD_ID) + + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + accountHome: { variable: 'CODEX_HOME', path: sharedRuntimeHome }, + lease: { runtimeKind: 'tui', claimStatus: 'live' } + }) + }, 20_000) + + it('adopts a pane whose Codex runs under a non-selected account home', async () => { + // Only the pane's account owns the rollout; the selected account's home has none. + await writeRolloutFixture(paneAccountHome, THREAD_ID) + + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + accountHome: { variable: 'CODEX_HOME', path: paneAccountHome }, + lease: { runtimeKind: 'tui', claimStatus: 'live' } + }) + }, 20_000) + + it('still adopts a pane whose account is the selected one', async () => { + await recordPaneAccount({ + selectionKey: 'host', + accountId: SELECTED_ACCOUNT_ID, + homeRoute: 'account-home' + }) + await writeRolloutFixture(selectedAccountHome, THREAD_ID) + + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + accountHome: { path: selectedAccountHome } + }) + }, 20_000) + + it('refuses a pane whose Codex home cannot be attributed instead of probing another account', async () => { + await recordPaneAccount(null) + await writeRolloutFixture(selectedAccountHome, THREAD_ID) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { callerKey: 'renderer-1' }) + ).rejects.toThrow('no record of which Codex account this terminal launched under') + // Nothing was probed and nothing was reserved, so the wrong home was never consulted. + expect(rig.proofWrites).toEqual([]) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + expect(rig.store.getRecord(SESSION_ID)).toBeNull() + }) + + it('names the account when the pane launched under one that is gone', async () => { + await recordPaneAccount({ + selectionKey: 'host', + accountId: 'codex-account-removed', + homeRoute: 'account-home' + }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { callerKey: 'renderer-1' }) + ).rejects.toThrow('managed account codex-account-removed, which is no longer configured') + }) + + it('leaves create following the selected account, not the adopted pane', async () => { + const created = await rig.runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: SESSION_ID, clientOperationId: 'op-create' }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + }) + + // The registry still names the pane's account; a create must ignore it. + expect(created.accountHome).toEqual({ variable: 'CODEX_HOME', path: selectedAccountHome }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts new file mode 100644 index 000000000000..fab6a74542dc --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts @@ -0,0 +1,465 @@ +/** + * Adoption against the REAL `agentSessionPtyWriteGate`. + * + * Every other adoption test mocks the PTY controller, so `writeAgentSessionProof` answers whatever + * the mock says and `admitProof` never runs — which is precisely why they all passed while no user + * could ever switch a Codex tab to structured chat. Here the controller delegates to the real gate + * over a real record store, so the probe is admitted only when adoption has actually reserved the + * lease and bound the pane first. + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation' +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 { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-runtime' +import { OrcaRuntimeService } from './orca-runtime' + +const { readStructuredTuiProcessIdentity } = vi.hoisted(() => ({ + readStructuredTuiProcessIdentity: vi.fn() +})) +// The only OS-dependent step in the flow; the rollout proof and the write gate both stay real. +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) + +const WORKTREE_ID = 'repo-1::/tmp/structured-adoption' +const PTY_ID = 'pty-adopt' +const PANE_KEY = 'tab-adopt:leaf-adopt' +const THREAD_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const SESSION_ID = `codex_${THREAD_ID.replaceAll('-', '_')}` + +type ProofRig = { + runtime: OrcaRuntimeService + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + proofWrites: string[] + refusedWrites: string[] + codexHome: string + historyFilePath: ReturnType +} + +let root: string +let rig: ProofRig + +async function writeRolloutFixture(codexHome: string, threadId: string): Promise { + const dir = join(codexHome, 'sessions', '2026', '08', '19') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, `rollout-2026-08-19T10-00-00-${threadId}.jsonl`), + `${JSON.stringify({ type: 'session_meta', payload: { id: threadId } })}\n`, + 'utf8' + ) +} + +/** + * A PTY that answers `/status` with a Codex session banner — but only for bytes the real gate + * admitted. This mirrors `writeAgentSessionProof` in src/main/ipc/pty.ts verbatim. + */ +function installProofPty( + rigState: ProofRig, + threadId: string, + ptyRecord: { lastOutputAt: number } +) { + return (ptyId: string, data: string, authority: { sessionId: string; spawnToken: string }) => { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + rigState.refusedWrites.push(data) + return false + } + rigState.proofWrites.push(data) + if (data === '\r') { + const record = rigState.runtime as unknown as { + ptysById: Map + } + const pty = record.ptysById.get(PTY_ID)! + pty.tailBuffer = [`Session ID: ${threadId}`] + pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000 + } + return true + } +} + +async function buildRig(): Promise { + const codexHome = join(root, 'codex-home') + await writeRolloutFixture(codexHome, THREAD_ID) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'agent-sessions'), + hostId: 'local' + }) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + const historyFilePath = vi.fn(async () => null) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + historyFilePath + }, + journalRoot: join(root, 'journals'), + claimKeyId: 'key-1', + probeOwner: createStructuredAgentSessionOwnerProbe( + 'local', + async () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), + async () => [] + ) + }) + setStructuredAgentSessionHost(host) + + const runtime = new OrcaRuntimeService({ getSettings: () => ({ agentDefaultEnv: {} }) } as never) + const state: ProofRig = { + runtime, + store, + host, + proofWrites: [], + refusedWrites: [], + codexHome, + historyFilePath + } + const ptyRecord = { + ptyId: PTY_ID, + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt', + tailBuffer: [] as string[], + tailPartialLine: '', + preview: '', + lastOutputAt: 1_000 + } + const internal = runtime as unknown as { + ptysById: Map + resolveRuntimeFileTarget(): Promise + resolveStructuredAgentSessionAdoptionIntent(input: { envelope: unknown }): Promise + issueStructuredTuiPtyHandle(): string + } + internal.ptysById.set(PTY_ID, ptyRecord) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + connectionId: null, + worktree: { id: WORKTREE_ID } + })) + // Why the adoption intent and not the create one: adoption resolves the pane's OWN account + // home, which this rig has no pane-account record for. The pane-home resolution itself is + // covered by orca-runtime-structured-tui-adoption-account-home.test.ts. + internal.resolveStructuredAgentSessionAdoptionIntent = vi.fn(async ({ envelope }) => ({ + envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: codexHome }, + runtimeKind: 'native' + })) + internal.issueStructuredTuiPtyHandle = vi.fn(() => 'term-adopt') + runtime.setPtyController({ + listProcesses: async () => [{ id: PTY_ID, incarnationId: 'inc-adopt', rootProcessId: 31337 }], + writeAgentSessionProof: installProofPty(state, THREAD_ID, ptyRecord) + } as never) + return state +} + +function adoptInput(overrides: { threadId?: string } = {}) { + return { + envelope: { + sessionId: SESSION_ID, + clientOperationId: createStructuredAgentSessionOperationId(() => randomUUID()), + expectedRuntimeFence: null as null, + payloadFingerprint: 'a'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + ptyId: PTY_ID, + ...overrides + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-gate-')) + readStructuredTuiProcessIdentity.mockImplementation( + async (input: { hostId: string; spawnToken: string }) => ({ + hostId: input.hostId, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }) + ) + rig = await buildRig() +}) + +afterEach(async () => { + agentSessionPtyWriteGate.unbindPty(PTY_ID) + agentSessionPtyWriteGate.detachRecordLookup() + setStructuredAgentSessionHost(null) + await rig.host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured Codex adoption against the real PTY write gate', () => { + it('authorizes the provider probe, so switching a Codex tab to chat succeeds', async () => { + const result = await rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + + expect(result).toMatchObject({ ok: true }) + // The probe reached the pane: `/status`, submit, escape — none of them refused by the gate. + expect(rig.refusedWrites).toEqual([]) + expect(rig.proofWrites).toHaveLength(3) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + lease: { runtimeKind: 'tui', claimStatus: 'live', handoffStage: null } + }) + }, 20_000) + + it('authorizes the probe when no thread id has been published yet', async () => { + const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), { + callerKey: 'renderer-1' + }) + + expect(result).toMatchObject({ ok: true }) + expect(rig.refusedWrites).toEqual([]) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD_ID } }] + }) + }, 20_000) + + it('leaves nothing latched when the proof fails, so the next attempt still succeeds', async () => { + await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + // The reservation is durably marked as never having spawned, and the pane is released. + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({ + claimStatus: 'reserved', + ownerProcess: null + }) + expect(rig.store.getRecord(SESSION_ID)?.lease.processlessAt).toEqual(expect.any(Number)) + + await writeRolloutFixture(rig.codexHome, THREAD_ID) + const retried = await rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + + expect(retried).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + lease: { claimStatus: 'live', runtimeKind: 'tui' } + }) + }, 30_000) + + it('deduplicates a replay while a third fresh operation supersedes it', async () => { + const arrived: string[] = [] + let releaseBoth: () => void = () => {} + const bothArrived = new Promise((resolve) => { + releaseBoth = resolve + }) + readStructuredTuiProcessIdentity.mockImplementation( + async (input: { hostId: string; spawnToken: string }) => { + if (!arrived.includes(input.spawnToken)) { + arrived.push(input.spawnToken) + if (arrived.length === 2) { + releaseBoth() + } + await bothArrived + } + return { + hostId: input.hostId, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + } + } + ) + + const replayedInput = { ...adoptInput(), threadId: THREAD_ID } + const superseded = rig.runtime.adoptStructuredAgentSessionTerminal(replayedInput, { + callerKey: 'renderer-1' + }) + superseded.catch(() => undefined) + const replay = rig.runtime.adoptStructuredAgentSessionTerminal(replayedInput, { + callerKey: 'renderer-1' + }) + expect(replay).toBe(superseded) + await vi.waitFor(() => expect(arrived).toHaveLength(1)) + // A fresh operation is independent and supersedes the shared delivery's reservation. + const winner = rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + + await expect(winner).resolves.toMatchObject({ ok: true }) + await expect(superseded).rejects.toThrow('could not verify its Codex session') + await expect(replay).rejects.toThrow('could not verify its Codex session') + // The loser's cleanup ran against a pane and a reservation that were no longer its own. + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(rig.store.getRecord(SESSION_ID)).toMatchObject({ + lease: { runtimeKind: 'tui', claimStatus: 'live', handoffStage: null } + }) + }, 30_000) + + it('rolls back a process identity when proving the owner fails', async () => { + vi.spyOn(rig.store, 'proveOwner').mockRejectedValueOnce(new Error('fault after process commit')) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('fault after process commit') + expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({ + claimStatus: 'reserved', + ownerProcess: null, + processlessAt: expect.any(Number) + }) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).resolves.toMatchObject({ ok: true }) + }, 30_000) + + it('preserves a just-proved owner when the next runtime step fails', async () => { + const settlePtyAttempt = + agentSessionPtyWriteGate.settlePtyAttempt.bind(agentSessionPtyWriteGate) + const settle = vi.spyOn(agentSessionPtyWriteGate, 'settlePtyAttempt') + settle.mockImplementationOnce((ptyId, attemptToken) => { + const settled = settlePtyAttempt(ptyId, attemptToken) + expect(settled).toBe(true) + throw new Error('fault after owner proof') + }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('fault after owner proof') + expect(rig.store.getRecord(SESSION_ID)?.lease.claimStatus).toBe('live') + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }, 30_000) + + it('keeps the pane settled when journal attachment fails after owner proof', async () => { + rig.historyFilePath.mockRejectedValueOnce(new Error('journal attach fault')) + const input = { ...adoptInput(), threadId: THREAD_ID } + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(input, { callerKey: 'renderer-1' }) + ).rejects.toThrow('journal attach fault') + expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({ + claimStatus: 'live', + ownerProcess: { pid: 4242 } + }) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(agentSessionPtyWriteGate.admit(PTY_ID)).toMatchObject({ + admitted: true, + sessionId: SESSION_ID + }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(input, { callerKey: 'renderer-1' }) + ).resolves.toMatchObject({ ok: true, replayed: true }) + }, 30_000) + + it('keeps the pane settled when outcome persistence fails', async () => { + vi.spyOn(rig.store, 'recordOperationOutcome').mockRejectedValueOnce( + new Error('outcome persistence fault') + ) + const input = { ...adoptInput(), threadId: THREAD_ID } + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(input, { callerKey: 'renderer-1' }) + ).rejects.toThrow('outcome persistence fault') + expect(rig.store.getRecord(SESSION_ID)?.lease.claimStatus).toBe('live') + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal(input, { callerKey: 'renderer-1' }) + ).resolves.toMatchObject({ ok: true, replayed: true }) + }, 30_000) + + it('retries a transient real-store reservation release without masking the proof error', async () => { + await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true }) + const release = vi + .spyOn(rig.store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('transient store write fault')) + .mockRejectedValueOnce(new Error('transient store write fault')) + .mockRejectedValueOnce(new Error('transient store write fault')) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + expect(release).toHaveBeenCalledTimes(3) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + await vi.waitFor( + () => { + expect(release).toHaveBeenCalledTimes(4) + expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({ + claimStatus: 'reserved', + ownerProcess: null, + processlessAt: expect.any(Number) + }) + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + }, + { timeout: 3_000 } + ) + }, 30_000) + + it('does not recreate a pane binding when the pane exits during settlement', async () => { + const settle = vi.spyOn(agentSessionPtyWriteGate, 'settlePtyAttempt') + settle.mockImplementationOnce((ptyId) => { + const internal = rig.runtime as unknown as { + ptysById: Map + } + internal.ptysById.get(ptyId)!.connected = false + agentSessionPtyWriteGate.unbindPty(ptyId) + return false + }) + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).resolves.toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION_ID)?.lease.claimStatus).toBe('live') + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull() + }, 30_000) + + it('refuses a pane another structured session already owns', async () => { + agentSessionPtyWriteGate.bindPty(PTY_ID, 'someone-elses-session') + + await expect( + rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('already belongs to another structured Codex session') + expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe('someone-elses-session') + expect(rig.store.getRecord(SESSION_ID)).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index fa44892277f3..5d25439c1e70 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { OrcaRuntimeService } from './orca-runtime' @@ -9,17 +10,20 @@ const { probeAgentSessionProcessIdentity, proveCodexTuiRollout, readStructuredTuiProcessIdentity, + resolveLiveCodexTuiRollout, resolvePinnedCodexRolloutProof } = vi.hoisted(() => ({ probeAgentSessionProcessIdentity: vi.fn(), proveCodexTuiRollout: vi.fn(), readStructuredTuiProcessIdentity: vi.fn(), + resolveLiveCodexTuiRollout: vi.fn(), resolvePinnedCodexRolloutProof: vi.fn() })) vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) vi.mock('../codex/codex-tui-rollout-proof', () => ({ proveCodexTuiRollout, + resolveLiveCodexTuiRollout, resolvePinnedCodexRolloutProof })) vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ @@ -29,6 +33,67 @@ vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' +function terminalAdoptionHarness() { + const runtime = new OrcaRuntimeService({ getSettings: () => ({ agentDefaultEnv: {} }) } as never) + runtime.setPtyController({ + listProcesses: async () => [ + { id: 'pty-adopt', incarnationId: 'inc-adopt', rootProcessId: 31337 } + ] + } as never) + const internal = runtime as unknown as { + ptysById: Map + resolveRuntimeFileTarget(): Promise + resolveStructuredAgentSessionAdoptionIntent(input: { envelope: unknown }): Promise + issueStructuredTuiPtyHandle(): string + } + internal.ptysById.set('pty-adopt', { + ptyId: 'pty-adopt', + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt' + }) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + connectionId: null, + worktree: { id: WORKTREE_ID } + })) + // Why the adoption intent and not the create one: adoption resolves the pane's OWN account + // home, which this rig has no pane-account record for. The pane-home resolution itself is + // covered by orca-runtime-structured-tui-adoption-account-home.test.ts. + internal.resolveStructuredAgentSessionAdoptionIntent = vi.fn(async ({ envelope }) => ({ + envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + runtimeKind: 'native' + })) + internal.issueStructuredTuiPtyHandle = vi.fn(() => 'term-adopt') + return runtime +} + +/** The host half adoption now needs: the lease is reserved before the provider probe runs. */ +function structuredHostDouble(overrides: Record = {}) { + return { + reserveAdoptedTuiOwner: vi.fn(async (input: { spawnToken: string }) => ({ + ok: true, + fence: 1, + spawnToken: input.spawnToken + })), + releaseAdoptedTuiReservation: vi.fn(async () => undefined), + adoptTuiOwner: vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })), + ...overrides + } +} + function notifier(revealTerminalSession: ReturnType) { return { worktreesChanged: vi.fn(), @@ -54,6 +119,256 @@ function structuredTabNotifier() { } describe('structured TUI launch tab binding', () => { + it('adopts the proven local Codex pane as a durable TUI owner', async () => { + const runtime = terminalAdoptionHarness() + const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/rollout.jsonl' + }) + const process = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + } + readStructuredTuiProcessIdentity.mockResolvedValueOnce(process).mockResolvedValueOnce(process) + + try { + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-adopt', + clientOperationId: 'operation-adopt', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).resolves.toMatchObject({ ok: true }) + expect(adoptTuiOwner).toHaveBeenCalledWith( + expect.objectContaining({ + owner: expect.objectContaining({ + adoptedTerminal: true, + historySource: 'provider-resume', + transcriptPath: '/tmp/codex-home/rollout.jsonl', + link: expect.objectContaining({ + origin: 'adopted', + handle: { provider: 'codex', threadId: 'thread-adopt' } + }) + }) + }) + ) + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBe('session-adopt') + expect(proveCodexTuiRollout).toHaveBeenCalledWith( + expect.objectContaining({ threadId: 'thread-adopt' }) + ) + expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() + } finally { + agentSessionPtyWriteGate.unbindPty('pty-adopt') + setStructuredAgentSessionHost(null) + } + }) + + it('refuses adoption when the provider rollout cannot be proved', async () => { + const runtime = terminalAdoptionHarness() + const host = structuredHostDouble() + setStructuredAgentSessionHost(host as never) + proveCodexTuiRollout.mockRejectedValueOnce( + new Error('The agent terminal did not prove the expected Codex rollout.') + ) + readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) + + try { + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-adopt', + clientOperationId: 'operation-adopt', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + // A proof that never lands hands the reservation back; nothing latches the next attempt. + expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1) + } finally { + setStructuredAgentSessionHost(null) + } + }) + + it('reports a reservation release that fails, without masking the proof error', async () => { + const runtime = terminalAdoptionHarness() + const host = structuredHostDouble({ + releaseAdoptedTuiReservation: vi.fn(async () => { + throw new Error('store write failed') + }) + }) + setStructuredAgentSessionHost(host as never) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + proveCodexTuiRollout.mockRejectedValueOnce( + new Error('The agent terminal did not prove the expected Codex rollout.') + ) + readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) + + try { + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-release-failure', + clientOperationId: 'operation-release-failure', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('did not prove the expected Codex rollout') + + const reserved = host.reserveAdoptedTuiOwner.mock.calls[0]?.[0] as { spawnToken: string } + const logged = warn.mock.calls.find( + ([message]) => message === '[native-chat] adopted TUI reservation release failed' + )?.[1] as { error: Error } | undefined + // A latched lease refuses every later attempt, so the cause cannot vanish with the throw. + expect(logged).toMatchObject({ + sessionId: 'session-release-failure', + fence: 1, + spawnToken: reserved.spawnToken + }) + expect(logged?.error.message).toBe('store write failed') + } finally { + warn.mockRestore() + setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.unbindPty('pty-adopt') + } + }) + + it('refuses a thread proof when the Codex process changes during it', async () => { + const runtime = terminalAdoptionHarness() + const host = structuredHostDouble() + setStructuredAgentSessionHost(host as never) + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/rollout.jsonl' + }) + readStructuredTuiProcessIdentity + .mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) + .mockResolvedValueOnce({ + hostId: 'local', + pid: 5252, + processStartTimeMs: 1_700_000_010_000, + spawnToken: 'spawn-adopt' + }) + + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-process-swap', + clientOperationId: 'operation-process-swap', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('process changed during conversation proof') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1) + setStructuredAgentSessionHost(null) + }) + + it('discovers the provider thread when hook status has not published it yet', async () => { + const runtime = terminalAdoptionHarness() + const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) + resolveLiveCodexTuiRollout.mockResolvedValueOnce({ + threadId: 'thread-discovered', + transcriptPath: '/tmp/codex-home/discovered.jsonl' + }) + const process = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + } + readStructuredTuiProcessIdentity.mockResolvedValueOnce(process).mockResolvedValueOnce(process) + + try { + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-discovered', + clientOperationId: 'operation-discovered', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).resolves.toMatchObject({ ok: true }) + expect(adoptTuiOwner).toHaveBeenCalledWith( + expect.objectContaining({ + owner: expect.objectContaining({ + transcriptPath: '/tmp/codex-home/discovered.jsonl', + link: expect.objectContaining({ + handle: { provider: 'codex', threadId: 'thread-discovered' } + }) + }) + }) + ) + } finally { + agentSessionPtyWriteGate.unbindPty('pty-adopt') + setStructuredAgentSessionHost(null) + } + }) + it('reveals the structured chat tab when reverse handoff completes', () => { const runtime = new OrcaRuntimeService() const targetNotifier = structuredTabNotifier() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 51f416292f66..1a4e291d1600 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -82,6 +82,12 @@ import { import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionCaller } from '../native-chat/agent-session-wire/structured-agent-session-host-types' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../shared/agent-session-wire' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { StructuredTuiLaunchCleanupError, type StructuredAgentSessionHandoffTransport, @@ -94,8 +100,16 @@ import { scopedAgentSessionClaimsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { + beginAdoptedCodexReadinessWatch, + type AdoptedCodexReadinessEvent, + type AdoptedCodexReadinessWatch +} from '../codex/adopted-codex-tui-readiness' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import { withInlineEnvAssignments } from '../../shared/inline-env-command-prefix' import { proveCodexTuiRollout, + resolveLiveCodexTuiRollout, resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { @@ -103,11 +117,19 @@ import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' +import { stopAdoptedCodexTui } from './adopted-codex-tui-stop' import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' +import { StructuredTuiAdoptionInflight } from './structured-tui-adoption-inflight' +import { releaseStructuredTuiAdoptionReservation } from './structured-tui-adoption-reservation-release' import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { getCodexPaneAccount } from '../codex/codex-pane-account-registry' +import { + describeCodexPaneLaunchHomeFailure, + resolveCodexPaneLaunchHome +} from '../codex/codex-pane-launch-home' import { agentSessionPtyWriteGate, type AgentSessionPtyWriteAdmittance @@ -3170,6 +3192,10 @@ export class OrcaRuntimeService { private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null private structuredAgentSessionTabRestorePromise: Promise | null = null private structuredAgentSessionStartupRestorePromise: Promise | null = null + private readonly adoptedStructuredTuiOwners = new Map() + private readonly structuredTuiAdoptionInflight = new StructuredTuiAdoptionInflight< + AgentSessionMutationResult + >() private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3678,6 +3704,9 @@ export class OrcaRuntimeService { }) => AgentHookAuthorityAttestation | null) | null private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + private readonly subscribeAgentHookEventsFn: + | ((listener: (event: AdoptedCodexReadinessEvent) => void) => () => void) + | null private readonly canRecoverPersistentLocalPtysFn: () => boolean private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus @@ -3762,6 +3791,12 @@ export class OrcaRuntimeService { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + /** Live tap on accepted agent hook events. The adopted-Codex return path uses + * it to take the resumed CLI's own SessionStart as its readiness proof + * instead of scraping the pane. */ + subscribeAgentHookEvents?: ( + listener: (event: AdoptedCodexReadinessEvent) => void + ) => () => void canRecoverPersistentLocalPtys?: () => boolean // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never @@ -3808,6 +3843,7 @@ export class OrcaRuntimeService { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.subscribeAgentHookEventsFn = deps?.subscribeAgentHookEvents ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions @@ -10601,6 +10637,16 @@ export class OrcaRuntimeService { return { hostLabel: hostname(), launchTui: async ({ record, fence, spawnToken, onSpawned }) => { + const adoptedOwner = this.adoptedStructuredTuiOwners.get(record.sessionId) + if (adoptedOwner) { + return this.launchAdoptedStructuredTui({ + record, + fence, + spawnToken, + owner: adoptedOwner, + onSpawned + }) + } const head = record.providerHandleChain.at(-1) if (head?.handle.provider !== 'codex') { throw new Error('agent_session_identity_required') @@ -10665,7 +10711,8 @@ export class OrcaRuntimeService { ptyId: terminal.ptyId }, process: spawnedOwner.process, - ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), + historySource: 'provider-resume' }) } catch (error) { try { @@ -10893,7 +10940,10 @@ export class OrcaRuntimeService { stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), tuiStatus: (owner) => this.structuredTuiStatus(owner), closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), - revealNativeSession: ({ workspaceId, sessionId }) => { + revealNativeSession: ({ workspaceId, sessionId, adoptedTerminal }) => { + if (adoptedTerminal) { + return + } this.publishStructuredAgentSessionTab({ workspaceId, sessionId, @@ -10977,6 +11027,10 @@ export class OrcaRuntimeService { private async closeStructuredTuiOwner( owner: StructuredTuiOwner ): Promise<{ transcriptPath?: string }> { + if (owner.adoptedTerminal) { + await this.closeAdoptedStructuredTuiOwner(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + } if (this.ptysById.get(owner.terminal.ptyId)?.connected) { const current = this.refreshStructuredTuiOwnerBinding(owner) try { @@ -10991,6 +11045,158 @@ export class OrcaRuntimeService { return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} } + private async launchAdoptedStructuredTui(input: { + record: AgentSessionRecord + fence: number + spawnToken: string + owner: StructuredTuiOwner + onSpawned?: (owner: StructuredTuiOwner) => Promise + }): Promise { + const head = input.record.providerHandleChain.at(-1) + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if ( + head?.handle.provider !== 'codex' || + !pty?.connected || + pty.paneKey !== input.owner.terminal.paneKey + ) { + throw new Error('The adopted terminal is no longer available.') + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${input.record.location.workspaceId}` + ) + const settings = this.requireStore().getSettings() + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + const startup = buildAgentResumeStartupPlan({ + agent: 'codex', + providerSession: { key: 'session_id', id: head.handle.threadId }, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv), + sessionOptions: this.toAgentSessionOptions(input.record.options), + sessionOptionsOverrideAgentArgs: Boolean(input.record.options), + platform, + shell, + isRemote: false + }) + if (!startup || !pty.paneKey) { + throw new Error('The adopted terminal could not resume Codex.') + } + // Why this is checked up front: the resumed Codex proves itself through its own + // SessionStart hook, so without the hooks there is nothing to wait for — fail + // now with the reason instead of after a 30s silence. + const subscribeAgentHookEvents = this.subscribeAgentHookEventsFn + if ( + !subscribeAgentHookEvents || + settings.agentStatusHooksEnabled === false || + settings.disabledTuiAgents?.includes('codex') === true + ) { + throw new Error('Agent status hooks are off, so Codex cannot confirm the resumed session.') + } + // Why the watch opens before the write: a warm Codex can post SessionStart + // before `writeAgentSessionProof` even returns, and a proof that arrives + // before its listener is a proof Orca never sees. + const sessionNonce = randomUUID() + const readiness = beginAdoptedCodexReadinessWatch({ + subscribe: subscribeAgentHookEvents, + target: { paneKey: pty.paneKey, threadId: head.handle.threadId, sessionNonce } + }) + // Why: a throw between here and the await would otherwise leave the watch's + // timeout rejection unhandled. + void readiness.settled.catch(() => {}) + let proved = false + try { + const resumeCommand = withInlineEnvAssignments({ + command: startup.launchCommand, + env: { [AGENT_HOOK_SESSION_NONCE_ENV_VAR]: sessionNonce }, + platform, + shell + }) + if ( + !this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${resumeCommand}\r`, { + sessionId: input.record.sessionId, + spawnToken: input.spawnToken + }) + ) { + throw new Error('The adopted terminal could not resume Codex.') + } + const listing = (await this.ptyController.listProcesses?.())?.find( + (candidate) => + candidate.id === pty.ptyId && + (!pty.incarnationId || candidate.incarnationId === pty.incarnationId) + ) + if (!listing?.rootProcessId) { + throw new Error('The adopted terminal did not publish a process identity.') + } + const process = await readStructuredTuiProcessIdentity({ + hostId: input.record.location.executionHostId, + rootPid: listing.rootProcessId, + spawnToken: input.spawnToken, + agent: 'codex' + }) + const owner: StructuredTuiOwner = { + ...input.owner, + process, + link: codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: input.fence, + observedAt: Date.now() + }) + } + await input.onSpawned?.(owner) + const transcriptPath = await this.waitForAdoptedStructuredTuiProof({ + owner, + readiness, + threadId: head.handle.threadId, + codexHome: input.record.accountHome.path + }) + proved = true + const provenOwner = { ...owner, transcriptPath } + this.adoptedStructuredTuiOwners.set(input.record.sessionId, provenOwner) + return provenOwner + } finally { + if (!proved) { + readiness.dispose() + } + } + } + + // Why nothing is typed at the pane here: the old proof pasted `/status` and + // scraped the tail for a session id, which cannot run until Codex is already up + // and cannot tell a ready shell from a ready Codex. The CLI's own SessionStart + // hook answers both questions off-screen, so this only has to wait for it and + // then name the rollout the thread already owns on disk. + private async waitForAdoptedStructuredTuiProof(input: { + owner: StructuredTuiOwner + readiness: AdoptedCodexReadinessWatch + threadId: string + codexHome: string + }): Promise { + await input.readiness.settled + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { + throw new Error('The adopted terminal lost its pane identity.') + } + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + return transcriptPath + } + + private async closeAdoptedStructuredTuiOwner(owner: StructuredTuiOwner): Promise { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== owner.terminal.paneKey) { + throw new Error('The owning agent terminal lost its pane identity.') + } + await stopAdoptedCodexTui({ identity: owner.process }) + } + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { const pty = this.ptysById.get(owner.terminal.ptyId) if (!pty?.connected) { @@ -11227,6 +11433,261 @@ export class OrcaRuntimeService { } } + adoptStructuredAgentSessionTerminal( + input: { + envelope: { + sessionId: string + clientOperationId: string + expectedRuntimeFence: null + payloadFingerprint: string + } + worktree: string + tabId: string + paneKey: string + ptyId: string + threadId?: string + }, + caller: StructuredAgentSessionCaller + ): Promise> { + return this.structuredTuiAdoptionInflight.run( + { + callerKey: caller.callerKey, + operationId: input.envelope.clientOperationId, + fingerprint: input.envelope.payloadFingerprint + }, + () => this.adoptStructuredAgentSessionTerminalOnce(input, caller) + ) + } + + private async adoptStructuredAgentSessionTerminalOnce( + input: Parameters[0], + caller: StructuredAgentSessionCaller + ): Promise> { + const pty = this.ptysById.get(input.ptyId) + if ( + !pty?.connected || + pty.connectionId !== null || + pty.wslDistro !== null || + pty.tabId !== input.tabId || + pty.paneKey !== input.paneKey + ) { + throw new Error('Structured Codex chat can only adopt this local terminal pane.') + } + const target = await this.resolveRuntimeFileTarget(input.worktree) + if (target.connectionId || target.worktree.id !== pty.worktreeId) { + throw new Error('Structured Codex chat is unavailable for remote terminals.') + } + const intent = await this.resolveStructuredAgentSessionAdoptionIntent({ + envelope: input.envelope, + worktree: input.worktree, + agent: 'codex', + ptyId: input.ptyId + }) + const listing = (await this.ptyController?.listProcesses?.())?.find( + (candidate) => + candidate.id === pty.ptyId && + (!pty.incarnationId || candidate.incarnationId === pty.incarnationId) + ) + if (!listing?.rootProcessId) { + throw new Error('Could not prove the Codex terminal process.') + } + const rootProcessId = listing.rootProcessId + const sessionId = input.envelope.sessionId + const priorBoundSessionId = agentSessionPtyWriteGate.boundSessionId(input.ptyId) + if (priorBoundSessionId !== null && priorBoundSessionId !== sessionId) { + throw new Error('This terminal pane already belongs to another structured Codex session.') + } + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + const launchEnv = resolveTuiAgentLaunchEnv( + 'codex', + this.requireStore().getSettings().agentDefaultEnv + ) + // Why the reservation comes before the proof: `admitProof` authorizes the `/status` probe only + // for a reservation that already names this session, `tui`, and this spawn token. Reserving + // afterwards left the probe unauthorized, so every adoption failed at its first keystroke. + const reserved = await host.reserveAdoptedTuiOwner({ + caller, + sessionId, + clientOperationId: input.envelope.clientOperationId, + fingerprint: input.envelope.payloadFingerprint, + location: intent.location, + provider: intent.provider, + accountHome: intent.accountHome, + spawnToken: randomUUID(), + claimKeyId: this.agentSessionClaimSigner.keyId, + launchEnv + }) + if (!reserved.ok) { + return reserved + } + const { fence, spawnToken } = reserved + agentSessionPtyWriteGate.bindPtyForAttempt(input.ptyId, sessionId, spawnToken) + let ownerProven = false + try { + return await this.attachAdoptedStructuredTui({ + input, + caller, + host, + intent, + fence, + spawnToken, + rootProcessId, + pty, + onOwnerProven: (owner) => { + if (ownerProven) { + return + } + ownerProven = true + this.adoptedStructuredTuiOwners.set(sessionId, owner) + const settled = agentSessionPtyWriteGate.settlePtyAttempt(input.ptyId, spawnToken) + const currentPty = this.ptysById.get(input.ptyId) + if ( + !settled && + currentPty?.connected && + currentPty.paneKey === input.paneKey && + agentSessionPtyWriteGate.boundSessionId(input.ptyId) === null + ) { + agentSessionPtyWriteGate.bindPty(input.ptyId, sessionId) + } + } + }) + } finally { + if (!ownerProven) { + const error = await releaseStructuredTuiAdoptionReservation( + () => host.releaseAdoptedTuiReservation({ sessionId, fence, spawnToken }), + () => agentSessionPtyWriteGate.releasePtyAttempt(input.ptyId, spawnToken) + ) + if (error !== null) { + console.warn('[native-chat] adopted TUI reservation release failed', { + sessionId, + fence, + spawnToken, + error + }) + } + } + } + } + + private async attachAdoptedStructuredTui(args: { + input: Parameters[0] + caller: StructuredAgentSessionCaller + host: NonNullable> + intent: AgentSessionAttachParams + fence: number + spawnToken: string + rootProcessId: number + pty: RuntimePtyWorktreeRecord + onOwnerProven: (owner: StructuredTuiOwner) => void + }): Promise> { + const { input, caller, host, intent, fence, spawnToken, rootProcessId, pty } = args + const readPty = (): RuntimePtyWorktreeRecord => { + const current = this.ptysById.get(input.ptyId) + if (!current?.connected || current.paneKey !== input.paneKey) { + throw new Error('The Codex terminal lost its pane identity.') + } + return current + } + const readProcess = () => + readStructuredTuiProcessIdentity({ + hostId: intent.location.executionHostId, + rootPid: rootProcessId, + spawnToken, + agent: 'codex' + }) + const process = await readProcess() + let threadId = input.threadId + const proofInput = { + codexHome: intent.accountHome.path, + kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(input.ptyId)?.flags ?? 0, + readOutput: () => { + const current = readPty() + return { + text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview), + lastOutputAt: current.lastOutputAt + } + }, + write: (data: string) => + this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { + sessionId: input.envelope.sessionId, + spawnToken + }) ?? false + } + let transcriptPath: string + if (threadId) { + const proof = await proveCodexTuiRollout({ ...proofInput, threadId }) + transcriptPath = proof.transcriptPath + } else { + const proof = await resolveLiveCodexTuiRollout({ + ...proofInput + }) + threadId = proof.threadId + transcriptPath = proof.transcriptPath + } + const confirmedProcess = await readProcess() + if ( + confirmedProcess.pid !== process.pid || + confirmedProcess.processStartTimeMs !== process.processStartTimeMs + ) { + throw new Error('The Codex terminal process changed during conversation proof.') + } + const owner: StructuredTuiOwner = { + terminal: { + handle: this.issueStructuredTuiPtyHandle(pty), + tabId: input.tabId, + paneKey: input.paneKey, + ptyId: input.ptyId + }, + process, + link: codexProviderHandleLink({ + threadId, + resumed: false, + origin: 'adopted', + // Why not 1: a retry supersedes the previous attempt's reservation, and `proveOwner` + // refuses a handle link minted at any fence but the reservation's own. + fence, + observedAt: Date.now() + }), + transcriptPath, + historySource: 'provider-resume', + adoptedTerminal: true + } + const params: AgentSessionAttachParams = { + ...intent, + envelope: { + ...input.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: input.envelope.sessionId, + fields: { + location: intent.location, + provider: 'codex', + agent: 'codex', + accountHome: intent.accountHome, + runtimeKind: 'tui', + providerHandle: { kind: 'codex', threadId }, + expectedRuntimeFence: null + } + }) + }, + runtimeKind: 'tui', + providerHandle: { kind: 'codex', threadId } + } + const adopted = await host.adoptTuiOwner({ + caller, + params, + owner, + onOwnerProven: () => args.onOwnerProven(owner) + }) + if (adopted.ok) { + args.onOwnerProven(owner) + } + return adopted + } + private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) @@ -11253,6 +11714,60 @@ export class OrcaRuntimeService { worktree: string agent: 'codex' }): Promise { + return this.resolveStructuredAgentSessionIntent(input, ({ workspacePath, launchEnv }) => { + // A create has no process yet, so the current selection is what it must follow. + const preparedHome = this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + const configuredHome = launchEnv.CODEX_HOME + return ( + preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn ? getSystemCodexHomePath() : configuredHome?.trim()) || + getSystemCodexHomePath() + ) + }) + } + + /** + * Why adoption resolves its own intent: the pane's Codex is already running under the + * CODEX_HOME it was spawned with, and only that home's sessions directory can hold its + * rollout. Following the create intent here probed the currently selected account instead, + * so adopting a pane on any other account failed with no hint that the account mismatched. + */ + async resolveStructuredAgentSessionAdoptionIntent(input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + ptyId: string + }): Promise { + return this.resolveStructuredAgentSessionIntent(input, () => + this.resolveAdoptedCodexPaneHomePath(input.ptyId) + ) + } + + private resolveAdoptedCodexPaneHomePath(ptyId: string): string { + const resolved = resolveCodexPaneLaunchHome({ + record: getCodexPaneAccount(ptyId), + settings: this.requireStore().getSettings(), + systemCodexHomePath: getSystemCodexHomePath(), + sharedRuntimeCodexHomePath: resolveOrcaManagedCodexHomePath() + }) + // Never fall back to the selected home: a silent wrong-home probe is what hid the mismatch. + if (resolved.kind !== 'attributed') { + throw new Error(describeCodexPaneLaunchHomeFailure(resolved)) + } + return resolved.path + } + + private async resolveStructuredAgentSessionIntent( + input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }, + resolveAccountHomePath: (context: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string + ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { throw new Error('structured_agent_session_unsupported') @@ -11261,8 +11776,6 @@ export class OrcaRuntimeService { const launchEnv = resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - const preparedHome = this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) - const configuredHome = launchEnv.CODEX_HOME return { envelope: { sessionId: input.envelope.sessionId, @@ -11275,12 +11788,7 @@ export class OrcaRuntimeService { agent: 'codex', accountHome: { variable: 'CODEX_HOME', - path: - preparedHome?.trim() || - (this.prepareCodexStructuredLaunchFn - ? getSystemCodexHomePath() - : configuredHome?.trim()) || - getSystemCodexHomePath() + path: resolveAccountHomePath({ workspacePath, launchEnv }) }, 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 06cc3dbd3e2f..41f42e8805a0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -111,6 +111,17 @@ export const CreateSupportParams = z }) .strict() +export const AdoptTerminalParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + tabId: Identifier('Invalid terminal tab id'), + paneKey: Identifier('Invalid terminal pane key'), + ptyId: Identifier('Invalid PTY id'), + threadId: Identifier('Invalid Codex thread id').optional() + }) + .strict() + /** Clients may only author user turns. Accepting an assistant or tool role here * would let one client write words into the agent's mouth in another's * timeline, and the provider — not the client — owns those. */ 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 69bfc057d7af..93c5c4d24802 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -123,6 +123,7 @@ function dispatcher(): RpcDispatcher { accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, runtimeKind: 'native' })), + adoptStructuredAgentSessionTerminal: vi.fn(async () => ({ ok: true, replayed: false })), publishStructuredAgentSessionTab: vi.fn() } const runtime = { @@ -191,7 +192,7 @@ describe('capability gating', () => { for (const method of STRUCTURED_AGENT_SESSION_METHODS) { expect(names).toContain(method.name) } - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(13) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(15) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -261,6 +262,84 @@ describe('capability gating', () => { }) describe('method routing', () => { + it('routes local terminal adoption with its verified request fingerprint', async () => { + const fields = { + worktree: 'id:workspace-1', + tabId: 'tab-1', + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1', + threadId: 'thread-1' + } + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.adoptTerminal', + sessionId: SESSION, + fields + }) + }), + ...fields + } + + const adopted = await call('agentSession.adoptTerminal', params) + + expect(adopted).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.adoptStructuredAgentSessionTerminal).toHaveBeenCalledWith( + params, + expect.anything() + ) + }) + + it('allows the host to discover a terminal thread omitted by an older hook surface', async () => { + const fields = { + worktree: 'id:workspace-1', + tabId: 'tab-1', + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1' + } + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.adoptTerminal', + sessionId: SESSION, + fields + }) + }), + ...fields + } + + await expect(call('agentSession.adoptTerminal', params)).resolves.toMatchObject({ ok: true }) + expect(runtimeCalls.adoptStructuredAgentSessionTerminal).toHaveBeenCalledWith( + params, + expect.anything() + ) + }) + + it('routes ownership handoff requests to the structured host', async () => { + const fields = { + direction: 'to-native' as const, + mode: 'after-turn' as const, + action: 'start' as const + } + const params = { + envelope: envelope({ + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }), + ...fields + } + + const handedOff = await call('agentSession.handoff', params) + + expect(handedOff).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith(expect.anything(), params) + }) + it('creates from mobile intent while the host resolves paths and provider identity', async () => { const worktree = 'id:workspace-1' const params = { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index cde0156c062f..3663e203dfa0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -17,10 +17,12 @@ import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-se import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { AttachParams, + AdoptTerminalParams, CancelParams, CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -104,6 +106,36 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { } export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.adoptTerminal', + params: AdoptTerminalParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + if (ctx.clientKind === 'mobile' || params.envelope.expectedRuntimeFence !== null) { + throw new Error('agent_session_operation_invalid') + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.adoptTerminal', + sessionId: params.envelope.sessionId, + fields: { + worktree: params.worktree, + tabId: params.tabId, + paneKey: params.paneKey, + ptyId: params.ptyId, + ...(params.threadId ? { threadId: params.threadId } : {}) + } + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + await ensureHostInstalled(ctx) + return ctx.runtime.adoptStructuredAgentSessionTerminal( + { ...params, envelope: { ...params.envelope, expectedRuntimeFence: null } }, + callerFor(ctx) + ) + } + }), defineMethod({ name: 'agentSession.createSupport', params: CreateSupportParams, @@ -208,6 +240,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.handoff', + params: HandoffParams, + handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) + }), defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, diff --git a/src/main/runtime/structured-tui-adoption-inflight.ts b/src/main/runtime/structured-tui-adoption-inflight.ts new file mode 100644 index 000000000000..238603ecb17c --- /dev/null +++ b/src/main/runtime/structured-tui-adoption-inflight.ts @@ -0,0 +1,28 @@ +const MAX_IN_FLIGHT_ADOPTIONS = 4_096 + +export class StructuredTuiAdoptionInflight { + private readonly operations = new Map>() + + run( + input: { callerKey: string; operationId: string; fingerprint: string }, + adopt: () => Promise + ): Promise { + const key = `${input.callerKey}\0${input.operationId}\0${input.fingerprint}` + const existing = this.operations.get(key) + if (existing) { + return existing + } + if (this.operations.size >= MAX_IN_FLIGHT_ADOPTIONS) { + return Promise.reject(new Error('Too many structured TUI adoptions are still pending.')) + } + const operation = adopt() + this.operations.set(key, operation) + const drop = (): void => { + if (this.operations.get(key) === operation) { + this.operations.delete(key) + } + } + void operation.then(drop, drop) + return operation + } +} diff --git a/src/main/runtime/structured-tui-adoption-reservation-release.ts b/src/main/runtime/structured-tui-adoption-reservation-release.ts new file mode 100644 index 000000000000..e8ad10b75577 --- /dev/null +++ b/src/main/runtime/structured-tui-adoption-reservation-release.ts @@ -0,0 +1,27 @@ +const RELEASE_ATTEMPTS = 3 +const BACKGROUND_RETRY_MS = 1_000 + +function retryInBackground(release: () => Promise, onReleased: () => void): void { + const timer = setTimeout(() => { + void release().then(onReleased, () => retryInBackground(release, onReleased)) + }, BACKGROUND_RETRY_MS) + timer.unref?.() +} + +export async function releaseStructuredTuiAdoptionReservation( + release: () => Promise, + onReleased: () => void +): Promise { + let failure: unknown = null + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + try { + await release() + onReleased() + return null + } catch (error) { + failure = error + } + } + retryInBackground(release, onReleased) + return failure +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index dbc2bd76c2f6..047875f36625 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -28,6 +28,7 @@ export function NativeChatStructuredSession(props: { target: RuntimeClientTarget agent: AgentType allowFileUriLinks: boolean + onSwitchToTerminal?: () => void }): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) @@ -225,6 +226,7 @@ export function NativeChatStructuredSession(props: { } }} structuredTransport={structuredTransport} + onSwitchToTerminal={props.onSwitchToTerminal} /> )} diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index 3aead4c8dda9..dc1ba79be3a7 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -160,6 +160,33 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: null, exitChat: true }) }) + it('keeps structured chat open when its ownership transfer stops the TUI', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'adopted-agent', + activeLeafId: 'adopted-agent', + chatLeafStillMounted: false, + activeLeafIsEligible: false, + chatLeafHasConfirmedAgentExit: true, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + + it('binds tab-bar structured adoption to the active leaf after the TUI exits', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'adopted-agent', + chatLeafStillMounted: true, + activeLeafIsEligible: false, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + it('moves chat to an eligible sibling after the owning agent exits', () => { expect( resolveNativeChatLeafRoute({ diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 2dfc25e77f8f..59c660930c5c 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -73,11 +73,19 @@ export function resolveNativeChatLeafRoute(args: { chatLeafStillMounted: boolean activeLeafIsEligible: boolean chatLeafHasConfirmedAgentExit?: boolean + structuredSessionId?: string | null }): NativeChatLeafRoute { + const confirmedAgentExit = args.chatLeafHasConfirmedAgentExit && !args.structuredSessionId if (!args.isChatViewMode) { return { chatLeafId: null, exitChat: false } } - if (args.chatLeafId && args.chatLeafStillMounted && !args.chatLeafHasConfirmedAgentExit) { + if (args.structuredSessionId) { + return { + chatLeafId: args.chatLeafId ?? args.activeLeafId, + exitChat: false + } + } + if (args.chatLeafId && args.chatLeafStillMounted && !confirmedAgentExit) { // Why: agent/title evidence can disappear while local, SSH, or runtime // transports reconnect. A mounted owning pane is not a terminal lifecycle // event, so keep its chat surface until the pane itself is removed. @@ -85,13 +93,10 @@ export function resolveNativeChatLeafRoute(args: { } // Manager hydration can briefly have no active pane; preserve the requested // mode until a concrete leaf exists instead of toggling it off during mount. - if (!args.activeLeafId && !args.chatLeafHasConfirmedAgentExit) { + if (!args.activeLeafId && !confirmedAgentExit) { return { chatLeafId: args.chatLeafId, exitChat: false } } - if ( - args.activeLeafIsEligible && - (!args.chatLeafHasConfirmedAgentExit || args.activeLeafId !== args.chatLeafId) - ) { + if (args.activeLeafIsEligible && (!confirmedAgentExit || args.activeLeafId !== args.chatLeafId)) { return { chatLeafId: args.activeLeafId, exitChat: false } } // Why: removing the owning leaf or confirming its agent exited must not leave diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index a68e8aa9f287..d1a206be82bf 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -32,6 +32,7 @@ export type NativeChatStructuredViewProps = { target: RuntimeClientTarget agent: AgentType allowFileUriLinks: boolean + onSwitchToTerminal?: () => void } export type NativeChatResolvedViewProps = { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts deleted file mode 100644 index b4fba75d3eec..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { - AgentSessionAttachResult, - AgentSessionMutationResult -} from '../../../../shared/agent-session-wire' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { - createStructuredAgentSessionOperationId, - showStructuredAgentSessionChoice, - structuredAgentSessionCreateFingerprint -} from '../../../../shared/structured-agent-session-mutation' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { useAppStore } from '@/store' -import { - getActiveRuntimeTarget, - runtimeEnvironmentSupportsCapability -} from '@/runtime/runtime-rpc-client' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { recordWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' - -const LOCAL_OWNER = 'local-structured-session' - -export function useStructuredAgentSessionCreate(worktreeId: string): { - supported: boolean - creating: boolean - create: () => Promise -} { - const environmentId = useAppStore((state) => - getRuntimeEnvironmentIdForWorktree(state, worktreeId) - ) - const target = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), - [environmentId] - ) - const [supported, setSupported] = useState(false) - const [creating, setCreating] = useState(false) - - useEffect(() => { - let stale = false - setSupported(false) - void (async () => { - const hostCapability = - target.kind === 'local' - ? ((await window.api.runtime.getStatus()).capabilities?.includes( - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ) ?? false) - : await runtimeEnvironmentSupportsCapability( - target.environmentId, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ) - if (!hostCapability) { - return - } - const support = await callStructuredAgentSession<{ supported?: boolean }>( - target, - 'agentSession.createSupport', - { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' } - ) - if (!stale) { - setSupported( - showStructuredAgentSessionChoice({ - hostCapability, - workspaceSupport: support.supported === true, - agent: 'codex' - }) - ) - } - })().catch(() => !stale && setSupported(false)) - return () => { - stale = true - } - }, [target, worktreeId]) - - const create = useCallback(async (): Promise => { - if (!supported || creating) { - return false - } - setCreating(true) - const sessionId = `desktop_${Date.now().toString(36)}${crypto.randomUUID().slice(0, 8)}` - const worktree = toRuntimeWorktreeSelector(worktreeId) - try { - recordWebSessionFocusIntent( - { environmentId: target.kind === 'environment' ? target.environmentId : LOCAL_OWNER }, - worktreeId, - `agent-session:${sessionId}` - ) - const result = await callStructuredAgentSession< - AgentSessionMutationResult - >(target, 'agentSession.create', { - envelope: { - sessionId, - clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), - expectedRuntimeFence: null, - payloadFingerprint: structuredAgentSessionCreateFingerprint({ sessionId, worktree }) - }, - worktree, - agent: 'codex' - }) - if (!result.ok) { - throw new Error(result.refusal.message) - } - return true - } finally { - setCreating(false) - } - }, [creating, supported, target, worktreeId]) - - return { supported, creating, create } -} diff --git a/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts b/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts new file mode 100644 index 000000000000..59cff83d623d --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { resolveNativeChatTabAgentEvidence } from './native-chat-tab-agent-evidence' + +describe('resolveNativeChatTabAgentEvidence', () => { + it('uses the retained provider identity when a generated title masks the process title', () => { + expect( + resolveNativeChatTabAgentEvidence( + { + title: 'Summarize recent commits', + aiVaultTitle: null + }, + { + label: 'Summarize recent commits', + aiVaultTitle: { + agent: 'codex', + sessionId: 'thread-1', + title: 'Summarize recent commits' + } + } + ) + ).toBe('codex') + }) + + it('keeps the committed process-title signal ahead of retained metadata', () => { + expect( + resolveNativeChatTabAgentEvidence( + { + title: 'Claude Code', + aiVaultTitle: { agent: 'codex', sessionId: 'thread-1', title: 'Old title' } + }, + { label: 'Old title', aiVaultTitle: null } + ) + ).toBe('claude') + }) +}) diff --git a/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.ts b/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.ts new file mode 100644 index 000000000000..6d1a3d5fc248 --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.ts @@ -0,0 +1,17 @@ +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' + +export function resolveNativeChatTabAgentEvidence( + tab: Pick, + unifiedTab?: Pick +): TuiAgent | null { + return ( + resolveCommittedTitleAgentType(unifiedTab?.label ?? '') ?? + resolveCommittedTitleAgentType(tab.title) ?? + unifiedTab?.aiVaultTitle?.agent ?? + tab.aiVaultTitle?.agent ?? + null + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 38d6f2e267fd..ffe6eb53d956 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -83,6 +83,29 @@ describe('selectTabAgentTypesByTabId', () => { expect(projection['tab-1'] ?? null).toBeNull() }) + it('uses the active leaf foreground process before hook status exists', () => { + const layouts = { 'tab-1': splitLayout('leaf-b') } + const foreground = { + 'tab-1:leaf-a': { agent: 'claude' as const, shellForeground: false }, + 'tab-1:leaf-b': { agent: 'codex' as const, shellForeground: false } + } + + expect(selectTabAgentTypesByTabId({}, layouts, foreground)).toEqual({ 'tab-1': 'codex' }) + expect( + selectTabAgentTypesByTabId( + { 'tab-1:leaf-b': entry({ agentType: 'grok' }) }, + layouts, + foreground + ) + ).toEqual({ 'tab-1': 'grok' }) + expect( + selectTabAgentTypesByTabId({}, layouts, { + ...foreground, + 'tab-1:leaf-b': { agent: 'codex', shellForeground: true } + }) + ).toEqual({}) + }) + it('uses the reassigned active sibling after the prior agent leaf closes', () => { const statuses = { 'tab-1:leaf-a': entry({ agentType: 'claude' }), diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 420949992d8d..f40bc1550b57 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,5 +1,6 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId @@ -13,6 +14,7 @@ type TabBarAgentProjectionSelectorDependencies = { export type TabBarAgentProjectionState = { agentStatusByPaneKey?: Record + paneForegroundAgentByPaneKey?: Record terminalLayoutsByTabId?: Record settings?: { experimentalNativeChat?: boolean } | null } @@ -24,6 +26,8 @@ export type TabBarAgentProjections = { } const EMPTY_AGENT_STATUS_BY_PANE_KEY: Record = Object.freeze({}) +const EMPTY_PANE_FOREGROUND_AGENT_BY_PANE_KEY: Record = + Object.freeze({}) const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_TAB_AGENT_TYPES_BY_TAB_ID: Record = Object.freeze({}) const EMPTY_UNSAFE_TABS_BY_ID: Record = Object.freeze({}) @@ -50,6 +54,7 @@ function reuseRecordIfEqual( function projectTabAgentTypesByTabId( agentStatusByPaneKey: Record, terminalLayoutsByTabId: Record, + paneForegroundAgentByPaneKey: Record = {}, dependencies?: TabBarAgentProjectionSelectorDependencies ): Record { const byTabId: Record = {} @@ -64,9 +69,15 @@ function projectTabAgentTypesByTabId( if (!activeLeafId) { continue } - const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] + const paneKey = `${tabId}:${activeLeafId}` + const entry = agentStatusByPaneKey[paneKey] if (entry?.agentType != null) { byTabId[tabId] = entry.agentType + continue + } + const foreground = paneForegroundAgentByPaneKey[paneKey] + if (foreground?.agent != null && !foreground.shellForeground && !foreground.routingRevoked) { + byTabId[tabId] = foreground.agent } } for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { @@ -120,9 +131,14 @@ function projectNativeChatTabWideFallbackUnsafeTabsById( */ export function selectTabAgentTypesByTabId( agentStatusByPaneKey: Record, - terminalLayoutsByTabId: Record = {} + terminalLayoutsByTabId: Record = {}, + paneForegroundAgentByPaneKey: Record = {} ): Record { - return projectTabAgentTypesByTabId(agentStatusByPaneKey, terminalLayoutsByTabId) + return projectTabAgentTypesByTabId( + agentStatusByPaneKey, + terminalLayoutsByTabId, + paneForegroundAgentByPaneKey + ) } export function selectNativeChatTabWideFallbackUnsafeTabsById( @@ -135,6 +151,7 @@ export function createTabBarAgentProjectionSelector( dependencies?: TabBarAgentProjectionSelectorDependencies ): (state: TabBarAgentProjectionState) => TabBarAgentProjections { let cachedAgentStatusByPaneKey: Record | null = null + let cachedPaneForegroundAgentByPaneKey: Record | null = null let cachedAgentTypeLayoutsByTabId: Record | null = null let cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID let cachedUnsafeLayoutsByTabId: Record | null = null @@ -145,6 +162,7 @@ export function createTabBarAgentProjectionSelector( if (state.settings?.experimentalNativeChat !== true) { if (cachedEnabledResult) { cachedAgentStatusByPaneKey = null + cachedPaneForegroundAgentByPaneKey = null cachedAgentTypeLayoutsByTabId = null cachedAgentTypesByTabId = EMPTY_TAB_AGENT_TYPES_BY_TAB_ID cachedUnsafeLayoutsByTabId = null @@ -155,13 +173,19 @@ export function createTabBarAgentProjectionSelector( } const statuses = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + const foreground = state.paneForegroundAgentByPaneKey ?? EMPTY_PANE_FOREGROUND_AGENT_BY_PANE_KEY const layouts = state.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - if (statuses !== cachedAgentStatusByPaneKey || layouts !== cachedAgentTypeLayoutsByTabId) { + if ( + statuses !== cachedAgentStatusByPaneKey || + foreground !== cachedPaneForegroundAgentByPaneKey || + layouts !== cachedAgentTypeLayoutsByTabId + ) { cachedAgentTypesByTabId = reuseRecordIfEqual( cachedAgentTypesByTabId, - projectTabAgentTypesByTabId(statuses, layouts, dependencies) + projectTabAgentTypesByTabId(statuses, layouts, foreground, dependencies) ) cachedAgentStatusByPaneKey = statuses + cachedPaneForegroundAgentByPaneKey = foreground cachedAgentTypeLayoutsByTabId = layouts } if (layouts !== cachedUnsafeLayoutsByTabId) { diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 92283230909a..4156b572f87a 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -4,10 +4,10 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { OpenFile } from '../../store/slices/editor' import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' import BrowserTab from './BrowserTab' +import { resolveNativeChatTabAgentEvidence } from './native-chat-tab-agent-evidence' import type { DropIndicator } from './drop-indicator' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { getTabDragLabel, type TabBarItem } from './tab-bar-item-model' @@ -85,9 +85,7 @@ export function renderTabBarItems({ } const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. - const resolvedAgent = - resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? - resolveCommittedTitleAgentType(terminalTab.title) + const resolvedAgent = resolveNativeChatTabAgentEvidence(terminalTab, unifiedTabForItem) // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index cfb8dc88627c..dfbe7558dfed 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -527,6 +527,11 @@ function TerminalPane( getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) ?.viewMode === 'chat' ) + const structuredSessionId = useAppStore( + (store) => + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.structuredSessionId ?? null + ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) const effectiveChatViewMode = nativeChatEnabled && isChatViewMode const unifiedTabLabel = useAppStore( @@ -538,7 +543,11 @@ function TerminalPane( ) // Carry each leaf's agent identity, not just "an agent exists", so the gate can reject unsupported agents; scoped to this tab's panes. const tabAgentTypeByLeaf = useAppStore((store) => - selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) + selectTerminalTabAgentTypesByLeaf( + store.agentStatusByPaneKey, + tabId, + store.paneForegroundAgentByPaneKey + ) ) const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) const setTabViewMode = useAppStore((store) => store.setTabViewMode) @@ -656,11 +665,18 @@ function TerminalPane( activeLeafId, chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId), activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), - chatLeafHasConfirmedAgentExit: true + chatLeafHasConfirmedAgentExit: true, + structuredSessionId }) ) }, - [applyNativeChatLeafRoute, chatLeafId, isChatEligibleForLeaf, isChatViewMode] + [ + applyNativeChatLeafRoute, + chatLeafId, + isChatEligibleForLeaf, + isChatViewMode, + structuredSessionId + ] ) useEffect(() => { // Why: transport callbacks must observe only committed chat ownership; render work can be replayed/discarded under concurrent React. @@ -2962,7 +2978,8 @@ function TerminalPane( chatLeafId, activeLeafId, chatLeafStillMounted, - activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), + structuredSessionId }) applyNativeChatLeafRoute(route) }, [ @@ -2971,7 +2988,8 @@ function TerminalPane( activePane?.leafId, chatLeafStillMounted, applyNativeChatLeafRoute, - isChatEligibleForLeaf + isChatEligibleForLeaf, + structuredSessionId ]) const chatPane = isChatViewMode && chatLeafId @@ -3146,44 +3164,57 @@ function TerminalPane( {effectiveChatViewMode && chatPane?.container ? createPortal(
- contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), - onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), - canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, - onEqualizePaneSizes: () => - contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), - canExpandPane: managedPanes.length > 1, - isPaneExpanded: expandedPaneId === chatPane.id, - onToggleExpand: () => - contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), - canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( - resolveAgentForLeaf(chatPane.leafId) - ), - onContinueAgentSessionInNewSession: () => - contextMenu.runForPane( - chatPane.id, - contextMenu.onContinueAgentSessionInNewSession + {structuredSessionId ? ( + + ) : ( + + contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), + onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), + canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, + onEqualizePaneSizes: () => + contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), + canExpandPane: managedPanes.length > 1, + isPaneExpanded: expandedPaneId === chatPane.id, + onToggleExpand: () => + contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), + canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( + resolveAgentForLeaf(chatPane.leafId) ), - onForkAgentSession: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), - onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), - onCopyTerminalId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), - onCopyPaneId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), - canClosePane: managedPanes.length > 1, - onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) - }} - /> + onContinueAgentSessionInNewSession: () => + contextMenu.runForPane( + chatPane.id, + contextMenu.onContinueAgentSessionInNewSession + ), + onForkAgentSession: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), + onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), + onCopyTerminalId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), + onCopyPaneId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canClosePane: managedPanes.length > 1, + onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) + }} + /> + )}
, chatPane.container, `native-chat-${tabId}-${chatPane.leafId}` diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts index bf000ffc6971..d1714b19ed8e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -71,4 +71,30 @@ describe('createTerminalTabAgentTypeSelector', () => { expect(select(state, 'tab-1')).toEqual({}) expect(select(state, 'malformed')).toEqual({}) }) + + it('uses a live foreground process until hook identity arrives', () => { + const select = createTerminalTabAgentTypeSelector() + const foreground = { + 'tab-1:leaf-a': { + agent: 'codex' as const, + shellForeground: false, + routingTrusted: true + } + } + + expect(select({}, 'tab-1', foreground)).toEqual({ 'leaf-a': 'codex' }) + expect(select({ 'tab-1:leaf-a': entry('claude') }, 'tab-1', foreground)).toEqual({ + 'leaf-a': 'claude' + }) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], shellForeground: true } + }) + ).toEqual({}) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], routingRevoked: true } + }) + ).toEqual({}) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index 1b7693b45ab3..f14e96763e71 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -1,4 +1,5 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' export type TerminalTabAgentTypeState = Record export type TerminalTabAgentTypesByLeaf = Readonly> @@ -8,6 +9,9 @@ type SelectorDependencies = { } const EMPTY_AGENT_TYPES_BY_LEAF: TerminalTabAgentTypesByLeaf = Object.freeze({}) +const EMPTY_FOREGROUND_AGENT_BY_PANE_KEY: Record = Object.freeze( + {} +) function reuseRecordIfEqual( previous: TerminalTabAgentTypesByLeaf | undefined, @@ -25,14 +29,19 @@ function reuseRecordIfEqual( export function createTerminalTabAgentTypeSelector( dependencies: SelectorDependencies = {} -): (state: TerminalTabAgentTypeState, tabId: string) => TerminalTabAgentTypesByLeaf { +): ( + state: TerminalTabAgentTypeState, + tabId: string, + foreground?: Record +) => TerminalTabAgentTypesByLeaf { let cachedState: TerminalTabAgentTypeState | null = null + let cachedForeground: Record | null = null let cachedByTabId = new Map() - return (state, tabId) => { + return (state, tabId, foreground = EMPTY_FOREGROUND_AGENT_BY_PANE_KEY) => { // Why: production writes replace this map. Its identity lets unrelated // Zustand notifications skip the global scan entirely. - if (state !== cachedState) { + if (state !== cachedState || foreground !== cachedForeground) { const previousByTabId = cachedByTabId const nextByTabId = new Map>() for (const [paneKey, entry] of Object.entries(state)) { @@ -53,6 +62,23 @@ export function createTerminalTabAgentTypeSelector( nextByTabId.set(entryTabId, { [leafId]: entry.agentType }) } } + for (const [paneKey, entry] of Object.entries(foreground)) { + if (!entry.agent || entry.shellForeground || entry.routingRevoked) { + continue + } + const separator = paneKey.indexOf(':') + if (separator <= 0) { + continue + } + const entryTabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + const byLeaf = nextByTabId.get(entryTabId) + if (byLeaf) { + byLeaf[leafId] ??= entry.agent + } else { + nextByTabId.set(entryTabId, { [leafId]: entry.agent }) + } + } const stabilizedByTabId = new Map() for (const [entryTabId, byLeaf] of nextByTabId) { @@ -63,6 +89,7 @@ export function createTerminalTabAgentTypeSelector( } cachedByTabId = stabilizedByTabId cachedState = state + cachedForeground = foreground } return cachedByTabId.get(tabId) ?? EMPTY_AGENT_TYPES_BY_LEAF } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0fb68524a7a5..8ac9f0c159fd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15956,6 +15956,8 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "Could not switch this Codex session to structured chat", + "structuredReturnToTerminalFailed": "Could not return this Codex session to the terminal", "composer": { "imageUnsupported": "Image paste is not supported for this agent.", "send": "Send", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 27136599f39d..d4c20da64c96 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14383,6 +14383,8 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "No se pudo cambiar esta sesión de Codex al chat estructurado", + "structuredReturnToTerminalFailed": "No se pudo devolver esta sesión de Codex al terminal", "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", "send": "Enviar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1a2f3e9f95a1..2e4fbadba5a0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14383,6 +14383,8 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "この Codex セッションを構造化チャットに切り替えられませんでした", + "structuredReturnToTerminalFailed": "この Codex セッションをターミナルに戻せませんでした", "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", "send": "送信", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a360d1331ecb..8c368be9613e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14412,6 +14412,8 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "이 Codex 세션을 구조화된 채팅으로 전환할 수 없습니다", + "structuredReturnToTerminalFailed": "이 Codex 세션을 터미널로 되돌릴 수 없습니다", "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", "send": "보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1774a4afa4b6..bd7fedd7a9df 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14403,6 +14403,8 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "无法将此 Codex 会话切换到结构化聊天", + "structuredReturnToTerminalFailed": "无法将此 Codex 会话返回终端", "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", "send": "发送", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 51deb11be284..b2daf1a6d97f 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -43,6 +43,10 @@ describe('native chat locale copy', () => { expect(localized).not.toBe(english) } expect(search.grok).toBe('grok') + expect(catalog.components['native-chat'].structuredReturnToTerminalFailed.trim()).not.toBe('') + expect(catalog.components['native-chat'].structuredReturnToTerminalFailed).not.toBe( + en.components['native-chat'].structuredReturnToTerminalFailed + ) const composer = catalog.components['native-chat'].composer for (const key of [ 'model', diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 4d93a29dbe30..6968a21ae1cd 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -218,6 +218,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') }) + it('marks a seeded launch prompt failed when paste delivery rejects', async () => { + const error = new Error('prompt transport rejected') + mocks.pasteDraftWhenAgentReady.mockRejectedValue(error) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + ).rejects.toBe(error) + + expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + }) + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { // claude delivers via `--prefill` at launch, so paste no-ops (returns false) // when forcePaste is false — that is a native delivery, not a failure. diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 87680462ccea..864c25f3e32b 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -64,10 +64,18 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout - }).then((delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { - useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + }).then( + (delivered) => { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + return delivered || deliversViaNativePrefill + }, + (error) => { + if (shouldSeed && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + throw error } - return delivered || deliversViaNativePrefill - }) + ) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 5f9ce2568850..a69179201001 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -5,6 +5,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() const mockSetActiveTabType = vi.fn() +const mockSetTabViewMode = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() @@ -13,6 +14,7 @@ const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() +const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -79,6 +81,7 @@ const store = { closeTab: vi.fn(), queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, + setTabViewMode: mockSetTabViewMode, setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, @@ -112,6 +115,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-ready-wait', () => ({ + waitForAgentReady: mockWaitForAgentReady +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -171,6 +178,7 @@ describe('launchAgentInNewTab', () => { store.ptyIdsByTabId = {} mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) }) it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { @@ -212,7 +220,7 @@ describe('launchAgentInNewTab', () => { ) }) - it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + it('adopts supported Codex launches through the structured chat toggle', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -231,8 +239,7 @@ describe('launchAgentInNewTab', () => { }) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', - viewMode: 'chat' + launchAgent: 'codex' }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', @@ -246,6 +253,7 @@ describe('launchAgentInNewTab', () => { text: 'large generated prompt', createdAt: expect.any(Number) }) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) }) it('opens local Grok submit-after-ready launches in native chat', async () => { @@ -445,8 +453,9 @@ describe('launchAgentInNewTab', () => { 'wt-1', undefined, undefined, - expect.objectContaining({ viewMode: 'chat' }) + expect.not.objectContaining({ viewMode: 'chat' }) ) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) }) it('preserves paired-host draft delivery and supported launch preferences', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d46cb530bd65..8573079003f5 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -29,6 +29,8 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' +import { waitForAgentReady } from '@/lib/agent-ready-wait' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -139,6 +141,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI isFollowupPath }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined + let promptDeliveryCompletion: Promise | undefined if (!startupPlan) { return null @@ -175,10 +178,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. + const adoptStructuredChat = + agent === 'codex' && + initialViewModeProps.viewMode === 'chat' && + canUseStructuredNativeChat(store, worktreeId) const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialViewModeProps + ...(adoptStructuredChat ? {} : initialViewModeProps) }) seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) if (initialCwd?.trim()) { @@ -236,6 +243,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } }) + promptDeliveryCompletion = deliveryPromise if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise } else { @@ -247,6 +255,25 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI onPromptDelivered?.() } + if (adoptStructuredChat) { + void (promptDeliveryCompletion ?? Promise.resolve()) + .catch(() => undefined) + .then(async () => { + const ready = await waitForAgentReady(tab.id, TUI_AGENT_CONFIG.codex.expectedProcess) + if (!ready.ready) { + // Why: chat adoption proves the pane's foreground process. Flipping a tab whose Codex is + // not up yet trades a working terminal for an adoption error; the view toggle re-runs + // adoption whenever the user wants it. + console.warn('Codex was not ready for structured chat; staying in terminal view', { + tabId: tab.id, + reason: ready.reason + }) + return + } + useAppStore.getState().setTabViewMode(tab.id, 'chat') + }) + } + // Why: without setActiveTabType('terminal') a worktree showing an editor keeps rendering it and the new tab stays hidden. store.setActiveTabType('terminal') diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts new file mode 100644 index 000000000000..632127fa065c --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockSetTabViewMode = vi.fn() +const mockWaitForAgentReady = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {}, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }] + }, + detectedWorktreesByRepo: {}, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: vi.fn(), + setActiveTabType: vi.fn(), + setTabViewMode: mockSetTabViewMode, + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) +vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +/** Structured adoption creates the tab in terminal mode and flips it to chat once + * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That + * difference is the only observable signal that the availability guard ran. */ +describe('structured chat adoption guard on the launch path', () => { + beforeEach(() => { + vi.clearAllMocks() + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) + }) + + it('adopts a local Codex tab into the structured stack', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex' + }) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) + }) + + it('stays in terminal view when Codex never became ready', async () => { + mockWaitForAgentReady.mockResolvedValue({ ready: false, reason: 'timeout' }) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + await vi.waitFor(() => expect(mockWaitForAgentReady).toHaveBeenCalled()) + // Let every continuation the ready wait could have queued run before asserting it did not flip. + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Chat adoption probes the pane's foreground process: flipping now hands the user an adoption + // error in place of the terminal Codex is still starting in. + expect(mockSetTabViewMode).not.toHaveBeenCalledWith('tab-1', 'chat') + }) + + it('shows rejected prompt delivery in chat after Codex becomes ready', async () => { + const error = new Error('prompt transport rejected') + mockPasteDraftWhenAgentReady.mockRejectedValue(error) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).rejects.toBe(error) + expect(mockMarkNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) + expect(mockMarkNativeChatLaunchPromptFailed.mock.invocationCallOrder[0]).toBeLessThan( + mockSetTabViewMode.mock.invocationCallOrder[0]! + ) + }) + + it('keeps an SSH Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) + + it('keeps a runtime-paired Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts new file mode 100644 index 000000000000..939c27bf5f43 --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { canUseStructuredNativeChat } from './structured-native-chat-availability' + +const { mockGetRendererAppPlatform } = vi.hoisted(() => ({ + mockGetRendererAppPlatform: vi.fn<() => NodeJS.Platform>(() => 'darwin') +})) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: mockGetRendererAppPlatform +})) + +function stateFor(input: { + connectionId?: string | null + windowsRuntime?: 'windows-host' | 'wsl' + worktreePath?: string +}): AppState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: {}, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: input.worktreePath ?? 'C:\\repo\\worktree' + } + ] + }, + detectedWorktreesByRepo: {} + } as unknown as AppState +} + +describe('canUseStructuredNativeChat', () => { + beforeEach(() => { + mockGetRendererAppPlatform.mockReturnValue('darwin') + }) + + it('allows the structured stack on a local worktree', () => { + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it('refuses an SSH worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'ssh-a' }), 'wt-1')).toBe(false) + }) + + it('refuses a runtime-paired worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'runtime-ssh-a' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a WSL project on Windows so the pane stays on the bridge', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'wsl' }), 'wt-1')).toBe(false) + }) + + it('allows a Windows-host project on Windows', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts new file mode 100644 index 000000000000..b0eed7f334a5 --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -0,0 +1,11 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { + if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { + return false + } + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + return !(projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') +} diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts new file mode 100644 index 000000000000..fe3a756be69d --- /dev/null +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + nativeChatRouteForAgent, + nativeChatRouteForTerminal, + setTerminalNativeChatMode +} from './structured-native-chat-toggle' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mockToastError } })) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: () => 'win32' +})) + +function terminalState(input: { + agent?: 'codex' | 'claude' + structuredSessionId?: string + connectionId?: string | null + executionHostId?: `runtime:${string}` + windowsRuntime?: 'windows-host' | 'wsl' +}) { + const agent = input.agent ?? 'codex' + const tab = { + id: `tab-${agent}`, + entityId: `terminal-${agent}`, + worktreeId: 'workspace-1', + contentType: 'terminal' as const, + ...(input.structuredSessionId ? { structuredSessionId: input.structuredSessionId } : {}) + } + const paneKey = `${tab.entityId}:leaf-1` + return { + tab, + state: { + activeRepoId: 'repo-1', + activeWorktreeId: 'workspace-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: {}, + worktreesByRepo: { + 'repo-1': [ + { + id: 'workspace-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\repo\\worktree', + ...(input.executionHostId ? { hostId: input.executionHostId } : {}) + } + ] + }, + detectedWorktreesByRepo: {}, + unifiedTabsByWorktree: { 'workspace-1': [tab] }, + tabsByWorktree: { + 'workspace-1': [{ id: tab.entityId, launchAgent: agent }] + }, + terminalLayoutsByTabId: { + [tab.entityId]: { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': `pty-${agent}` } + } + }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: agent, + providerSession: { id: `thread-${agent}` } + } + } + } + } +} + +beforeEach(() => { + vi.mocked(callStructuredAgentSession).mockReset() + mockToastError.mockReset() +}) + +describe('native chat routing', () => { + it.each([ + ['codex', 'structured'], + ['claude', 'bridge'], + ['openclaude', 'bridge'], + ['grok', 'bridge'], + ['omp', 'bridge'] + ] as const)('routes %s to %s', (agent, route) => { + expect(nativeChatRouteForAgent(agent)).toBe(route) + }) + + it('keeps an adopted terminal structured after live Codex evidence disappears', () => { + expect( + nativeChatRouteForTerminal({ + agent: null, + structuredSessionId: 'codex_thread-1', + mode: 'terminal' + }) + ).toBe('structured') + }) + + it('lets a pre-migration Codex bridge return to the terminal before adoption', () => { + expect(nativeChatRouteForTerminal({ agent: 'codex', mode: 'terminal' })).toBe('bridge') + }) + + it.each([ + ['SSH', { connectionId: 'ssh-1' }], + ['paired runtime', { executionHostId: 'runtime:paired-1' as const }], + ['WSL', { windowsRuntime: 'wsl' as const }] + ])('keeps %s Codex panes on the transcript bridge', async (_label, overrides) => { + const { tab, state } = terminalState(overrides) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') + + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledWith(tab.id, { viewMode: 'chat' }) + }) + + it('retries a failed initial handoff with its original operation id', async () => { + const { tab, state } = terminalState({}) + const patch = vi.fn((tabId: string, next: Record) => { + if (tabId === tab.id) { + Object.assign(tab, next) + } + }) + let originalOperationId = '' + let startSent = false + let retrySent = false + vi.mocked(callStructuredAgentSession).mockImplementation( + async (_target, method, params: unknown) => { + const request = params as { + action?: string + envelope?: { clientOperationId?: string } + } + if (method === 'agentSession.adoptTerminal') { + return { ok: true, fence: 1 } as never + } + if (method === 'agentSession.history') { + return { ok: true, page: { fence: 2 } } as never + } + if (method === 'agentSession.handoff') { + if (request.action === 'start') { + originalOperationId = request.envelope?.clientOperationId ?? '' + startSent = true + } else { + expect(request).toMatchObject({ + action: 'retry', + envelope: { clientOperationId: originalOperationId } + }) + retrySent = true + } + return { ok: true } as never + } + if (method === 'agentSession.handoffStatus') { + return ( + retrySent + ? { owner: 'native', direction: null, phase: 'idle' } + : !startSent + ? { owner: 'tui', direction: null, phase: 'idle' } + : { + owner: 'tui', + direction: 'to-native', + phase: 'failed', + operationId: originalOperationId, + error: { message: 'native proof unavailable', recoverableOwner: 'tui' } + } + ) as never + } + throw new Error(`Unexpected method: ${method}`) + } + ) + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('ignored') + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('structured') + + expect(originalOperationId).not.toBe('') + expect(tab).toMatchObject({ structuredSessionId: 'codex_thread-codex' }) + expect(patch).toHaveBeenLastCalledWith(tab.id, { + structuredSessionId: 'codex_thread-codex', + viewMode: 'chat' + }) + }) + + it('clears a stale Codex binding before routing another agent to the bridge', async () => { + const { tab, state } = terminalState({ + agent: 'claude', + structuredSessionId: 'codex_old-thread' + }) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') + + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledTimes(1) + const [patchedTabId, bridgePatch] = patch.mock.calls[0] as [string, Record] + expect(patchedTabId).toBe(tab.id) + expect(bridgePatch.viewMode).toBe('chat') + // Deep equality treats an absent key as undefined, so presence is the only proof it was cleared. + expect(Object.keys(bridgePatch)).toContain('structuredSessionId') + expect(bridgePatch.structuredSessionId).toBeUndefined() + }) + + it('keeps a Codex binding when the same agent falls back to the bridge', async () => { + const { tab, state } = terminalState({ + agent: 'codex', + structuredSessionId: 'codex_thread-codex', + connectionId: 'ssh-1' + }) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') + + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledTimes(1) + const [, codexBridgePatch] = patch.mock.calls[0] as [string, Record] + expect(Object.keys(codexBridgePatch)).not.toContain('structuredSessionId') + }) + + it('names the terminal direction when returning ownership fails', async () => { + const { tab, state } = terminalState({ structuredSessionId: 'codex_thread-codex' }) + vi.mocked(callStructuredAgentSession).mockRejectedValueOnce(new Error('history unavailable')) + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch: vi.fn(), + tabId: tab.id, + mode: 'terminal' + }) + ).resolves.toBe('ignored') + + expect(mockToastError).toHaveBeenCalledWith( + 'Could not return this Codex session to the terminal', + { description: 'history unavailable' } + ) + }) + + it('adopts from trusted foreground Codex evidence before hooks publish a thread', async () => { + const tab = { + id: 'tab-foreground', + entityId: 'terminal-foreground', + worktreeId: 'workspace-1', + contentType: 'terminal' as const + } + const paneKey = 'terminal-foreground:leaf-1' + const state = { + unifiedTabsByWorktree: { 'workspace-1': [tab] }, + tabsByWorktree: { 'workspace-1': [{ id: 'terminal-foreground' }] }, + terminalLayoutsByTabId: { + 'terminal-foreground': { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': 'pty-foreground' } + } + }, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: { + [paneKey]: { agent: 'codex', shellForeground: false, routingTrusted: true } + } + } + vi.mocked(callStructuredAgentSession) + .mockResolvedValueOnce({ ok: true, fence: 1 }) + .mockRejectedValueOnce(new Error('handoff failed')) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('ignored') + expect(vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2]).not.toHaveProperty('threadId') + expect(patch).toHaveBeenCalledWith( + tab.id, + expect.objectContaining({ structuredSessionId: expect.stringMatching(/^codex_adopt-/) }) + ) + }) +}) diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.ts new file mode 100644 index 000000000000..828d3ed01a5b --- /dev/null +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.ts @@ -0,0 +1,268 @@ +import { toast } from 'sonner' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + AgentSessionAttachResult, + AgentSessionHistoryResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult +} from '../../../../shared/agent-session-wire' +import type { Tab } from '../../../../shared/tab-types' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../../shared/structured-agent-session-mutation' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { translate } from '@/i18n/i18n' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' +import type { AppState } from '../types' + +export type NativeChatRoute = 'structured' | 'bridge' + +export function nativeChatRouteForAgent(agent: AgentType | null): NativeChatRoute { + return agent === 'codex' ? 'structured' : 'bridge' +} + +export function nativeChatRouteForTerminal(input: { + agent: AgentType | null + structuredSessionId?: string + mode: 'terminal' | 'chat' + structuredAvailable?: boolean +}): NativeChatRoute { + if (input.structuredAvailable === false) { + return 'bridge' + } + if (input.structuredSessionId) { + return input.agent === null || input.agent === 'codex' ? 'structured' : 'bridge' + } + return input.mode === 'chat' ? nativeChatRouteForAgent(input.agent) : 'bridge' +} + +function activeTerminalFacts(state: AppState, tab: Tab) { + const terminal = (state.tabsByWorktree[tab.worktreeId] ?? []).find( + (candidate) => candidate.id === tab.entityId + ) + const layout = state.terminalLayoutsByTabId[tab.entityId] + const leafId = layout?.activeLeafId ?? null + const paneKey = leafId ? `${tab.entityId}:${leafId}` : null + const status = paneKey ? state.agentStatusByPaneKey[paneKey] : undefined + const foreground = paneKey ? state.paneForegroundAgentByPaneKey?.[paneKey] : undefined + const foregroundAgent = + foreground?.agent && !foreground.shellForeground && !foreground.routingRevoked + ? foreground.agent + : null + const agent = + status?.agentType ?? + foregroundAgent ?? + terminal?.launchAgent ?? + resolveCommittedTitleAgentType(tab.label) ?? + resolveCommittedTitleAgentType(terminal?.title ?? '') + const ptyId = leafId ? layout?.ptyIdsByLeafId?.[leafId] : null + return { + agent: agent ?? null, + paneKey, + ptyId: ptyId ?? null, + threadId: status?.providerSession?.id + } +} + +function adoptedSessionId(threadId: string): string { + return `codex_${threadId.replaceAll(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 128) +} + +function operationId(): string { + return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) +} + +async function currentFence(sessionId: string): Promise { + const history = await callStructuredAgentSession( + { kind: 'local' }, + 'agentSession.history', + { sessionId, direction: 'tail', limit: 1 } + ) + const fence = history.ok ? history.page.fence : history.fence + if (!fence) { + throw new Error('Structured Codex chat did not publish an ownership fence.') + } + return fence +} + +async function waitForOwner( + sessionId: string, + owner: 'native' | 'tui' +): Promise { + for (let attempt = 0; attempt < 300; attempt += 1) { + const status = await callStructuredAgentSession( + { kind: 'local' }, + 'agentSession.handoffStatus', + { sessionId } + ) + if (status.phase === 'failed') { + return status + } + if (status.owner === owner && status.phase === 'idle') { + return status + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('Codex ownership transfer did not finish.') +} + +async function requestHandoff( + sessionId: string, + direction: 'to-native' | 'to-tui', + fence: number, + clientOperationId: string, + action: 'start' | 'retry' +): Promise { + const fields = { direction, mode: 'after-turn' as const, action } + const result = await callStructuredAgentSession>( + { kind: 'local' }, + 'agentSession.handoff', + { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId, + fields + }) + }, + ...fields + } + ) + if (!result.ok) { + throw new Error(result.refusal.message) + } +} + +const pendingTabs = new Set() + +async function recoverableHandoffOperation( + sessionId: string, + direction: 'to-native' | 'to-tui' +): Promise { + const status = await callStructuredAgentSession( + { kind: 'local' }, + 'agentSession.handoffStatus', + { sessionId } + ) + return status.phase === 'failed' && + status.direction === direction && + status.operationId && + status.error?.recoverableOwner !== 'none' + ? status.operationId + : null +} + +export async function setTerminalNativeChatMode(input: { + getState: () => AppState + patch: (tabId: string, patch: Partial) => void + tabId: string + mode: 'terminal' | 'chat' +}): Promise<'structured' | 'bridge' | 'ignored'> { + if (pendingTabs.has(input.tabId)) { + return 'ignored' + } + const tab = Object.values(input.getState().unifiedTabsByWorktree) + .flat() + .find((candidate) => candidate.id === input.tabId) + if (!tab || tab.contentType !== 'terminal') { + return 'ignored' + } + const facts = activeTerminalFacts(input.getState(), tab) + const structuredAvailable = canUseStructuredNativeChat(input.getState(), tab.worktreeId) + if ( + nativeChatRouteForTerminal({ + agent: facts.agent, + structuredSessionId: tab.structuredSessionId, + mode: input.mode, + structuredAvailable + }) === 'bridge' + ) { + input.patch(tab.id, { + viewMode: input.mode, + ...(tab.structuredSessionId && facts.agent !== 'codex' + ? { structuredSessionId: undefined } + : {}) + }) + return 'bridge' + } + pendingTabs.add(tab.id) + try { + let sessionId = tab.structuredSessionId + let fence: number + if (!sessionId) { + if (!facts.paneKey || !facts.ptyId) { + throw new Error('Codex has not published a resumable terminal pane yet.') + } + sessionId = facts.threadId + ? adoptedSessionId(facts.threadId) + : adoptedSessionId(`adopt-${crypto.randomUUID()}`) + const worktree = `id:${tab.worktreeId}` + const fields = { + worktree, + tabId: tab.entityId, + paneKey: facts.paneKey, + ptyId: facts.ptyId, + ...(facts.threadId ? { threadId: facts.threadId } : {}) + } + const adopted = await callStructuredAgentSession< + AgentSessionMutationResult + >({ kind: 'local' }, 'agentSession.adoptTerminal', { + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.adoptTerminal', + sessionId, + fields + }) + }, + ...fields + }) + if (!adopted.ok) { + throw new Error(adopted.refusal.message) + } + // Keep the durable binding retryable if the following ownership transfer fails. + input.patch(tab.id, { structuredSessionId: sessionId }) + fence = adopted.fence + } else { + fence = await currentFence(sessionId) + } + const direction = input.mode === 'chat' ? 'to-native' : 'to-tui' + const recoverableOperationId = await recoverableHandoffOperation(sessionId, direction) + const handoffOperationId = recoverableOperationId ?? operationId() + await requestHandoff( + sessionId, + direction, + fence, + handoffOperationId, + recoverableOperationId ? 'retry' : 'start' + ) + const status = await waitForOwner(sessionId, input.mode === 'chat' ? 'native' : 'tui') + if (status.phase === 'failed') { + throw new Error(status.error?.message ?? 'Codex ownership transfer failed.') + } + input.patch(tab.id, { structuredSessionId: sessionId, viewMode: input.mode }) + return 'structured' + } catch (error) { + toast.error( + translate( + input.mode === 'chat' + ? 'components.native-chat.structuredAdoptionFailed' + : 'components.native-chat.structuredReturnToTerminalFailed', + input.mode === 'chat' + ? 'Could not switch this Codex session to structured chat' + : 'Could not return this Codex session to the terminal' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + return 'ignored' + } finally { + pendingTabs.delete(tab.id) + } +} diff --git a/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts new file mode 100644 index 000000000000..969b3a50c7f4 --- /dev/null +++ b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts @@ -0,0 +1,112 @@ +/** + * Both ways into structured chat go through one door. + * + * Right-click → "Switch to chat" and launching a Codex tab with open-in-chat both end at + * `setTabViewMode(tabId, 'chat')`, which hands a terminal tab to `setTerminalNativeChatMode` and + * from there to `agentSession.adoptTerminal`. Pinning that here is what makes "launch-at-chat + * shares the adoption defect" a checked claim rather than an inspection of the call graph. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { createTestStore, makeUnifiedTab, makeTabGroup } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +const runtimeCall = vi.fn() + +const mockApi = { + ui: { set: vi.fn().mockResolvedValue(undefined) }, + settings: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) }, + runtime: { call: runtimeCall } +} + +// @ts-expect-error -- partial window stub is sufficient for these store-only tests +globalThis.window = { api: mockApi } + +const WT = 'repo1::/tmp/feature' + +describe('structured chat route out of setTabViewMode', () => { + let store: ReturnType + + beforeEach(() => { + let handoffStarted = false + runtimeCall.mockReset() + runtimeCall.mockImplementation(async ({ method }: { method: string }) => { + if (method === 'agentSession.adoptTerminal') { + return { ok: true, result: { ok: true, fence: 1 } } + } + if (method === 'agentSession.handoff') { + handoffStarted = true + return { ok: true, result: { ok: true } } + } + if (method === 'agentSession.handoffStatus') { + return { + ok: true, + result: handoffStarted + ? { owner: 'native', direction: null, phase: 'idle' } + : { owner: 'tui', direction: null, phase: 'idle' } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + store = createTestStore() + store.setState({ + activeRepoId: 'repo1', + activeWorktreeId: WT, + projects: [{ id: 'repo1' }], + repos: [{ id: 'repo1', path: '/tmp/repo', connectionId: null }], + worktreesByRepo: { + repo1: [{ id: WT, repoId: 'repo1', path: '/tmp/feature', hostId: 'local' }] + }, + detectedWorktreesByRepo: {}, + settings: {}, + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'codex-tab', worktreeId: WT, groupId: 'g-1' })] + }, + tabsByWorktree: { [WT]: [{ id: 'codex-tab', launchAgent: 'codex' }] }, + terminalLayoutsByTabId: { + 'codex-tab': { + activeLeafId: 'leaf-adopt', + ptyIdsByLeafId: { 'leaf-adopt': 'pty-adopt' } + } + }, + agentStatusByPaneKey: { + 'codex-tab:leaf-adopt': { + agentType: 'codex', + providerSession: { id: 'thread-adopt' } + } + }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: 'g-1', + worktreeId: WT, + activeTabId: 'codex-tab', + tabOrder: ['codex-tab'] + }) + ] + } + } as unknown as Partial) + }) + + it('hands an explicit chat mode to the structured adoption path', async () => { + store.getState().setTabViewMode('codex-tab', 'chat') + + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'agentSession.adoptTerminal' }) + ) + ) + }) + + it('hands a toggled chat mode to the same path', async () => { + store.getState().toggleTabViewMode('codex-tab') + + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'agentSession.adoptTerminal' }) + ) + ) + }) +}) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 2e382765a480..76465969cc43 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -46,6 +46,7 @@ import { buildValidWorktreeIdsForSessionHydration, collectPersistedWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' +import { setTerminalNativeChatMode } from './structured-native-chat-toggle' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -1297,42 +1298,53 @@ export const createTabsSlice: StateCreator = (set, }, setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) - mirrorTabViewModeToHost(get(), tabId, mode) + const found = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) + if (!found || found.tab.contentType !== 'terminal') { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + return + } + void setTerminalNativeChatMode({ + getState: get, + tabId, + mode, + patch: (id, patch) => set((state) => patchTab(state.unifiedTabsByWorktree, id, patch) ?? {}) + }).then((result) => { + if (result === 'bridge') { + mirrorTabViewModeToHost(get(), tabId, mode) + } + }) }, toggleTabViewMode: (tabId) => { - let toggled: { + const found = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) + if (!found) { + return + } + const toggled: { from: 'terminal' | 'chat' to: 'terminal' | 'chat' agent: TuiAgent | null - } | null = null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. - const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' - const nextMode = fromMode === 'chat' ? 'terminal' : 'chat' - // Why: launchAgent lives on the legacy terminal tab (keyed by entityId); resolve it here so toggle telemetry can attribute by agent. - const agent = - (state.tabsByWorktree[found.worktreeId] ?? []).find( + } = { + from: found.tab.viewMode === 'chat' ? 'chat' : 'terminal', + to: found.tab.viewMode === 'chat' ? 'terminal' : 'chat', + agent: + (get().tabsByWorktree[found.worktreeId] ?? []).find( (terminal) => terminal.id === found.tab.entityId )?.launchAgent ?? null - toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} - }) - // Why: emit after the state write so the event reflects the committed mode. - const committed = toggled as { - from: 'terminal' | 'chat' - to: 'terminal' | 'chat' - agent: TuiAgent | null - } | null - if (committed) { - emitNativeChatToggled(committed) - mirrorTabViewModeToHost(get(), tabId, committed.to) } + void setTerminalNativeChatMode({ + getState: get, + tabId, + mode: toggled.to, + patch: (id, patch) => set((state) => patchTab(state.unifiedTabsByWorktree, id, patch) ?? {}) + }).then((result) => { + if (result !== 'ignored') { + emitNativeChatToggled(toggled) + } + if (result === 'bridge') { + mirrorTabViewModeToHost(get(), tabId, toggled.to) + } + }) }, setRenamingTabId: (tabId) => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 2bd756dcc824..76f767d25673 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -327,6 +327,11 @@ export type AgentHookEventPayload = { source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** One-shot nonce Orca minted for a single agent invocation, stamped into that + * process's env only. Unlike `launchToken` (pane-lifetime identity that + * authority attestation matches against) this is per-launch and disposable, so + * it can bind a specific spawn without rewriting the pane's launch identity. */ + sessionNonce?: string tabId?: string worktreeId?: string /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop @@ -4401,6 +4406,7 @@ export function normalizeHookPayload( } const worktreeId = readStringField(record, 'worktreeId') const launchToken = readStringField(record, 'launchToken') + const sessionNonce = readStringField(record, 'sessionNonce') const hookPayloadRecord = hookPayload as Record if (source === 'claude') { @@ -4604,6 +4610,7 @@ export function normalizeHookPayload( paneKey, source, launchToken, + ...(sessionNonce ? { sessionNonce } : {}), tabId, worktreeId, // Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here. diff --git a/src/shared/agent-hook-session-nonce.ts b/src/shared/agent-hook-session-nonce.ts new file mode 100644 index 000000000000..a87dcf0b9d44 --- /dev/null +++ b/src/shared/agent-hook-session-nonce.ts @@ -0,0 +1,7 @@ +// Why: a resumed Codex TUI must prove itself off-screen. Orca stamps this one-shot +// nonce into the single `codex resume` invocation it types, and the managed hook +// echoes it back as the `sessionNonce` form field, so a stale SessionStart from an +// earlier process in the same pane cannot pass for the new one. Deliberately NOT +// `launchToken`: that is pane-lifetime identity and authority attestation matches +// against it, so rotating it per resume would break orchestration dispatch. +export const AGENT_HOOK_SESSION_NONCE_ENV_VAR = 'ORCA_AGENT_SESSION_NONCE' diff --git a/src/shared/inline-env-command-prefix.test.ts b/src/shared/inline-env-command-prefix.test.ts new file mode 100644 index 000000000000..8f5b3bd6f7b4 --- /dev/null +++ b/src/shared/inline-env-command-prefix.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { withInlineEnvAssignments } from './inline-env-command-prefix' + +const ENV = { ORCA_AGENT_SESSION_NONCE: 'nonce-1' } + +describe('inline env assignments for a typed command', () => { + it('leaves the command untouched when there is nothing to assign', () => { + expect( + withInlineEnvAssignments({ command: 'codex resume x', env: {}, platform: 'darwin' }) + ).toBe('codex resume x') + }) + + // Why one POSIX form rather than a fish dialect: Orca cannot reliably detect the login shell of a + // remote or WSL host, so `AgentStartupShell` deliberately has no fish member. The single emitted + // form has to parse in every Unix shell instead, which is what this pins. + it('uses env(1) on POSIX so fish parses it too', () => { + expect( + withInlineEnvAssignments({ + command: "codex resume 'thread-1'", + env: ENV, + platform: 'linux', + shell: 'posix' + }) + // Why not `NAME=value cmd`: fish rejects that form outright. + ).toBe("env ORCA_AGENT_SESSION_NONCE='nonce-1' codex resume 'thread-1'") + }) + + it('emits that same fish-safe form when no shell is given', () => { + expect( + withInlineEnvAssignments({ command: 'codex resume x', env: ENV, platform: 'darwin' }) + ).toBe("env ORCA_AGENT_SESSION_NONCE='nonce-1' codex resume x") + }) + + it('uses PowerShell and cmd assignment syntax on Windows', () => { + expect( + withInlineEnvAssignments({ + command: 'codex resume thread-1', + env: ENV, + platform: 'win32', + shell: 'powershell' + }) + ).toBe("$env:ORCA_AGENT_SESSION_NONCE='nonce-1'; codex resume thread-1") + expect( + withInlineEnvAssignments({ + command: 'codex resume thread-1', + env: ENV, + platform: 'win32', + shell: 'cmd' + }) + ).toBe('set "ORCA_AGENT_SESSION_NONCE=nonce-1" && codex resume thread-1') + }) + + it('quotes values for the target dialect', () => { + expect( + withInlineEnvAssignments({ + command: 'codex', + env: { N: "it's" }, + platform: 'darwin', + shell: 'posix' + }) + // Why this idiom and not `'\''`: closing, adding a double-quoted quote, then reopening parses + // in fish too, which does not honour a backslash escape inside single quotes. + ).toContain(`N='it'"'"'s'`) + expect( + withInlineEnvAssignments({ + command: 'codex', + env: { N: "it's" }, + platform: 'win32', + shell: 'powershell' + }) + ).toContain(`$env:N='it''s'`) + }) +}) diff --git a/src/shared/inline-env-command-prefix.ts b/src/shared/inline-env-command-prefix.ts new file mode 100644 index 000000000000..e471d77a1cbd --- /dev/null +++ b/src/shared/inline-env-command-prefix.ts @@ -0,0 +1,46 @@ +// Env assignments for a command Orca TYPES into a shell it already owns, rather +// than one it spawns with an env map. The adopted-Codex return path has no spawn +// to attach env to: the pane is a live shell and the resume command is keystrokes. +import { + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' + +export function buildInlineEnvCommandPrefix(args: { + env: Record + platform: NodeJS.Platform + shell?: AgentStartupShell +}): string { + const entries = Object.entries(args.env) + if (entries.length === 0) { + return '' + } + const shell = resolveStartupShell(args.platform, args.shell) + const quote = (value: string): string => quoteStartupArg(value, shell) + if (shell === 'powershell') { + return `${entries.map(([name, value]) => `$env:${name}=${quote(value)}`).join('; ')}; ` + } + if (shell === 'cmd') { + // Why: `set` here outlives the command, unlike the POSIX one-shot form. Safe + // because every caller overwrites the same names on its next invocation, so a + // leftover value can only ever fail a match — never satisfy a fresh one. + return `${entries.map(([name, value]) => `set ${quote(`${name}=${value}`)}`).join(' && ')} && ` + } + // Why `env` rather than the bare `NAME=value cmd` prefix: fish rejects that form + // outright ("Unsupported use of '='"), and the pane's POSIX dialect is not + // recorded anywhere Orca can read at write time. `env` parses in sh, bash, zsh + // and fish alike, and matches how every other Orca launch path reaches the agent + // binary — through the executable, not a shell alias. + return `env ${entries.map(([name, value]) => `${name}=${quote(value)}`).join(' ')} ` +} + +/** Prefixes a typed command so the assignments apply to that invocation. */ +export function withInlineEnvAssignments(args: { + command: string + env: Record + platform: NodeJS.Platform + shell?: AgentStartupShell +}): string { + return `${buildInlineEnvCommandPrefix(args)}${args.command}` +} diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index a788f246bab1..5f2b74d71274 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -70,6 +70,8 @@ export type Tab = { isPinned?: boolean // pinned tabs survive "close others" /** Provider backing a structured agent-session tab. */ agentSessionAgent?: AgentType + /** Structured session adopted from this terminal's Codex TUI. */ + structuredSessionId?: string /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw 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 47b981707c0c..ea10aa02bedb 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 @@ -47,6 +47,7 @@ const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, { method: 'agentSession.setOption', hostMethod: 'setOption' }, { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' }, + { method: 'agentSession.handoff', hostMethod: 'requestHandoff' }, { method: 'agentSession.options', hostMethod: 'readOptions' }, { method: 'agentSession.history', hostMethod: 'history' }, { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, @@ -152,6 +153,13 @@ function paramsFor(method: string): unknown { const fields = { key: 'model', value: 'gpt-5' } return { envelope: envelope({ method, fields, fence }), ...fields } } + case 'agentSession.handoff': { + const fields = { direction: 'to-native', mode: 'after-turn', action: 'start' } + return { + envelope: envelope({ method: 'agentSession.requestHandoff', fields, fence }), + ...fields + } + } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } default: @@ -298,7 +306,7 @@ describe('cross-version structured agent sessions', () => { it('finds no structured method registered on the old build', () => { expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( - STRUCTURED_CALLS.length + 1 + STRUCTURED_CALLS.length + 2 ) })