mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
+2
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user