From 469ae09c9766ef072f3e354c3d74d3aa2cd8be74 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:39:25 -0700 Subject: [PATCH] fix(agents): a resumed agent's chat is readable on the phone before its first new turn (#29664) * fix(agents): a resumed agent's chat is readable on the phone before its first new turn (#10923) * fix(agents): skip a dropped Codex resume and keep the seeded identity across restart * fix(agents): seed resume identity before the spawn's first await and keep it through terminal evidence * fix(agents): seed after the spawn commits, and name the session on host-built resumes --- .../server-launch-resume-identity.test.ts | 165 ++++++++++ .../server/server-ingest-terminal.ts | 58 +++- src/main/ipc/pty/ipc/spawn-commit.ts | 12 + .../pty/pane/resume-launch-identity.test.ts | 58 ++++ .../ipc/pty/pane/resume-launch-identity.ts | 51 ++++ src/main/ipc/pty/runtime/spawn-commit.ts | 12 + src/main/ipc/pty/runtime/spawn-preflight.ts | 1 + src/main/ipc/pty/runtime/spawn-state.ts | 2 + ...nal-creation-and-readiness-part-02.spec.ts | 6 +- .../get-agent-session-execution-namespace.ts | 2 + ...-serve-mobile-native-chat-identity.spec.ts | 284 ++++++++++++++++++ 11 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 src/main/agent-hooks/server-launch-resume-identity.test.ts create mode 100644 src/main/ipc/pty/pane/resume-launch-identity.test.ts create mode 100644 src/main/ipc/pty/pane/resume-launch-identity.ts create mode 100644 tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts new file mode 100644 index 00000000000..27afee0f245 --- /dev/null +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +const RESUMED = { key: 'session_id' as const, id: 'resumed-session' } + +// #10923: Codex runs SessionStart only at its first turn, so a resumed pane is silent until the +// user types; the host that launched the resume seeds the identity every reader joins on. +describe('a resume launch seeds the pane identity', () => { + const servers: AgentHookServer[] = [] + let userDataPath: string + + beforeEach(() => { + _internals.resetCachesForTests() + getCohortAtEmitMock.mockReturnValue({}) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-launch-resume-identity-')) + }) + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + rmSync(userDataPath, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + async function startServer(): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath }) + return server + } + + it('publishes an identity-only row before the agent reports', async () => { + const server = await startServer() + server.recordLaunchResumeIdentity({ + paneKey: PANE, + worktreeId: 'wt-1', + agentType: 'codex', + providerSession: RESUMED, + launchedAt: Date.now() + }) + expect(server.getStatusSnapshotForPane(PANE)).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentType: 'codex', + providerSession: RESUMED, + providerSessionOnly: true + }) + ]) + }) + + it("yields to the agent's own report and is replaced by its first hook", async () => { + const server = await startServer() + const launchedAt = Date.now() - 1_000 + const response = await postHookEvent( + server, + buildBody({ hook_event_name: 'SessionStart', session_id: 'agent-reported' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'codex', + providerSession: RESUMED, + launchedAt + }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.providerSession?.id).toBe('agent-reported') + }) + + it("replaces a row left by the pane's previous process", async () => { + const server = await startServer() + const response = await postHookEvent( + server, + buildBody({ hook_event_name: 'Stop', session_id: 'previous-run' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'codex', + providerSession: RESUMED, + launchedAt: Date.now() + 1_000 + }) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + providerSession: RESUMED, + providerSessionOnly: true + }) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', session_id: RESUMED.id, prompt: 'go on' }), + '/hook/codex' + ) + const [row] = server.getStatusSnapshotForPane(PANE) + expect(row?.providerSessionOnly).toBeUndefined() + expect(row).toMatchObject({ state: 'working', providerSession: RESUMED }) + }) + + it('keeps the seeded identity when terminal evidence starts the first turn', async () => { + const server = await startServer() + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'opencode2', + providerSession: RESUMED, + launchedAt: Date.now() + }) + server.ingestTerminalStatus({ + paneKey: PANE, + origin: 'process', + payload: { state: 'working', prompt: '', agentType: 'opencode2' } + }) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working', + providerSession: RESUMED + }) + }) + + it('keeps the seeded identity across a host restart', async () => { + const server = await startServer() + server.recordLaunchResumeIdentity({ + paneKey: PANE, + worktreeId: 'wt-1', + agentType: 'codex', + providerSession: RESUMED, + launchedAt: Date.now() + }) + // Synchronous flush via stop() captures the trailing-debounced write. + server.stop() + servers.length = 0 + const restarted = await startServer() + expect(restarted.getStatusSnapshotForPane(PANE)).toEqual([ + expect.objectContaining({ + agentType: 'codex', + providerSession: RESUMED, + providerSessionOnly: true + }) + ]) + }) + + it('ignores a pane key that is not a stable tab:leaf key', async () => { + const server = await startServer() + server.recordLaunchResumeIdentity({ + paneKey: 'not-a-pane', + agentType: 'codex', + providerSession: RESUMED, + launchedAt: Date.now() + }) + expect(server.getStatusSnapshot()).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 3a21a95fd5f..15537b938f3 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -3,11 +3,64 @@ import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-l import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' import type { EnrichedAgentHookEventPayload } from './server-types' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { + /** + * A resume launch names its provider session before the agent reports one. Codex runs + * SessionStart only at its first turn, so until the user typed, readers had no transcript + * to address and native chat stayed empty (#10923). Seeds an identity-only row. + */ + recordLaunchResumeIdentity(event: { + paneKey: string + worktreeId?: string + agentType: string + providerSession: AgentProviderSessionMetadata + /** When the pane's process launched; a row reported since then is the agent's own. */ + launchedAt: number + }): void { + const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey || paneKey.length > MAX_PANE_KEY_LEN) { + return + } + const previous = this.state.lastStatusByPaneKey.get(paneKey) + if ( + previous && + (previous.providerSessionOnly === true + ? agentProviderSessionsEqual( + event.agentType, + previous.providerSession, + event.providerSession + ) + : 'receivedAt' in previous && + typeof previous.receivedAt === 'number' && + previous.receivedAt >= event.launchedAt) + ) { + return + } + const identity: AgentHookEventPayload & + Pick = { + paneKey, + tabId: parsedPaneKey.tabId, + ...(event.worktreeId?.trim() ? { worktreeId: event.worktreeId.trim() } : {}), + connectionId: null, + providerSession: event.providerSession, + providerSessionOnly: true, + // Why: hydration keeps only marked identity rows; a host restart before the first turn must not lose it. + retainedForLiveness: true, + payload: { state: 'done', prompt: '', agentType: event.agentType } + } + this.applyNormalizedStatus(identity, undefined, 'launch') + } + ingestTerminalStatus(event: { ptyId?: string paneKey: string @@ -153,10 +206,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.payload.agentType && event.payload.agentType !== 'unknown' ? event.payload.agentType : undefined + // A launch seed's `done` is a placeholder, not a finished turn, so its session carries over. const preservedProviderSession = previous?.providerSession && (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && - (previous.payload.state !== 'done' || event.payload.state === 'done') + (previous.payload.state !== 'done' || + event.payload.state === 'done' || + previous.observation?.origin === 'launch') ? previous.providerSession : undefined // Why: OSC carries no main agent fact. While it repaints the state the hook row already holds, the diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 2ae21ec3f8e..2b5c078164c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -11,6 +11,7 @@ import { import { rememberPaneKeyForPty } from '../pane/key-state' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity' import { admitProviderReattachLaunchIdentity, admitRendererAgentLaunchAuthority @@ -151,6 +152,17 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise ({ + recordLaunchResumeIdentity: vi.fn() +})) + +vi.mock('../../../agent-hooks/server', () => ({ + agentHookServer: { recordLaunchResumeIdentity } +})) + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const LAUNCHED_AT = new Date(1_000) + +function seed(overrides: Partial[0]> = {}) { + seedResumeLaunchIdentityFromSpawn({ + paneKey: PANE_KEY, + launchAgent: 'codex', + resumeProviderSession: { key: 'session_id', id: 'codex-session' }, + connectionId: null, + worktreeId: 'wt-1', + launchedAt: LAUNCHED_AT, + resumeArgvDropped: false, + result: { id: 'pty-1' }, + ...overrides + }) +} + +describe('seedResumeLaunchIdentityFromSpawn', () => { + beforeEach(() => { + recordLaunchResumeIdentity.mockReset() + }) + + it('records the resumed session of a fresh local launch', () => { + seed() + expect(recordLaunchResumeIdentity).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + worktreeId: 'wt-1', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + launchedAt: 1_000 + }) + }) + + it.each([ + ['a reattached process', { result: { id: 'pty-1', isReattach: true } }], + ['a Codex resume that fell back to a fresh session', { resumeArgvDropped: true }], + ['an SSH pane, whose relay owns its rows', { connectionId: 'ssh-1' }], + ['a launch with no resume', { resumeProviderSession: undefined }], + ['a malformed resume id', { resumeProviderSession: { key: 'session_id', id: '-rf' } }], + ['an agent that cannot resume', { launchAgent: 'not-an-agent' }], + ['a Pi resume without its transcript file', { launchAgent: 'pi' }], + ['a spawn with no pane key', { paneKey: null }] + ])('skips %s', (_label, overrides) => { + seed(overrides) + expect(recordLaunchResumeIdentity).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/pane/resume-launch-identity.ts b/src/main/ipc/pty/pane/resume-launch-identity.ts new file mode 100644 index 00000000000..97851a026e1 --- /dev/null +++ b/src/main/ipc/pty/pane/resume-launch-identity.ts @@ -0,0 +1,51 @@ +import { agentHookServer } from '../../../agent-hooks/server' +import { + isResumableTuiAgent, + normalizeAgentProviderSession +} from '../../../../shared/agent-session-resume' +import type { PtySpawnResult } from '../../../providers/pty-provider-contract' + +/** + * Record a fresh resume launch's provider session in the hook store, so every reader (phone tab + * list, native chat, worktree ps) can address the transcript before the agent's first hook. + * Both spawn choke points (renderer pty:spawn and the runtime controller) call this. + */ +export function seedResumeLaunchIdentityFromSpawn(args: { + paneKey: string | null | undefined + launchAgent: unknown + resumeProviderSession: unknown + connectionId: string | null | undefined + worktreeId: unknown + launchedAt: Date | undefined + /** The Codex resume fell back to a fresh session, so the pane does not hold this one. */ + resumeArgvDropped: boolean + result: PtySpawnResult +}): void { + // Why: a reattached process already reported its own identity; an SSH pane's rows belong to + // its relay, which owns that host's status. + if ( + !args.paneKey || + args.resumeArgvDropped || + args.result.isReattach === true || + args.connectionId || + !args.launchedAt + ) { + return + } + const agent = args.launchAgent + const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) + if (!providerSession || !isResumableTuiAgent(agent)) { + return + } + // Why: Pi-family resumes are addressed by transcript file, not id; without it the row is invalid. + if ((agent === 'pi' || agent === 'prime-agent') && !providerSession.transcriptPath) { + return + } + agentHookServer.recordLaunchResumeIdentity({ + paneKey: args.paneKey, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + agentType: agent, + providerSession, + launchedAt: args.launchedAt.getTime() + }) +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 1472a842b44..b72c9b2a79b 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -19,6 +19,7 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' @@ -252,6 +253,17 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { ? ctx.result.pid : null }) + // Why after commit: a spawn rejected above must not leave a published identity behind. + seedResumeLaunchIdentityFromSpawn({ + paneKey, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + connectionId: args.connectionId, + worktreeId: args.worktreeId, + launchedAt: ctx.codexHomeLaunchStartedAt, + resumeArgvDropped: ctx.codexResumeArgvDropped, + result: ctx.result + }) // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) ctx.deps.options?.onCodexHomePtySpawned?.({ diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 82e52190eb8..5cc79cebdac 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -106,6 +106,7 @@ export async function prepareRuntimePtySpawn( // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. ctx.launchCommand = codexResumeLaunch.command + ctx.codexResumeArgvDropped = codexResumeLaunch.droppedResumeArgv args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 6b585e281a8..57a3ef2394a 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -60,6 +60,7 @@ export type RuntimePtySpawnState = { requestedAgentTeamsPath: string | undefined selectedCodexHomePath: string | null codexResumeHomeSelected: boolean + codexResumeArgvDropped: boolean skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean spawnOptions: PtySpawnOptions @@ -175,6 +176,7 @@ export function createRuntimePtySpawnState( requestedAgentTeamsPath: undefined, selectedCodexHomePath: null, codexResumeHomeSelected: false, + codexResumeArgvDropped: false, skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, spawnOptions: { cols: args.cols, rows: args.rows }, diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 1df54c18403..32b010532ea 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -392,7 +392,11 @@ describe('OrcaRuntimeService', () => { spawnIntent: 'automatic-restore' }) expect(spawn).toHaveBeenLastCalledWith( - expect.objectContaining({ spawnIntent: 'automatic-restore' }) + expect.objectContaining({ + spawnIntent: 'automatic-restore', + // The spawn names the session it resumes, so the host can publish it before a hook. + resumeProviderSession: expect.objectContaining({ id: 'provider-session-intent' }) + }) ) await runtime.ensureAgentSession({ diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index b2690847244..cd2748e1cc3 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -162,6 +162,8 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O launchConfig: startup.launchConfig, startupCommandDelivery: startup.startupCommandDelivery, launchAgent: request.agent, + // Why: like every host-built resume, the spawn names its session so readers can address it. + resumeProviderSession: identity.providerSession, terminalImageViewerSupported: _caller.terminalImageViewerSupported, launchRuntime, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, diff --git a/tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts b/tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts new file mode 100644 index 00000000000..d6a7951969c --- /dev/null +++ b/tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts @@ -0,0 +1,284 @@ +/** + * #10923: a phone paired straight to a headless `orca serve` must learn each terminal agent's + * provider session from `session.tabs.list`, or native chat has no transcript to read and stays + * empty. The agents are fakes that pipe hook JSON through Orca's own managed hook scripts from + * inside the serve's PTY, so the hook endpoint, pane key and tab-list projection are all real. + * Real Codex 0.162 behaves the same way: a typed session reports its identity on its first turn, + * and `codex resume` reports nothing until the user sends a message. + */ +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { pairMobileClient, type PairedMobileClient } from './helpers/paired-mobile-client' +import { getManagedScript as getClaudeManagedScript } from '../../src/main/claude/hook-script' +import { getManagedScript as getCodexManagedScript } from '../../src/main/codex/codex-hook-script' +import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts' +import type { RuntimeTerminalListResult } from '../../src/shared/runtime-terminal-contracts' +import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts' + +test.skip(process.platform === 'win32', 'the fake agents are POSIX shell scripts') + +type FakeAgent = 'claude' | 'codex' +type ListedTerminal = RuntimeTerminalListResult['terminals'][number] + +function transcriptLines(agent: FakeAgent, sessionId: string, cwd: string): string { + const now = Date.now() + const at = (offset: number) => new Date(now + offset).toISOString() + const lines = + agent === 'codex' + ? [ + { timestamp: at(0), type: 'session_meta', payload: { id: sessionId, cwd } }, + // Codex records chat text as event_msg rows; the response_item copies are model input. + { + timestamp: at(1), + type: 'event_msg', + payload: { type: 'user_message', message: 'codex question' } + }, + { + timestamp: at(2), + type: 'event_msg', + payload: { type: 'agent_message', message: 'codex answer' } + } + ] + : [ + { + sessionId, + uuid: `${sessionId}-user`, + timestamp: at(0), + type: 'user', + cwd, + message: { role: 'user', content: [{ type: 'text', text: 'claude question' }] } + }, + { + sessionId, + uuid: `${sessionId}-assistant`, + timestamp: at(1), + type: 'assistant', + cwd, + message: { model: 'claude-opus-4', content: [{ type: 'text', text: 'claude answer' }] } + } + ] + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` +} + +/** A fake agent: writes its transcript, then reports the same hook events the real CLI does. */ +function writeFakeAgent(dir: string, agent: FakeAgent, sessionId: string, cwd: string): string { + const transcriptPath = path.join(dir, `${agent}-${sessionId}.jsonl`) + writeFileSync(transcriptPath, transcriptLines(agent, sessionId, cwd)) + const hookScript = path.join(dir, `${agent}-hook.sh`) + writeFileSync( + hookScript, + agent === 'codex' ? getCodexManagedScript('posix') : getClaudeManagedScript('posix') + ) + chmodSync(hookScript, 0o755) + const event = (name: string, extra: Record) => + JSON.stringify({ + hook_event_name: name, + session_id: sessionId, + transcript_path: transcriptPath, + cwd, + ...(agent === 'codex' ? { model: 'gpt-5-codex' } : {}), + ...extra + }) + const events = [ + event('SessionStart', { source: 'startup' }), + event('UserPromptSubmit', { + prompt: `${agent} question`, + ...(agent === 'codex' ? { turn_id: 'turn-1' } : {}) + }), + event('Stop', { + last_assistant_message: `${agent} answer`, + ...(agent === 'codex' ? { turn_id: 'turn-1' } : {}) + }) + ] + const agentScript = path.join(dir, agent) + writeFileSync( + agentScript, + [ + '#!/bin/sh', + ...events.map((payload) => `printf '%s' '${payload}' | sh '${hookScript}'`), + `echo FAKE_${agent.toUpperCase()}_REPORTED`, + // Stay in the foreground like a real agent waiting at its prompt. + 'exec sleep 600', + '' + ].join('\n') + ) + chmodSync(agentScript, 0o755) + return agentScript +} + +async function openPhoneWorkspace( + host: HeadlessPairedRuntimeHost, + phone: PairedMobileClient, + repoPath: string +): Promise { + await host.client.call('repo.add', { path: repoPath, kind: 'git' }) + let worktreeId = '' + await expect + .poll( + async () => { + const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>( + 'worktree.ps', + { limit: 1_000 } + ) + worktreeId = worktrees.find((row) => row.path === repoPath)?.worktreeId ?? '' + return worktreeId + }, + { timeout: 30_000 } + ) + .not.toBe('') + return `id:${worktreeId}` +} + +async function waitForTerminal( + phone: PairedMobileClient, + worktree: string, + matches: (row: ListedTerminal) => boolean +): Promise { + let found: ListedTerminal | undefined + await expect + .poll( + async () => { + const { terminals } = await phone.call('terminal.list', { + worktree + }) + found = terminals.find(matches) + return found?.handle ?? '' + }, + { timeout: 30_000 } + ) + .not.toBe('') + if (!found) { + throw new Error('terminal not listed') + } + return found +} + +/** What the phone's chat view does: read the session tab list, then the transcript it names. */ +async function expectPhoneChatReadsTranscript(args: { + phone: PairedMobileClient + worktree: string + parentTabId: string + agent: FakeAgent + sessionId: string +}): Promise { + const { phone, worktree, parentTabId, agent, sessionId } = args + let agentStatus: AgentStatusEntry | null | undefined + await expect + .poll( + async () => { + const result = await phone.call('session.tabs.list', { + worktree + }) + const listed = result.tabs.find( + (row) => row.type === 'terminal' && row.parentTabId === parentTabId + ) + agentStatus = listed?.type === 'terminal' ? listed.agentStatus : undefined + return agentStatus?.providerSession?.id ?? null + }, + { timeout: 30_000, message: `${agent} providerSession in session.tabs.list` } + ) + .toBe(sessionId) + expect(agentStatus?.agentType).toBe(agent) + const transcriptPath = agentStatus?.providerSession?.transcriptPath + const read = await phone.call<{ messages?: { role: string }[]; error?: string }>( + 'nativeChat.readSession', + { agent, sessionId, ...(transcriptPath ? { transcriptPath } : {}) } + ) + expect(read.error, `${agent} nativeChat.readSession`).toBeUndefined() + expect(read.messages?.map((message) => message.role)).toEqual(['user', 'assistant']) +} + +test('a phone paired to headless serve gets each terminal agent its provider session', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-10923-')) + const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'mobile' }) + const phone = pairMobileClient(host.offer) + try { + const worktree = await openPhoneWorkspace(host, phone, testRepoPath) + for (const agent of ['codex', 'claude'] as const) { + const sessionId = `${agent}-10923-${Date.now()}` + const agentScript = writeFakeAgent(fakeDir, agent, sessionId, testRepoPath) + const { tab } = await phone.call<{ tab: { id: string; parentTabId: string } }>( + 'session.tabs.createTerminal', + { + worktree, + clientMutationId: `create-${agent}`, + activate: false, + select: true, + navigation: 'caller' + } + ) + const terminal = await waitForTerminal( + phone, + worktree, + (row) => row.tabId === tab.parentTabId + ) + await phone.call('terminal.send', { + terminal: terminal.handle, + text: `'${agentScript}'`, + enter: true + }) + await expectPhoneChatReadsTranscript({ + phone, + worktree, + parentTabId: tab.parentTabId, + agent, + sessionId + }) + } + } finally { + await host.dispose() + rmSync(fakeDir, { recursive: true, force: true }) + } +}) + +// Codex runs SessionStart only at its first turn, so a resumed session is silent until the user +// types. The host launched the resume, so it already knows which session the pane holds. +test('a phone can read a resumed Codex chat before its first new turn', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-10923-')) + const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'mobile' }) + const phone = pairMobileClient(host.offer) + try { + const worktree = await openPhoneWorkspace(host, phone, testRepoPath) + const sessionId = randomUUID() + const transcriptPath = path.join(fakeDir, `rollout-2026-10-16T00-00-00-${sessionId}.jsonl`) + writeFileSync(transcriptPath, transcriptLines('codex', sessionId, testRepoPath)) + const resumedCodex = path.join(fakeDir, 'codex-resumed') + writeFileSync(resumedCodex, '#!/bin/sh\necho FAKE_CODEX_RESUMED\nexec sleep 600\n') + chmodSync(resumedCodex, 0o755) + const created = await host.client.call<{ terminal: { handle: string } }>('terminal.create', { + worktree, + command: `'${resumedCodex}'`, + launchAgent: 'codex', + resumeProviderSession: { key: 'session_id', id: sessionId, transcriptPath } + }) + const terminal = await waitForTerminal( + phone, + worktree, + (row) => row.handle === created.result.terminal.handle + ) + await expectPhoneChatReadsTranscript({ + phone, + worktree, + parentTabId: terminal.tabId, + agent: 'codex', + sessionId + }) + } finally { + await host.dispose() + rmSync(fakeDir, { recursive: true, force: true }) + } +})