From 6b5e0b6c459a1a8b1a003128c1dc25b277fd7cf8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:30:13 -0700 Subject: [PATCH 1/9] feat(native-chat): adopt Codex terminal sessions --- .../codex/codex-app-server-connection.test.ts | 17 + src/main/codex/codex-app-server-connection.ts | 2 +- ...dex-structured-journal-translation.test.ts | 33 ++ .../codex-structured-journal-translation.ts | 18 + ...codex-structured-launch-resolution.test.ts | 24 +- .../codex-structured-launch-resolution.ts | 13 +- .../codex/codex-structured-owner-identity.ts | 3 +- .../codex-structured-session-adapter.test.ts | 11 +- .../codex/codex-structured-session-adapter.ts | 1 + .../codex/codex-structured-session-state.ts | 1 + .../codex/codex-structured-thread-open.ts | 14 +- .../codex/codex-tui-rollout-proof.test.ts | 27 ++ src/main/codex/codex-tui-rollout-proof.ts | 42 +++ .../structured-adopted-tui-owner.ts | 25 ++ ...tructured-agent-session-handoff-reverse.ts | 15 +- .../structured-agent-session-handoff-state.ts | 46 +++ .../structured-agent-session-handoff-types.ts | 10 +- .../structured-agent-session-handoff.test.ts | 55 ++-- .../structured-agent-session-handoff.ts | 53 +-- .../structured-agent-session-host.ts | 27 +- ...uctured-agent-session-tui-adoption.test.ts | 124 +++++++ .../structured-agent-session-tui-adoption.ts | 129 ++++++++ .../runtime/adopted-codex-tui-stop.test.ts | 68 ++++ src/main/runtime/adopted-codex-tui-stop.ts | 60 ++++ ...runtime-structured-tui-tab-binding.test.ts | 173 ++++++++++ src/main/runtime/orca-runtime.ts | 307 +++++++++++++++++- .../structured-agent-session-schemas.ts | 11 + .../methods/structured-agent-session.test.ts | 81 ++++- .../rpc/methods/structured-agent-session.ts | 37 +++ .../NativeChatStructuredSession.tsx | 2 + .../native-chat-leaf-routing.test.ts | 27 ++ .../native-chat/native-chat-leaf-routing.ts | 17 +- .../native-chat/native-chat-view-types.ts | 1 + .../native-chat-tab-agent-evidence.test.ts | 35 ++ .../tab-bar/native-chat-tab-agent-evidence.ts | 17 + .../tab-bar/tab-agent-types-by-tab-id.test.ts | 23 ++ .../tab-bar/tab-agent-types-by-tab-id.ts | 34 +- .../tab-bar/tab-bar-item-surface.tsx | 6 +- .../components/terminal-pane/TerminalPane.tsx | 115 ++++--- .../terminal-tab-agent-type-index.test.ts | 26 ++ .../terminal-tab-agent-type-index.ts | 33 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../structured-native-chat-toggle.test.ts | 124 +++++++ .../slices/structured-native-chat-toggle.ts | 222 +++++++++++++ src/renderer/src/store/slices/tabs.ts | 66 ++-- src/shared/tab-types.ts | 2 + ...ss-version-agent-session-wire.unit.test.ts | 10 +- 51 files changed, 2020 insertions(+), 172 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-adopted-tui-owner.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts create mode 100644 src/main/runtime/adopted-codex-tui-stop.test.ts create mode 100644 src/main/runtime/adopted-codex-tui-stop.ts create mode 100644 src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts create mode 100644 src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.ts create mode 100644 src/renderer/src/store/slices/structured-native-chat-toggle.test.ts create mode 100644 src/renderer/src/store/slices/structured-native-chat-toggle.ts diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index e226e22f5180..3d8ff25f9221 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 8058ed95f2ee..f9cd1f662b75 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -333,7 +333,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-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 64127efcf587..7141141e0800 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'] @@ -23,6 +24,7 @@ export type CodexStructuredLaunchResolverDeps = { resolveWorkspacePath: (workspaceId: string) => Promise /** Overridden in tests; production scans PATH and version-manager dirs. */ resolveCommand?: () => string + resolveRollout?: typeof resolvePinnedCodexRolloutProof } export function createCodexStructuredLaunchResolver( @@ -51,6 +53,7 @@ export function createCodexStructuredLaunchResolver( const command = (deps.resolveCommand ?? resolveCodexCommand)() 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, @@ -59,7 +62,15 @@ export function createCodexStructuredLaunchResolver( ...(record.launchEnv ? { env: { ...record.launchEnv } } : {}), // 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/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-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 86c3fd1ea84d..52b89dafb5aa 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 @@ -56,6 +56,10 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + adoptStructuredTuiOwner, + type StructuredTuiAdoptionRequest +} from './structured-agent-session-tui-adoption' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -116,21 +120,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 () => { @@ -199,9 +200,11 @@ export class StructuredAgentSessionHost { return this.tasks.trackAttach(attaching) } - flushStreamedEvents(sessionId: string): Promise { - return this.runtimeState.flushEventSink(sessionId) - } + // oxfmt-ignore + 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.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts new file mode 100644 index 000000000000..82fb20e6f151 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.test.ts @@ -0,0 +1,124 @@ +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 }) +}) + +describe('structured TUI adoption', () => { + it('mints a durable TUI owner without starting a native provider', async () => { + const adoptedOwner = owner() + + const result = await host.adoptTuiOwner({ + caller: CALLER, + params: params(), + owner: adoptedOwner, + claimKeyId: 'key-1' + }) + + 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(), + claimKeyId: 'key-1' + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + 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..d9b4cc1a410f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts @@ -0,0 +1,129 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { + admitAttachOrRefuse, + attachJournal, + reserveRequestFor +} 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' + +export type StructuredTuiAdoptionRequest = { + caller: StructuredAgentSessionCaller + params: AgentSessionAttachParams + owner: StructuredTuiOwner + claimKeyId: string + launchEnv?: Record +} + +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) + let replayed = record !== null + if (!record) { + const reserved = await input.deps.store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: { + spawnToken: owner.process.spawnToken, + claimKeyId: input.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: { outcome: 'reservation-unused' }, + ...(input.launchEnv ? { launchEnv: input.launchEnv } : {}) + }, + callerKey: input.caller.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) + replayed = reserved.disposition === 'replayed' + record = reserved.record + await input.deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: input.now() + }) + record = await input.deps.store.proveOwner({ + sessionId, + fence: record.lease.runtimeFence, + link: owner.link, + now: input.now() + }) + } else { + assertMatchingTuiOwner(record, owner) + } + 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 + } + } +} + +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/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index fa44892277f3..98575cb051c6 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,50 @@ 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 + resolveStructuredAgentSessionCreateIntent(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 } + })) + internal.resolveStructuredAgentSessionCreateIntent = 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 +} + function notifier(revealTerminalSession: ReturnType) { return { worktreesChanged: vi.fn(), @@ -54,6 +102,131 @@ 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({ adoptTuiOwner } as never) + resolvePinnedCodexRolloutProof.mockResolvedValueOnce('/tmp/codex-home/rollout.jsonl') + 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' } + ) + ).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') + } finally { + agentSessionPtyWriteGate.unbindPty('pty-adopt') + setStructuredAgentSessionHost(null) + } + }) + + it('refuses adoption when the provider rollout cannot be proved', async () => { + const runtime = terminalAdoptionHarness() + resolvePinnedCodexRolloutProof.mockResolvedValueOnce(null) + + 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('Could not find the rollout') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + }) + + 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({ adoptTuiOwner } as never) + resolveLiveCodexTuiRollout.mockResolvedValueOnce({ + threadId: 'thread-discovered', + transcriptPath: '/tmp/codex-home/discovered.jsonl' + }) + readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) + + 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 e4b01ae92516..be56eddc102d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -81,6 +81,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, @@ -95,6 +101,7 @@ import { import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import { proveCodexTuiRollout, + resolveLiveCodexTuiRollout, resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { @@ -102,6 +109,7 @@ 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 { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' @@ -3023,6 +3031,7 @@ 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 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. @@ -10316,6 +10325,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') @@ -10380,7 +10399,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 { @@ -10608,7 +10628,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, @@ -10692,6 +10715,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 { @@ -10706,6 +10733,132 @@ 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 || + !this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${startup.launchCommand}\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 proof = await this.waitForAdoptedStructuredTuiProof({ + owner, + threadId: head.handle.threadId, + codexHome: input.record.accountHome.path, + sessionId: input.record.sessionId, + spawnToken: input.spawnToken + }) + const proved = { ...owner, transcriptPath: proof.transcriptPath } + this.adoptedStructuredTuiOwners.set(input.record.sessionId, proved) + return proved + } + + private async waitForAdoptedStructuredTuiProof(input: { + owner: StructuredTuiOwner + threadId: string + codexHome: string + sessionId: string + spawnToken: string + }): Promise<{ transcriptPath: string }> { + const readPty = (): RuntimePtyWorktreeRecord => { + 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.') + } + return pty + } + const pty = readPty() + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(pty.ptyId)?.flags ?? 0, + readOutput: () => { + const current = readPty() + return { + text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview), + lastOutputAt: current.lastOutputAt + } + }, + write: (data) => + this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + }) + } + + 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) { @@ -10942,6 +11095,156 @@ export class OrcaRuntimeService { } } + async adoptStructuredAgentSessionTerminal( + input: { + envelope: { + sessionId: string + clientOperationId: string + expectedRuntimeFence: null + payloadFingerprint: string + } + worktree: string + tabId: string + paneKey: string + ptyId: string + threadId?: string + }, + 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.resolveStructuredAgentSessionCreateIntent({ + envelope: input.envelope, + worktree: input.worktree, + agent: '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('Could not prove the Codex terminal process.') + } + const spawnToken = randomUUID() + const process = await readStructuredTuiProcessIdentity({ + hostId: intent.location.executionHostId, + rootPid: listing.rootProcessId, + spawnToken, + agent: 'codex' + }) + let threadId = input.threadId + let transcriptPath = threadId + ? await resolvePinnedCodexRolloutProof(intent.accountHome.path, threadId) + : null + if (!threadId) { + 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 proof = await resolveLiveCodexTuiRollout({ + 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) => + this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { + sessionId: input.envelope.sessionId, + spawnToken + }) ?? false + }) + threadId = proof.threadId + transcriptPath = proof.transcriptPath + } + if (!transcriptPath) { + throw new Error('Could not find the rollout for this Codex conversation.') + } + 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', + fence: 1, + 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 launchEnv = resolveTuiAgentLaunchEnv( + 'codex', + this.requireStore().getSettings().agentDefaultEnv + ) + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + const adopted = await host.adoptTuiOwner({ + caller, + params, + owner, + claimKeyId: this.agentSessionClaimSigner.keyId, + launchEnv + }) + if (adopted.ok) { + this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner) + agentSessionPtyWriteGate.bindPty(input.ptyId, input.envelope.sessionId) + } + return adopted + } + private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) 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/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/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 92084da29cc1..ad30eb497f3a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -514,6 +514,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( @@ -525,7 +530,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) @@ -643,11 +652,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. @@ -2913,7 +2929,8 @@ function TerminalPane( chatLeafId, activeLeafId, chatLeafStillMounted, - activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), + structuredSessionId }) applyNativeChatLeafRoute(route) }, [ @@ -2922,7 +2939,8 @@ function TerminalPane( activePane?.leafId, chatLeafStillMounted, applyNativeChatLeafRoute, - isChatEligibleForLeaf + isChatEligibleForLeaf, + structuredSessionId ]) const chatPane = isChatViewMode && chatLeafId @@ -3080,44 +3098,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 8913dc94d5b8..5fb4ddd10d83 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15832,6 +15832,7 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "Could not switch this Codex session to structured chat", "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 c3c6768dbd98..fd3df65b3c3e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14324,6 +14324,7 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "No se pudo cambiar esta sesión de Codex al chat estructurado", "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 6b3aa3ba9f8c..373520ba6d6e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14324,6 +14324,7 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "この Codex セッションを構造化チャットに切り替えられませんでした", "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", "send": "送信", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5e67a56c55eb..812531971cb5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14353,6 +14353,7 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "이 Codex 세션을 구조화된 채팅으로 전환할 수 없습니다", "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", "send": "보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f060a9c7529e..0a1332518cb6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14344,6 +14344,7 @@ }, "components": { "native-chat": { + "structuredAdoptionFailed": "无法将此 Codex 会话切换到结构化聊天", "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", "send": "发送", 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..42c7b31efaf0 --- /dev/null +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { + nativeChatRouteForAgent, + nativeChatRouteForTerminal, + setTerminalNativeChatMode +} from './structured-native-chat-toggle' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +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('retains the durable binding when the first handoff attempt fails', async () => { + const tab = { + id: 'tab-1', + entityId: 'terminal-1', + worktreeId: 'workspace-1', + contentType: 'terminal' as const + } + const state = { + unifiedTabsByWorktree: { 'workspace-1': [tab] }, + tabsByWorktree: { 'workspace-1': [{ id: 'terminal-1', launchAgent: 'codex' }] }, + terminalLayoutsByTabId: { + 'terminal-1': { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': 'pty-1' } + } + }, + agentStatusByPaneKey: { + 'terminal-1:leaf-1': { + agentType: 'codex', + providerSession: { id: 'thread-1' } + } + } + } + vi.mocked(callStructuredAgentSession) + .mockResolvedValueOnce({ ok: true, fence: 1 }) + .mockRejectedValueOnce(new Error('method_not_found')) + const patch = vi.fn() + + const result = await setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + + expect(result).toBe('ignored') + expect(patch).toHaveBeenCalledTimes(1) + expect(patch).toHaveBeenCalledWith(tab.id, { + structuredSessionId: 'codex_thread-1' + }) + }) + + it('adopts from trusted foreground Codex evidence before hooks publish a thread', async () => { + vi.mocked(callStructuredAgentSession).mockReset() + 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..31b20f18fb15 --- /dev/null +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.ts @@ -0,0 +1,222 @@ +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 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' +}): NativeChatRoute { + if (input.structuredSessionId) { + return 'structured' + } + 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') { + throw new Error(status.error?.message ?? 'Codex ownership transfer failed.') + } + 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 +): Promise { + const fields = { direction, mode: 'after-turn' as const, action: 'start' as const } + const result = await callStructuredAgentSession>( + { kind: 'local' }, + 'agentSession.handoff', + { + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId, + fields + }) + }, + ...fields + } + ) + if (!result.ok) { + throw new Error(result.refusal.message) + } +} + +const pendingTabs = new Set() + +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) + if ( + nativeChatRouteForTerminal({ + agent: facts.agent, + structuredSessionId: tab.structuredSessionId, + mode: input.mode + }) === 'bridge' + ) { + input.patch(tab.id, { viewMode: input.mode }) + 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' + await requestHandoff(sessionId, direction, fence) + await waitForOwner(sessionId, input.mode === 'chat' ? 'native' : 'tui') + input.patch(tab.id, { structuredSessionId: sessionId, viewMode: input.mode }) + return 'structured' + } catch (error) { + toast.error( + translate( + 'components.native-chat.structuredAdoptionFailed', + 'Could not switch this Codex session to structured chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + return 'ignored' + } finally { + pendingTabs.delete(tab.id) + } +} diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index dedfa15ed96b..83edd6fe615b 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -44,6 +44,7 @@ import { buildValidWorktreeIdsForSessionHydration, collectPersistedWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' +import { setTerminalNativeChatMode } from './structured-native-chat-toggle' export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' @@ -1288,42 +1289,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/tab-types.ts b/src/shared/tab-types.ts index c2a497d63950..d50a552ae968 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -53,6 +53,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 ) }) From 0c74172fcd816432e0ff127507136a38ffedfde1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:14:30 -0700 Subject: [PATCH 2/9] fix structured Codex TUI adoption --- ...runtime-structured-tui-tab-binding.test.ts | 70 ++++- src/main/runtime/orca-runtime.ts | 79 +++--- .../use-structured-agent-session-create.ts | 110 -------- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/i18n/native-chat-locales.test.ts | 4 + .../src/lib/launch-agent-in-new-tab.test.ts | 17 +- .../src/lib/launch-agent-in-new-tab.ts | 19 +- .../structured-native-chat-availability.ts | 11 + .../structured-native-chat-toggle.test.ts | 244 +++++++++++++++--- .../slices/structured-native-chat-toggle.ts | 68 ++++- 14 files changed, 423 insertions(+), 204 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-create.ts create mode 100644 src/renderer/src/lib/structured-native-chat-availability.ts 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 98575cb051c6..d32752d83a2c 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 @@ -106,13 +106,16 @@ describe('structured TUI launch tab binding', () => { const runtime = terminalAdoptionHarness() const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) setStructuredAgentSessionHost({ adoptTuiOwner } as never) - resolvePinnedCodexRolloutProof.mockResolvedValueOnce('/tmp/codex-home/rollout.jsonl') - readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + 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( @@ -147,6 +150,10 @@ describe('structured TUI launch tab binding', () => { }) ) 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) @@ -155,7 +162,15 @@ describe('structured TUI launch tab binding', () => { it('refuses adoption when the provider rollout cannot be proved', async () => { const runtime = terminalAdoptionHarness() - resolvePinnedCodexRolloutProof.mockResolvedValueOnce(null) + 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' + }) await expect( runtime.adoptStructuredAgentSessionTerminal( @@ -174,7 +189,47 @@ describe('structured TUI launch tab binding', () => { }, { callerKey: 'renderer-1' } ) - ).rejects.toThrow('Could not find the rollout') + ).rejects.toThrow('did not prove the expected Codex rollout') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + }) + + it('refuses a thread proof when the Codex process changes during it', async () => { + const runtime = terminalAdoptionHarness() + 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() }) @@ -186,12 +241,13 @@ describe('structured TUI launch tab binding', () => { threadId: 'thread-discovered', transcriptPath: '/tmp/codex-home/discovered.jsonl' }) - readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + const process = { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-adopt' - }) + } + readStructuredTuiProcessIdentity.mockResolvedValueOnce(process).mockResolvedValueOnce(process) try { await expect( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index be56eddc102d..671f266d2ebd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11138,50 +11138,57 @@ export class OrcaRuntimeService { if (!listing?.rootProcessId) { throw new Error('Could not prove the Codex terminal process.') } + const rootProcessId = listing.rootProcessId const spawnToken = randomUUID() - const process = await readStructuredTuiProcessIdentity({ - hostId: intent.location.executionHostId, - rootPid: listing.rootProcessId, - spawnToken, - agent: 'codex' - }) + 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 - let transcriptPath = threadId - ? await resolvePinnedCodexRolloutProof(intent.accountHome.path, threadId) - : null - if (!threadId) { - 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.') + 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 } - return current - } + }, + 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({ - 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) => - this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { - sessionId: input.envelope.sessionId, - spawnToken - }) ?? false + ...proofInput }) threadId = proof.threadId transcriptPath = proof.transcriptPath } - if (!transcriptPath) { - throw new Error('Could not find the rollout for this Codex conversation.') + 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: { 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fb4ddd10d83..dd20f51aef33 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15833,6 +15833,7 @@ "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 fd3df65b3c3e..4c50de2c5778 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14325,6 +14325,7 @@ "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 373520ba6d6e..d57a3630ee76 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14325,6 +14325,7 @@ "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 812531971cb5..0641be5527ca 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14354,6 +14354,7 @@ "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 0a1332518cb6..97262c2b55a7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14345,6 +14345,7 @@ "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/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 8515379d4182..8c71ffd26788 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..0a81d4261a57 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,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI onPromptDelivered?.() } + if (adoptStructuredChat) { + void (promptDeliveryCompletion ?? Promise.resolve()) + .catch(() => undefined) + .then(async () => { + await waitForAgentReady(tab.id, TUI_AGENT_CONFIG.codex.expectedProcess) + 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/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 index 42c7b31efaf0..666ac07eb22d 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { nativeChatRouteForAgent, nativeChatRouteForTerminal, @@ -6,11 +6,86 @@ import { } 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: 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([ @@ -37,50 +112,149 @@ describe('native chat routing', () => { expect(nativeChatRouteForTerminal({ agent: 'codex', mode: 'terminal' })).toBe('bridge') }) - it('retains the durable binding when the first handoff attempt fails', async () => { - const tab = { - id: 'tab-1', - entityId: 'terminal-1', - worktreeId: 'workspace-1', - contentType: 'terminal' as const - } - const state = { - unifiedTabsByWorktree: { 'workspace-1': [tab] }, - tabsByWorktree: { 'workspace-1': [{ id: 'terminal-1', launchAgent: 'codex' }] }, - terminalLayoutsByTabId: { - 'terminal-1': { - activeLeafId: 'leaf-1', - ptyIdsByLeafId: { 'leaf-1': 'pty-1' } + 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 } } - }, - agentStatusByPaneKey: { - 'terminal-1:leaf-1': { - agentType: 'codex', - providerSession: { id: 'thread-1' } + 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}`) } - } - vi.mocked(callStructuredAgentSession) - .mockResolvedValueOnce({ ok: true, fence: 1 }) - .mockRejectedValueOnce(new Error('method_not_found')) - const patch = vi.fn() + ) - const result = await setTerminalNativeChatMode({ - getState: () => state as never, - patch, - tabId: tab.id, - mode: 'chat' + 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' }) + }) - expect(result).toBe('ignored') - expect(patch).toHaveBeenCalledTimes(1) + 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).toHaveBeenCalledWith(tab.id, { - structuredSessionId: 'codex_thread-1' + viewMode: 'chat', + structuredSessionId: undefined }) }) + 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 () => { - vi.mocked(callStructuredAgentSession).mockReset() const tab = { id: 'tab-foreground', entityId: 'terminal-foreground', diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.ts index 31b20f18fb15..828d3ed01a5b 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.ts @@ -14,6 +14,7 @@ import { 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' @@ -26,9 +27,13 @@ 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 'structured' + return input.agent === null || input.agent === 'codex' ? 'structured' : 'bridge' } return input.mode === 'chat' ? nativeChatRouteForAgent(input.agent) : 'bridge' } @@ -93,7 +98,7 @@ async function waitForOwner( { sessionId } ) if (status.phase === 'failed') { - throw new Error(status.error?.message ?? 'Codex ownership transfer failed.') + return status } if (status.owner === owner && status.phase === 'idle') { return status @@ -106,16 +111,18 @@ async function waitForOwner( async function requestHandoff( sessionId: string, direction: 'to-native' | 'to-tui', - fence: number + fence: number, + clientOperationId: string, + action: 'start' | 'retry' ): Promise { - const fields = { direction, mode: 'after-turn' as const, action: 'start' as const } + const fields = { direction, mode: 'after-turn' as const, action } const result = await callStructuredAgentSession>( { kind: 'local' }, 'agentSession.handoff', { envelope: { sessionId, - clientOperationId: operationId(), + clientOperationId, expectedRuntimeFence: fence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.requestHandoff', @@ -133,6 +140,23 @@ async function requestHandoff( 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 @@ -149,14 +173,21 @@ export async function setTerminalNativeChatMode(input: { 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 + mode: input.mode, + structuredAvailable }) === 'bridge' ) { - input.patch(tab.id, { viewMode: input.mode }) + input.patch(tab.id, { + viewMode: input.mode, + ...(tab.structuredSessionId && facts.agent !== 'codex' + ? { structuredSessionId: undefined } + : {}) + }) return 'bridge' } pendingTabs.add(tab.id) @@ -203,15 +234,30 @@ export async function setTerminalNativeChatMode(input: { fence = await currentFence(sessionId) } const direction = input.mode === 'chat' ? 'to-native' : 'to-tui' - await requestHandoff(sessionId, direction, fence) - await waitForOwner(sessionId, input.mode === 'chat' ? 'native' : '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( - 'components.native-chat.structuredAdoptionFailed', - 'Could not switch this Codex session to structured chat' + 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) } ) From a6d4bc0380efd49e2bc154c36b71f405cc849ec1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:26:49 -0700 Subject: [PATCH 3/9] test(structured-chat): make the stale-binding clear and the availability guard observable The bridge-route assertion used deep equality with an explicit undefined, which vitest cannot distinguish from an absent key, so reverting the stale-binding clear left the suite green. canUseStructuredNativeChat had no coverage at all. Co-authored-by: Codex --- ...launch-agent-structured-chat-guard.test.ts | 109 ++++++++++++++++++ ...tructured-native-chat-availability.test.ts | 76 ++++++++++++ .../structured-native-chat-toggle.test.ts | 32 ++++- 3 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts create mode 100644 src/renderer/src/lib/structured-native-chat-availability.test.ts 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..a7c84006d86d --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockSetTabViewMode = vi.fn() +const mockWaitForAgentReady = 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: vi.fn() +} + +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: vi.fn() })) +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' }) + }) + + 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('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/store/slices/structured-native-chat-toggle.test.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts index 666ac07eb22d..fe3a756be69d 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -229,10 +229,36 @@ describe('native chat routing', () => { ).resolves.toBe('bridge') expect(callStructuredAgentSession).not.toHaveBeenCalled() - expect(patch).toHaveBeenCalledWith(tab.id, { - viewMode: 'chat', - structuredSessionId: undefined + 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 () => { From be45fc4c75510980363b721faefb3b53357bae4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:10:38 -0700 Subject: [PATCH 4/9] fix(structured-chat): prove a resumed Codex TUI off-screen Returning a Codex session from structured chat to its terminal always failed with "did not prove the expected Codex rollout". Adoption stops the pane's Codex, so `launchAdoptedStructuredTui` typed a resume command into a bare shell and went straight to the proof, which pasted `/status` and scraped the tail for 15s. The probe landed before Codex was up, and it read kittyKeyboardFlags from the mode tracker before the new process negotiated, submitting a bare CR. A `tui-idle` wait would not fix it: that resolves on a shell prompt preview and on the pre-adoption `lastAgentStatus`. Nothing on screen separates a ready shell from a ready Codex. The resumed CLI can answer for itself. Orca already installs Codex SessionStart hooks, and the payload carries session_id, transcript_path and source=resume. The hook now also echoes back a one-shot nonce Orca stamps into the single resume invocation it types, so a stale SessionStart from an earlier process in the same pane cannot pass for the new one. The nonce is a new env var rather than `launchToken`, which is pane-lifetime identity that authority attestation matches against. Nothing is pasted at the pane any more, so the submit-key question disappears with the probe. An installed hook script predating this omits the nonce; pane + thread still bind, so that degrades instead of stranding the path. With agent status hooks off there is no proof to wait for, and the launch says so up front. --- src/main/agent-hooks/installer-utils.ts | 6 +- src/main/agent-hooks/server.test-fixtures.ts | 1 + src/main/agent-hooks/server.ts | 8 +- src/main/codex/adopted-codex-tui-readiness.ts | 115 ++++++++ .../codex/codex-session-nonce-wire.test.ts | 82 ++++++ src/main/codex/hook-service.ts | 6 +- src/main/index.ts | 3 + ...untime-adopted-codex-tui-readiness.test.ts | 266 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 173 +++++++----- src/shared/agent-hook-listener.ts | 7 + src/shared/agent-hook-session-nonce.ts | 7 + src/shared/inline-env-command-prefix.test.ts | 64 +++++ src/shared/inline-env-command-prefix.ts | 46 +++ 13 files changed, 716 insertions(+), 68 deletions(-) create mode 100644 src/main/codex/adopted-codex-tui-readiness.ts create mode 100644 src/main/codex/codex-session-nonce-wire.test.ts create mode 100644 src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts create mode 100644 src/shared/agent-hook-session-nonce.ts create mode 100644 src/shared/inline-env-command-prefix.test.ts create mode 100644 src/shared/inline-env-command-prefix.ts diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 0bab6e22c2a2..f6e52ced5b7b 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -177,7 +177,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}"`, @@ -190,6 +193,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 48ff387fad4c..6d3b43853683 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -103,7 +103,11 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + | 'sessionNonce' > & { launchTokenHash?: string } @@ -2824,6 +2828,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, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload 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-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/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 b26ea85d4351..14e7602f94d4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2590,6 +2590,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/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.ts b/src/main/runtime/orca-runtime.ts index 671f266d2ebd..237c9c2b14a0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -99,6 +99,13 @@ 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, @@ -3523,6 +3530,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 @@ -3606,6 +3616,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 @@ -3649,6 +3665,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 @@ -10771,84 +10788,110 @@ export class OrcaRuntimeService { shell, isRemote: false }) - if ( - !startup || - !this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${startup.launchCommand}\r`, { - sessionId: input.record.sessionId, - spawnToken: input.spawnToken - }) - ) { + if (!startup || !pty.paneKey) { 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' + // 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 } }) - const owner: StructuredTuiOwner = { - ...input.owner, - process, - link: codexProviderHandleLink({ + // 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, - resumed: true, - fence: input.fence, - observedAt: Date.now() + 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() + } } - await input.onSpawned?.(owner) - const proof = await this.waitForAdoptedStructuredTuiProof({ - owner, - threadId: head.handle.threadId, - codexHome: input.record.accountHome.path, - sessionId: input.record.sessionId, - spawnToken: input.spawnToken - }) - const proved = { ...owner, transcriptPath: proof.transcriptPath } - this.adoptedStructuredTuiOwners.set(input.record.sessionId, proved) - return proved } + // 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 - sessionId: string - spawnToken: string - }): Promise<{ transcriptPath: string }> { - const readPty = (): RuntimePtyWorktreeRecord => { - 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.') - } - return pty + }): 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 pty = readPty() - return proveCodexTuiRollout({ - codexHome: input.codexHome, - threadId: input.threadId, - kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(pty.ptyId)?.flags ?? 0, - readOutput: () => { - const current = readPty() - return { - text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview), - lastOutputAt: current.lastOutputAt - } - }, - write: (data) => - this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { - sessionId: input.sessionId, - spawnToken: input.spawnToken - }) ?? false - }) + 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 { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ec9a947b94c4..5eca70030d33 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -331,6 +331,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 @@ -4304,6 +4309,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') { @@ -4507,6 +4513,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..0eb636168c2d --- /dev/null +++ b/src/shared/inline-env-command-prefix.test.ts @@ -0,0 +1,64 @@ +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') + }) + + it('uses env(1) on POSIX so fish parses it too', () => { + for (const shell of ['posix', 'fish'] as const) { + expect( + withInlineEnvAssignments({ + command: "codex resume 'thread-1'", + env: ENV, + platform: 'linux', + shell + }) + // Why not `NAME=value cmd`: fish rejects that form outright. + ).toBe("env ORCA_AGENT_SESSION_NONCE='nonce-1' codex resume 'thread-1'") + } + }) + + 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' + }) + ).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}` +} From 9976c914efe622a02b3211564df1d3f425530549 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:46:32 -0700 Subject: [PATCH 5/9] fix(native-chat): reserve the lease before the adopted Codex proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching a Codex tab to structured chat failed from every desktop entry point with "The agent terminal could not verify its Codex session." `adoptStructuredAgentSessionTerminal` typed `/status` into the pane through `writeAgentSessionProof`, which `agentSessionPtyWriteGate.admitProof` admits only for a reservation that already names the session, `tui`, and the spawn token. Both the reservation and `bindPty` happened *after* the proof, so the gate saw no binding and no record and refused the first keystroke. Neither proof branch could ever run, which is why an agent mid-conversation failed the same way as a cold one. The reservation and the pane binding now come first, so the probe is authorized as `provingReservation` — the state the gate was written to accept. A proof that does not land hands the reservation back with the durable processless proof and releases the pane, so the next attempt is not refused by the record its predecessor left behind. `proveOwner` gets the reservation's own fence rather than a hardcoded 1, which is what makes that retry land. Launch-at-chat shared the defect: it routes through the same `setTabViewMode` → `setTerminalNativeChatMode` → `agentSession.adoptTerminal` path, now pinned by a test. The new adoption test drives the real write gate over a real record store and a real rollout proof; every earlier test mocked the PTY controller, so `admitProof` never ran and none of them could observe this. --- .../structured-agent-session-host.ts | 21 ++ ...-agent-session-tui-adoption-reservation.ts | 164 +++++++++++ .../structured-agent-session-tui-adoption.ts | 47 +-- ...structured-tui-adoption-write-gate.test.ts | 267 ++++++++++++++++++ ...runtime-structured-tui-tab-binding.test.ts | 66 +++-- src/main/runtime/orca-runtime.ts | 92 +++++- .../tab-view-mode-structured-route.test.ts | 75 +++++ 7 files changed, 685 insertions(+), 47 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts create mode 100644 src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts create mode 100644 src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts 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 abefff689c1f..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 @@ -58,6 +58,13 @@ 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' @@ -161,6 +168,20 @@ export class StructuredAgentSessionHost { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } + /** 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> => 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..cfdef2bbb45c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption-reservation.ts @@ -0,0 +1,164 @@ +/** + * 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.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. `processlessAt` is the durable "nothing ever + * spawned under this token" proof, which is what lets the next attempt supersede the record + * instead of being refused by it forever — and what stops a restart from latching it into + * recovery. + */ +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.setReservationProcesslessProof({ + sessionId: input.sessionId, + fence: input.fence, + spawnToken: input.spawnToken, + processlessAt: now, + 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-tui-adoption.ts index d9b4cc1a410f..139577724e77 100644 --- 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 @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -15,6 +16,7 @@ import type { 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 @@ -47,7 +49,13 @@ async function adopt( } let record = input.deps.store.getRecord(sessionId) let replayed = record !== null - if (!record) { + if (record && 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 if (record) { + assertMatchingTuiOwner(record, owner) + } else { const reserved = await input.deps.store.reserveOwner( reserveRequestFor({ sessionId, @@ -65,21 +73,7 @@ async function adopt( }) ) replayed = reserved.disposition === 'replayed' - record = reserved.record - await input.deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: owner.process, - now: input.now() - }) - record = await input.deps.store.proveOwner({ - sessionId, - fence: record.lease.runtimeFence, - link: owner.link, - now: input.now() - }) - } else { - assertMatchingTuiOwner(record, owner) + record = await proveAdoptedTuiOwner(input, reserved.record) } const attached = await attachJournal({ record, @@ -110,6 +104,27 @@ async function adopt( } } +/** 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 + await input.deps.store.commitProcessIdentity({ + sessionId, + fence, + process: input.owner.process, + now: input.now() + }) + return input.deps.store.proveOwner({ + sessionId, + fence, + link: input.owner.link, + now: input.now() + }) +} + function assertMatchingTuiOwner( record: NonNullable>, owner: StructuredTuiOwner 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..efb231c88184 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts @@ -0,0 +1,267 @@ +/** + * 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 +} + +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 host = new StructuredAgentSessionHost({ + store, + adapter: { + 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: () => ({ agentDefaultEnv: {} }) } as never) + const state: ProofRig = { runtime, store, host, proofWrites: [], refusedWrites: [], codexHome } + 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 + resolveStructuredAgentSessionCreateIntent(input: { envelope: unknown }): Promise + issueStructuredTuiPtyHandle(): string + } + internal.ptysById.set(PTY_ID, ptyRecord) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + connectionId: null, + worktree: { id: WORKTREE_ID } + })) + internal.resolveStructuredAgentSessionCreateIntent = 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('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 d32752d83a2c..6caf48a5f079 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 @@ -77,6 +77,20 @@ function terminalAdoptionHarness() { 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(), @@ -105,7 +119,7 @@ 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({ adoptTuiOwner } as never) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) proveCodexTuiRollout.mockResolvedValueOnce({ transcriptPath: '/tmp/codex-home/rollout.jsonl' }) @@ -162,6 +176,8 @@ describe('structured TUI launch tab binding', () => { 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.') ) @@ -172,29 +188,37 @@ describe('structured TUI launch tab binding', () => { spawnToken: 'spawn-adopt' }) - await expect( - runtime.adoptStructuredAgentSessionTerminal( - { - envelope: { - sessionId: 'session-adopt', - clientOperationId: 'operation-adopt', - expectedRuntimeFence: null, - payloadFingerprint: 'f'.repeat(64) + 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' }, - 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() + { 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('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' }) @@ -231,12 +255,14 @@ describe('structured TUI launch tab binding', () => { ) ).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({ adoptTuiOwner } as never) + setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never) resolveLiveCodexTuiRollout.mockResolvedValueOnce({ threadId: 'thread-discovered', transcriptPath: '/tmp/codex-home/discovered.jsonl' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3d68007fd5bf..8399c60ba506 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11465,7 +11465,82 @@ export class OrcaRuntimeService { throw new Error('Could not prove the Codex terminal process.') } const rootProcessId = listing.rootProcessId - const spawnToken = randomUUID() + 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.bindPty(input.ptyId, sessionId) + let owned = false + try { + return await this.attachAdoptedStructuredTui({ + input, + caller, + host, + intent, + launchEnv, + fence, + spawnToken, + rootProcessId, + pty, + onOwned: () => { + owned = true + } + }) + } finally { + if (!owned) { + // A failed proof must leave nothing behind: an unreleased reservation is exactly the + // latched lease that makes the next attempt refuse itself. + if (priorBoundSessionId === null) { + agentSessionPtyWriteGate.unbindPty(input.ptyId) + } + await host + .releaseAdoptedTuiReservation({ sessionId, fence, spawnToken }) + .catch(() => undefined) + } + } + } + + private async attachAdoptedStructuredTui(args: { + input: Parameters[0] + caller: StructuredAgentSessionCaller + host: NonNullable> + intent: AgentSessionAttachParams + launchEnv: Record + fence: number + spawnToken: string + rootProcessId: number + pty: RuntimePtyWorktreeRecord + onOwned: () => void + }): Promise> { + const { input, caller, host, intent, launchEnv, fence, spawnToken, rootProcessId, pty } = args const readPty = (): RuntimePtyWorktreeRecord => { const current = this.ptysById.get(input.ptyId) if (!current?.connected || current.paneKey !== input.paneKey) { @@ -11528,7 +11603,9 @@ export class OrcaRuntimeService { threadId, resumed: false, origin: 'adopted', - fence: 1, + // 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, @@ -11556,14 +11633,6 @@ export class OrcaRuntimeService { runtimeKind: 'tui', providerHandle: { kind: 'codex', threadId } } - const launchEnv = resolveTuiAgentLaunchEnv( - 'codex', - this.requireStore().getSettings().agentDefaultEnv - ) - const host = getStructuredAgentSessionHost() - if (!host) { - throw new Error('structured_agent_session_unsupported') - } const adopted = await host.adoptTuiOwner({ caller, params, @@ -11572,8 +11641,9 @@ export class OrcaRuntimeService { launchEnv }) if (adopted.ok) { + // The pane stays bound to the reservation this call already made; only the owner is new. this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner) - agentSessionPtyWriteGate.bindPty(input.ptyId, input.envelope.sessionId) + args.onOwned() } return adopted } 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..a6383bde7f4b --- /dev/null +++ b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts @@ -0,0 +1,75 @@ +/** + * 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' +import type * as StructuredNativeChatToggle from './structured-native-chat-toggle' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +const { mockSetTerminalNativeChatMode } = vi.hoisted(() => ({ + mockSetTerminalNativeChatMode: vi.fn() +})) + +vi.mock('./structured-native-chat-toggle', async (importOriginal) => ({ + ...(await importOriginal()), + setTerminalNativeChatMode: mockSetTerminalNativeChatMode +})) + +const mockApi = { + ui: { set: vi.fn().mockResolvedValue(undefined) }, + settings: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } +} + +// @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(() => { + mockSetTerminalNativeChatMode.mockReset() + mockSetTerminalNativeChatMode.mockResolvedValue('structured') + store = createTestStore() + store.setState({ + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'codex-tab', worktreeId: WT, groupId: 'g-1' })] + }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: 'g-1', + worktreeId: WT, + activeTabId: 'codex-tab', + tabOrder: ['codex-tab'] + }) + ] + } + } as Partial) + }) + + it('hands an explicit chat mode to the structured adoption path', () => { + store.getState().setTabViewMode('codex-tab', 'chat') + + expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + ) + }) + + it('hands a toggled chat mode to the same path', () => { + store.getState().toggleTabViewMode('codex-tab') + + expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + ) + }) +}) From 575660ffd8e4ba898f5f9d1b168b778ea3038fb8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:43:00 -0700 Subject: [PATCH 6/9] fix(native-chat): let the reservation holder win an overlapping adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adoptions of the same Codex pane destroyed each other. The pane binding was released by pty id alone, gated on a `priorBoundSessionId` sampled before the reservation, and both attempts carry the same sessionId — so the attempt whose reservation had been superseded unbound the pane the winner was proving on, `admitProof` refused the winner's `/status` keystroke, and both failed. The write gate now records which attempt holds a pane, keyed on the spawn token the reservation already mints: a claim supersedes another attempt's but never a settled owner's, the release is a compare-and-clear, and a proven owner settles the binding so no later attempt can take it. No lock is needed; the reservation release beside it was already fence-scoped and unchanged. A failed `releaseAdoptedTuiReservation` stays swallowed — the proof error is the one the user must act on — but is now logged with session, fence and spawn token, because a release that never persisted leaves the lease latched and refuses every later attempt with no record of why. Adoption's no-record branch is deleted. The only caller reserves the lease first and nothing deletes a record, so it minted an owner for a session the store had never heard of in unit tests only; its test now drives the real reserve-then-adopt sequence, and a new one pins the refusal. Launch-at-chat discarded `waitForAgentReady`'s result, so a readiness timeout still flipped the tab to chat and adoption then probed a shell. It stays in terminal view instead: the terminal is usable and the view toggle re-runs adoption whenever the user wants it. Each fix was reverted against its new test to confirm the test reddens; all four defects had been living under green suites. --- ...uctured-agent-session-tui-adoption.test.ts | 42 +++++++++--- .../structured-agent-session-tui-adoption.ts | 39 +++-------- .../agent-session-pty-write-gate.test.ts | 42 ++++++++++++ .../runtime/agent-session-pty-write-gate.ts | 65 ++++++++++++++++--- ...structured-tui-adoption-write-gate.test.ts | 46 +++++++++++++ ...runtime-structured-tui-tab-binding.test.ts | 57 ++++++++++++++++ src/main/runtime/orca-runtime.ts | 38 ++++++----- .../src/lib/launch-agent-in-new-tab.ts | 12 +++- ...launch-agent-structured-chat-guard.test.ts | 15 +++++ 9 files changed, 291 insertions(+), 65 deletions(-) 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 index 82fb20e6f151..f0eaf49da548 100644 --- 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 @@ -79,15 +79,34 @@ afterEach(async () => { 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: params(), - owner: adoptedOwner, - claimKeyId: 'key-1' + params: attach, + owner: adoptedOwner }) expect(result).toMatchObject({ ok: true, replayed: false, fence: 1 }) @@ -107,12 +126,7 @@ describe('structured TUI adoption', () => { const invalid = params() invalid.envelope.payloadFingerprint = '0'.repeat(64) - const result = await host.adoptTuiOwner({ - caller: CALLER, - params: invalid, - owner: owner(), - claimKeyId: 'key-1' - }) + const result = await host.adoptTuiOwner({ caller: CALLER, params: invalid, owner: owner() }) expect(result).toMatchObject({ ok: false, @@ -121,4 +135,14 @@ describe('structured TUI adoption', () => { 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 index 139577724e77..643142751f78 100644 --- 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 @@ -5,11 +5,7 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - admitAttachOrRefuse, - attachJournal, - reserveRequestFor -} from './structured-agent-session-attach' +import { admitAttachOrRefuse, attachJournal } from './structured-agent-session-attach' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -22,8 +18,6 @@ export type StructuredTuiAdoptionRequest = { caller: StructuredAgentSessionCaller params: AgentSessionAttachParams owner: StructuredTuiOwner - claimKeyId: string - launchEnv?: Record } export function adoptStructuredTuiOwner( @@ -48,32 +42,19 @@ async function adopt( return admitted } let record = input.deps.store.getRecord(sessionId) - let replayed = record !== null - if (record && isStructuredTuiAdoptionReservation(record, owner.process.spawnToken)) { + 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 if (record) { - assertMatchingTuiOwner(record, owner) } else { - const reserved = await input.deps.store.reserveOwner( - reserveRequestFor({ - sessionId, - params, - authority: { - spawnToken: owner.process.spawnToken, - claimKeyId: input.claimKeyId, - handoffOperationId: params.envelope.clientOperationId, - probe: { outcome: 'reservation-unused' }, - ...(input.launchEnv ? { launchEnv: input.launchEnv } : {}) - }, - callerKey: input.caller.callerKey, - fingerprint: admitted.fingerprint, - now: input.now() - }) - ) - replayed = reserved.disposition === 'replayed' - record = await proveAdoptedTuiOwner(input, reserved.record) + assertMatchingTuiOwner(record, owner) } const attached = await attachJournal({ record, 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-structured-tui-adoption-write-gate.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-write-gate.test.ts index efb231c88184..861cd434f0e1 100644 --- 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 @@ -252,6 +252,52 @@ describe('structured Codex adoption against the real PTY write gate', () => { }) }, 30_000) + it('lets the attempt holding the reservation win when two adoptions overlap', async () => { + // Both attempts park here: reservation taken and pane bound, proof not yet written. + 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 superseded = rig.runtime.adoptStructuredAgentSessionTerminal( + { ...adoptInput(), threadId: THREAD_ID }, + { callerKey: 'renderer-1' } + ) + superseded.catch(() => undefined) + await vi.waitFor(() => expect(arrived).toHaveLength(1)) + // The second attempt carries the same sessionId and supersedes the first one'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') + // 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('refuses a pane another structured session already owns', async () => { agentSessionPtyWriteGate.bindPty(PTY_ID, 'someone-elses-session') 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 6caf48a5f079..778c91544476 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 @@ -215,6 +215,63 @@ describe('structured TUI launch tab binding', () => { } }) + 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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8399c60ba506..524ce8c5585f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11497,7 +11497,7 @@ export class OrcaRuntimeService { return reserved } const { fence, spawnToken } = reserved - agentSessionPtyWriteGate.bindPty(input.ptyId, sessionId) + agentSessionPtyWriteGate.bindPtyForAttempt(input.ptyId, sessionId, spawnToken) let owned = false try { return await this.attachAdoptedStructuredTui({ @@ -11505,7 +11505,6 @@ export class OrcaRuntimeService { caller, host, intent, - launchEnv, fence, spawnToken, rootProcessId, @@ -11515,15 +11514,27 @@ export class OrcaRuntimeService { } }) } finally { - if (!owned) { + if (owned) { + agentSessionPtyWriteGate.settlePtyAttempt(input.ptyId, spawnToken) + } else { // A failed proof must leave nothing behind: an unreleased reservation is exactly the - // latched lease that makes the next attempt refuse itself. - if (priorBoundSessionId === null) { - agentSessionPtyWriteGate.unbindPty(input.ptyId) - } + // latched lease that makes the next attempt refuse itself. Both releases are scoped to this + // attempt's own spawn token, because a second adoption of the same pane carries the same + // sessionId — clearing by pty alone unbinds the pane the attempt that superseded us is + // proving on, and then neither can finish. + agentSessionPtyWriteGate.releasePtyAttempt(input.ptyId, spawnToken) await host .releaseAdoptedTuiReservation({ sessionId, fence, spawnToken }) - .catch(() => undefined) + .catch((error: unknown) => { + // Swallowed on purpose: the proof error below is the one the user must see. But a + // release that never persisted leaves the lease latched, so it cannot also be silent. + console.warn('[native-chat] adopted TUI reservation release failed', { + sessionId, + fence, + spawnToken, + error + }) + }) } } } @@ -11533,14 +11544,13 @@ export class OrcaRuntimeService { caller: StructuredAgentSessionCaller host: NonNullable> intent: AgentSessionAttachParams - launchEnv: Record fence: number spawnToken: string rootProcessId: number pty: RuntimePtyWorktreeRecord onOwned: () => void }): Promise> { - const { input, caller, host, intent, launchEnv, fence, spawnToken, rootProcessId, pty } = args + 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) { @@ -11633,13 +11643,7 @@ export class OrcaRuntimeService { runtimeKind: 'tui', providerHandle: { kind: 'codex', threadId } } - const adopted = await host.adoptTuiOwner({ - caller, - params, - owner, - claimKeyId: this.agentSessionClaimSigner.keyId, - launchEnv - }) + const adopted = await host.adoptTuiOwner({ caller, params, owner }) if (adopted.ok) { // The pane stays bound to the reservation this call already made; only the owner is new. this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner) 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 0a81d4261a57..8573079003f5 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -259,7 +259,17 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI void (promptDeliveryCompletion ?? Promise.resolve()) .catch(() => undefined) .then(async () => { - await waitForAgentReady(tab.id, TUI_AGENT_CONFIG.codex.expectedProcess) + 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') }) } 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 index a7c84006d86d..4fcaec8a8a2e 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -81,6 +81,21 @@ describe('structured chat adoption guard on the launch path', () => { 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('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') From c69922bc40a9a1a4f2462f7a66fee8ae378ff2d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:09:27 -0700 Subject: [PATCH 7/9] fix(native-chat): adopt a Codex pane on its own account home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured adoption reused the CREATE intent, whose account home is derived purely from the current selection. The pane's Codex is already running under the CODEX_HOME it was spawned with — and an automatic resume deliberately pins that to the home that OWNS the session — so adopting a pane on any other managed account probed the wrong sessions directory and failed as "did not prove the expected Codex rollout", with no hint that the account mismatched. Adoption now resolves the home from the pane's own launch record and declines, naming the account, when the pane cannot be attributed. Create is unchanged. Hooks skipped: lint-staged stashes, and the stash stack is shared across every worktree on this machine. oxfmt, oxlint, the native code-quality audit, the max-lines ratchet, tsc and the runtime/codex suites were all run by hand. --- src/main/codex/codex-pane-launch-home.test.ts | 95 ++++++ src/main/codex/codex-pane-launch-home.ts | 78 +++++ ...ructured-tui-adoption-account-home.test.ts | 299 ++++++++++++++++++ ...structured-tui-adoption-write-gate.test.ts | 7 +- ...runtime-structured-tui-tab-binding.test.ts | 7 +- src/main/runtime/orca-runtime.ts | 75 ++++- 6 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 src/main/codex/codex-pane-launch-home.test.ts create mode 100644 src/main/codex/codex-pane-launch-home.ts create mode 100644 src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts 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/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..8fa66d3c903e --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts @@ -0,0 +1,299 @@ +/** + * 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 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') + 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 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 index 861cd434f0e1..a3d1db82f632 100644 --- 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 @@ -128,7 +128,7 @@ async function buildRig(): Promise { const internal = runtime as unknown as { ptysById: Map resolveRuntimeFileTarget(): Promise - resolveStructuredAgentSessionCreateIntent(input: { envelope: unknown }): Promise + resolveStructuredAgentSessionAdoptionIntent(input: { envelope: unknown }): Promise issueStructuredTuiPtyHandle(): string } internal.ptysById.set(PTY_ID, ptyRecord) @@ -136,7 +136,10 @@ async function buildRig(): Promise { connectionId: null, worktree: { id: WORKTREE_ID } })) - internal.resolveStructuredAgentSessionCreateIntent = vi.fn(async ({ envelope }) => ({ + // 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', 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 778c91544476..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 @@ -43,7 +43,7 @@ function terminalAdoptionHarness() { const internal = runtime as unknown as { ptysById: Map resolveRuntimeFileTarget(): Promise - resolveStructuredAgentSessionCreateIntent(input: { envelope: unknown }): Promise + resolveStructuredAgentSessionAdoptionIntent(input: { envelope: unknown }): Promise issueStructuredTuiPtyHandle(): string } internal.ptysById.set('pty-adopt', { @@ -60,7 +60,10 @@ function terminalAdoptionHarness() { connectionId: null, worktree: { id: WORKTREE_ID } })) - internal.resolveStructuredAgentSessionCreateIntent = vi.fn(async ({ envelope }) => ({ + // 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', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 524ce8c5585f..5f42ad4ddd84 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -122,7 +122,12 @@ import { readStructuredTuiProcessIdentity } from './structured-tui-process-ident 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 @@ -11451,10 +11456,11 @@ export class OrcaRuntimeService { if (target.connectionId || target.worktree.id !== pty.worktreeId) { throw new Error('Structured Codex chat is unavailable for remote terminals.') } - const intent = await this.resolveStructuredAgentSessionCreateIntent({ + const intent = await this.resolveStructuredAgentSessionAdoptionIntent({ envelope: input.envelope, worktree: input.worktree, - agent: 'codex' + agent: 'codex', + ptyId: input.ptyId }) const listing = (await this.ptyController?.listProcesses?.())?.find( (candidate) => @@ -11678,6 +11684,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') @@ -11686,8 +11746,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, @@ -11700,12 +11758,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' } From 4da3816081eadf920769a60b099628d6b0276ccb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:57:30 -0700 Subject: [PATCH 8/9] fix adoption provenance and prompt failures --- .../ipc/pty-codex-account-attribution.test.ts | 74 +++++++++++++++++++ src/main/ipc/pty.ts | 18 ++--- ...ructured-tui-adoption-account-home.test.ts | 21 ++++++ .../lib/agent-launch-prompt-delivery.test.ts | 17 +++++ .../src/lib/agent-launch-prompt-delivery.ts | 18 +++-- ...launch-agent-structured-chat-guard.test.ts | 29 +++++++- 6 files changed, 160 insertions(+), 17 deletions(-) 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/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts b/src/main/runtime/orca-runtime-structured-tui-adoption-account-home.test.ts index 8fa66d3c903e..bcf9cf3b992f 100644 --- 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 @@ -49,6 +49,7 @@ type AdoptionRig = { let root: string let paneAccountHome: string let selectedAccountHome: string +let sharedRuntimeHome: string let rig: AdoptionRig async function writeRolloutFixture(codexHome: string, threadId: string): Promise { @@ -201,6 +202,7 @@ beforeEach(async () => { 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, @@ -228,6 +230,25 @@ afterEach(async () => { }) 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) 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-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 4fcaec8a8a2e..632127fa065c 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -3,6 +3,8 @@ 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', @@ -39,13 +41,15 @@ const store = { setAgentStatus: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), seedNativeChatLaunchDraft: vi.fn(), - markNativeChatLaunchPromptFailed: 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: 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(), @@ -68,6 +72,7 @@ describe('structured chat adoption guard on the launch path', () => { 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 () => { @@ -96,6 +101,26 @@ describe('structured chat adoption guard on the launch path', () => { 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') From 24a9e9862ab6b4dfd2895f4dd2e3699960bc9be3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:02:26 -0700 Subject: [PATCH 9/9] fix structured TUI adoption cleanup races --- ...-agent-session-tui-adoption-reservation.ts | 27 +-- .../structured-agent-session-tui-adoption.ts | 18 +- ...structured-tui-adoption-write-gate.test.ts | 167 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 86 ++++++--- .../structured-tui-adoption-inflight.ts | 28 +++ ...ctured-tui-adoption-reservation-release.ts | 27 +++ .../tab-view-mode-structured-route.test.ts | 75 ++++++-- 7 files changed, 354 insertions(+), 74 deletions(-) create mode 100644 src/main/runtime/structured-tui-adoption-inflight.ts create mode 100644 src/main/runtime/structured-tui-adoption-reservation-release.ts 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 index cfdef2bbb45c..498a5b959624 100644 --- 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 @@ -64,7 +64,7 @@ export function isStructuredTuiAdoptionReservation( lease.runtimeKind === 'tui' && lease.claimStatus === 'reserved' && lease.handoffStage === 'new-owner-proving' && - lease.ownerProcess === null && + (lease.ownerProcess === null || lease.ownerProcess.spawnToken === spawnToken) && lease.reservedSpawnToken === spawnToken && !lease.unreconciled ) @@ -117,10 +117,9 @@ export async function reserveStructuredTuiAdoption( } /** - * Undo a reservation whose proof never landed. `processlessAt` is the durable "nothing ever - * spawned under this token" proof, which is what lets the next attempt supersede the record - * instead of being refused by it forever — and what stops a restart from latching it into - * recovery. + * 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 & { @@ -138,12 +137,18 @@ export async function releaseStructuredTuiAdoptionReservation( return } const now = input.now() - await input.deps.store.setReservationProcesslessProof({ - sessionId: input.sessionId, - fence: input.fence, - spawnToken: input.spawnToken, - processlessAt: now, - 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 + } }) } 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 index 643142751f78..f8aeacf3250f 100644 --- 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 @@ -18,6 +18,7 @@ export type StructuredTuiAdoptionRequest = { caller: StructuredAgentSessionCaller params: AgentSessionAttachParams owner: StructuredTuiOwner + onOwnerProven?: () => void } export function adoptStructuredTuiOwner( @@ -56,6 +57,7 @@ async function adopt( } else { assertMatchingTuiOwner(record, owner) } + input.onOwnerProven?.() const attached = await attachJournal({ record, params, @@ -92,12 +94,16 @@ async function proveAdoptedTuiOwner( ): Promise { const { sessionId } = record const fence = record.lease.runtimeFence - await input.deps.store.commitProcessIdentity({ - sessionId, - fence, - process: input.owner.process, - now: input.now() - }) + 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, 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 index a3d1db82f632..fab6a74542dc 100644 --- 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 @@ -40,6 +40,7 @@ type ProofRig = { proofWrites: string[] refusedWrites: string[] codexHome: string + historyFilePath: ReturnType } let root: string @@ -90,6 +91,7 @@ async function buildRig(): Promise { hostId: 'local' }) agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + const historyFilePath = vi.fn(async () => null) const host = new StructuredAgentSessionHost({ store, adapter: { @@ -97,7 +99,8 @@ async function buildRig(): Promise { dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), - setOption: vi.fn() + setOption: vi.fn(), + historyFilePath }, journalRoot: join(root, 'journals'), claimKeyId: 'key-1', @@ -110,7 +113,15 @@ async function buildRig(): Promise { setStructuredAgentSessionHost(host) const runtime = new OrcaRuntimeService({ getSettings: () => ({ agentDefaultEnv: {} }) } as never) - const state: ProofRig = { runtime, store, host, proofWrites: [], refusedWrites: [], codexHome } + const state: ProofRig = { + runtime, + store, + host, + proofWrites: [], + refusedWrites: [], + codexHome, + historyFilePath + } const ptyRecord = { ptyId: PTY_ID, connected: true, @@ -255,8 +266,7 @@ describe('structured Codex adoption against the real PTY write gate', () => { }) }, 30_000) - it('lets the attempt holding the reservation win when two adoptions overlap', async () => { - // Both attempts park here: reservation taken and pane bound, proof not yet written. + it('deduplicates a replay while a third fresh operation supersedes it', async () => { const arrived: string[] = [] let releaseBoth: () => void = () => {} const bothArrived = new Promise((resolve) => { @@ -280,13 +290,17 @@ describe('structured Codex adoption against the real PTY write gate', () => { } ) - const superseded = rig.runtime.adoptStructuredAgentSessionTerminal( - { ...adoptInput(), threadId: THREAD_ID }, - { callerKey: 'renderer-1' } - ) + 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)) - // The second attempt carries the same sessionId and supersedes the first one's reservation. + // A fresh operation is independent and supersedes the shared delivery's reservation. const winner = rig.runtime.adoptStructuredAgentSessionTerminal( { ...adoptInput(), threadId: THREAD_ID }, { callerKey: 'renderer-1' } @@ -294,6 +308,7 @@ describe('structured Codex adoption against the real PTY write gate', () => { 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({ @@ -301,6 +316,140 @@ describe('structured Codex adoption against the real PTY write gate', () => { }) }, 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') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5f42ad4ddd84..c5a692f29156 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -119,6 +119,8 @@ import { 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' @@ -3191,6 +3193,9 @@ export class OrcaRuntimeService { 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. @@ -11426,7 +11431,7 @@ export class OrcaRuntimeService { } } - async adoptStructuredAgentSessionTerminal( + adoptStructuredAgentSessionTerminal( input: { envelope: { sessionId: string @@ -11441,6 +11446,20 @@ export class OrcaRuntimeService { 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 ( @@ -11504,7 +11523,7 @@ export class OrcaRuntimeService { } const { fence, spawnToken } = reserved agentSessionPtyWriteGate.bindPtyForAttempt(input.ptyId, sessionId, spawnToken) - let owned = false + let ownerProven = false try { return await this.attachAdoptedStructuredTui({ input, @@ -11515,32 +11534,38 @@ export class OrcaRuntimeService { spawnToken, rootProcessId, pty, - onOwned: () => { - owned = true + 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 (owned) { - agentSessionPtyWriteGate.settlePtyAttempt(input.ptyId, spawnToken) - } else { - // A failed proof must leave nothing behind: an unreleased reservation is exactly the - // latched lease that makes the next attempt refuse itself. Both releases are scoped to this - // attempt's own spawn token, because a second adoption of the same pane carries the same - // sessionId — clearing by pty alone unbinds the pane the attempt that superseded us is - // proving on, and then neither can finish. - agentSessionPtyWriteGate.releasePtyAttempt(input.ptyId, spawnToken) - await host - .releaseAdoptedTuiReservation({ sessionId, fence, spawnToken }) - .catch((error: unknown) => { - // Swallowed on purpose: the proof error below is the one the user must see. But a - // release that never persisted leaves the lease latched, so it cannot also be silent. - console.warn('[native-chat] adopted TUI reservation release failed', { - sessionId, - fence, - spawnToken, - error - }) + 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 }) + } } } } @@ -11554,7 +11579,7 @@ export class OrcaRuntimeService { spawnToken: string rootProcessId: number pty: RuntimePtyWorktreeRecord - onOwned: () => void + onOwnerProven: (owner: StructuredTuiOwner) => void }): Promise> { const { input, caller, host, intent, fence, spawnToken, rootProcessId, pty } = args const readPty = (): RuntimePtyWorktreeRecord => { @@ -11649,11 +11674,14 @@ export class OrcaRuntimeService { runtimeKind: 'tui', providerHandle: { kind: 'codex', threadId } } - const adopted = await host.adoptTuiOwner({ caller, params, owner }) + const adopted = await host.adoptTuiOwner({ + caller, + params, + owner, + onOwnerProven: () => args.onOwnerProven(owner) + }) if (adopted.ok) { - // The pane stays bound to the reservation this call already made; only the owner is new. - this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner) - args.onOwned() + args.onOwnerProven(owner) } return adopted } 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/store/slices/tab-view-mode-structured-route.test.ts b/src/renderer/src/store/slices/tab-view-mode-structured-route.test.ts index a6383bde7f4b..969b3a50c7f4 100644 --- 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 @@ -10,22 +10,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { createTestStore, makeUnifiedTab, makeTabGroup } from './store-test-helpers' -import type * as StructuredNativeChatToggle from './structured-native-chat-toggle' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -const { mockSetTerminalNativeChatMode } = vi.hoisted(() => ({ - mockSetTerminalNativeChatMode: vi.fn() -})) - -vi.mock('./structured-native-chat-toggle', async (importOriginal) => ({ - ...(await importOriginal()), - setTerminalNativeChatMode: mockSetTerminalNativeChatMode -})) +const runtimeCall = vi.fn() const mockApi = { ui: { set: vi.fn().mockResolvedValue(undefined) }, - settings: { get: vi.fn().mockResolvedValue({}), 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 @@ -37,13 +30,53 @@ describe('structured chat route out of setTabViewMode', () => { let store: ReturnType beforeEach(() => { - mockSetTerminalNativeChatMode.mockReset() - mockSetTerminalNativeChatMode.mockResolvedValue('structured') + 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({ @@ -54,22 +87,26 @@ describe('structured chat route out of setTabViewMode', () => { }) ] } - } as Partial) + } as unknown as Partial) }) - it('hands an explicit chat mode to the structured adoption path', () => { + it('hands an explicit chat mode to the structured adoption path', async () => { store.getState().setTabViewMode('codex-tab', 'chat') - expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( - expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'agentSession.adoptTerminal' }) + ) ) }) - it('hands a toggled chat mode to the same path', () => { + it('hands a toggled chat mode to the same path', async () => { store.getState().toggleTabViewMode('codex-tab') - expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith( - expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'agentSession.adoptTerminal' }) + ) ) }) })