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
This commit is contained in:
Neil
2026-10-16 05:39:25 -07:00
committed by GitHub
parent 6622b8298c
commit 469ae09c97
11 changed files with 649 additions and 2 deletions
@@ -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<AgentHookServer> {
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([])
})
})
@@ -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<EnrichedAgentHookEventPayload, 'retainedForLiveness'> = {
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
+12
View File
@@ -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<PtySpaw
clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey)
}
}
// Why after commit: a spawn rejected above must not leave a published identity behind.
seedResumeLaunchIdentityFromSpawn({
paneKey: ctx.validatedPaneKey,
launchAgent: args.launchAgent,
resumeProviderSession: args.resumeProviderSession,
connectionId: args.connectionId,
worktreeId: args.worktreeId,
launchedAt: ctx.codexHomeLaunchStartedAt,
resumeArgvDropped: ctx.codexResumeLaunch.droppedResumeArgv,
result: ctx.result
})
// Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs).
const spawnedPid = ctx.result.pid ?? null
// Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps.
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { seedResumeLaunchIdentityFromSpawn } from './resume-launch-identity'
const { recordLaunchResumeIdentity } = vi.hoisted(() => ({
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<Parameters<typeof seedResumeLaunchIdentityFromSpawn>[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()
})
})
@@ -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()
})
}
+12
View File
@@ -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?.({
@@ -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
+2
View File
@@ -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 },
@@ -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({
@@ -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,
@@ -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<string, unknown>) =>
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<string> {
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<ListedTerminal> {
let found: ListedTerminal | undefined
await expect
.poll(
async () => {
const { terminals } = await phone.call<RuntimeTerminalListResult>('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<void> {
const { phone, worktree, parentTabId, agent, sessionId } = args
let agentStatus: AgentStatusEntry | null | undefined
await expect
.poll(
async () => {
const result = await phone.call<RuntimeMobileSessionTabsResult>('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 })
}
})