diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index c1870d35a7c8..78c8d126e001 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8808,3 +8808,200 @@ describe('AgentHookServer closed-tab suppression bound', () => { expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) }) }) + +describe('seedCodexLaunchStatus', () => { + const SEED = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } + + it('seeds a working row with the injected prompt during the spawn window', () => { + const server = new AgentHookServer() + + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'say hi', + agentType: 'codex' + }) + ]) + }) + + it('seeds an idle session-boundary done row for a promptless spawn', () => { + const server = new AgentHookServer() + + server.seedCodexLaunchStatus(SEED) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: '', + agentType: 'codex', + sessionBoundary: true + }) + ]) + }) + + it('is idempotent under duplicate seeds', () => { + const server = new AgentHookServer() + const changeListener = vi.fn() + server.subscribeStatusChanges(changeListener) + + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + const afterFirst = server.getStatusSnapshot() + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual(afterFirst) + expect(changeListener).toHaveBeenCalledTimes(1) + }) + + it('never clobbers an existing pane status such as a real PermissionRequest waiting', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postCodexHook = (payload: Record): Promise => + 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(payload)) + }) + await postCodexHook({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }) + + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', agentType: 'codex' }) + ]) + } finally { + server.stop() + } + }) + + it('does not seed a retired pane', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('lets the real first-turn hooks replace the seed and cache the provider session', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postCodexHook = (payload: Record): Promise => + 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(payload)) + }) + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + // Codex fires SessionStart only alongside the first UserPromptSubmit, + // and stamps session_id on every event of the turn. + await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'codex-session-1' }) + await postCodexHook({ + hook_event_name: 'UserPromptSubmit', + session_id: 'codex-session-1', + prompt: 'say hi' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'say hi', + agentType: 'codex', + providerSession: expect.objectContaining({ key: 'session_id', id: 'codex-session-1' }) + }) + ]) + + await postCodexHook({ hook_event_name: 'Stop', last_assistant_message: 'Hi! 👋' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + agentType: 'codex', + lastAssistantMessage: 'Hi! 👋' + }) + ]) + } finally { + server.stop() + } + }) + + it('recovers waiting → working when the first turn starts after a spawn approval', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postCodexHook = (payload: Record): Promise => + 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(payload)) + }) + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + await postCodexHook({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', agentType: 'codex' }) + ]) + + await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'codex-session-2' }) + await postCodexHook({ hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'say hi', agentType: 'codex' }) + ]) + } finally { + server.stop() + } + }) + + it('stays a single working row under duplicate identical UserPromptSubmit delivery', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postCodexHook = (payload: Record): Promise => + 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(payload)) + }) + server.seedCodexLaunchStatus({ ...SEED, prompt: 'say hi' }) + + // Two managed hook brands can both deliver the same event on this machine. + await postCodexHook({ hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + await postCodexHook({ hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'say hi', agentType: 'codex' }) + ]) + + await postCodexHook({ hook_event_name: 'Stop', last_assistant_message: 'Hi! 👋' }) + await postCodexHook({ hook_event_name: 'Stop', last_assistant_message: 'Hi! 👋' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'done', agentType: 'codex' }) + ]) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e5ef70e13959..e44e977a0e9c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1859,6 +1859,51 @@ export class AgentHookServer { }) } + /** Seed a status row for a freshly spawned local Codex pane. Codex emits no hook + * until the first prompt submission (SessionStart only fires alongside the first + * UserPromptSubmit), so a spawned pane otherwise has no status row during the + * spawn window (#6643). An existing row always wins — a real PermissionRequest + * waiting is never overwritten — and any later hook/OSC status replaces the seed. */ + seedCodexLaunchStatus(seed: { + paneKey: string + tabId?: string + worktreeId?: string + prompt?: string + launchToken?: string + }): void { + const paneKey = seed.paneKey.trim() + if (paneKey.length === 0 || paneKey.length > MAX_PANE_KEY_LEN || !parsePaneKey(paneKey)) { + return + } + if ( + this.state.lastStatusByPaneKey.has(paneKey) || + this.getAgentStatusDisposition(paneKey) !== 'accept' + ) { + return + } + const prompt = seed.prompt?.trim() ?? '' + // Why: an idle spawn mirrors Claude's SessionStart row — 'working' would show a + // phantom spinner on an idle TUI, and sessionBoundary keeps completion-reactive + // consumers (notifications, automation runs) out of it (STA-3386). + const payload = normalizeAgentStatusPayload( + prompt + ? { state: 'working', prompt, agentType: 'codex' } + : { state: 'done', prompt: '', agentType: 'codex', sessionBoundary: true } + ) + if (!payload) { + return + } + this.applyNormalizedStatus({ + paneKey, + source: 'codex', + ...(seed.launchToken ? { launchToken: seed.launchToken } : {}), + ...(seed.tabId ? { tabId: seed.tabId } : {}), + ...(seed.worktreeId ? { worktreeId: seed.worktreeId } : {}), + connectionId: null, + payload + }) + } + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ ingestRemote( envelope: { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6545486a3a27..208a0ee2f41e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../agent-hooks/server' import type * as GitUsernameModule from '../git/git-username' import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' @@ -42917,6 +42918,167 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) }) + it('seeds the Codex spawn-window status for a CLI-created local worktree', async () => { + // Why: Codex emits no hook until its first prompt, so the runtime must seed + // the card status from launch metadata at spawn (#6643). + const seedSpy = vi.spyOn(agentHookServer, 'seedCodexLaunchStatus').mockImplementation(() => {}) + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli-codex-seed' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-cli-codex-seed' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term-cli-codex-seed', + tabId: 'tab-cli-codex-seed', + paneKey: 'tab-cli-codex-seed:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ptyId: 'pty-cli-codex-seed', + worktreeId: 'unused', + title: null, + surface: 'background' + } as never) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-cli-codex-seed') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-cli-codex-seed') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-cli-codex-seed', + head: 'def', + branch: 'runtime-cli-codex-seed', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'runtime-cli-codex-seed', + startupAgent: 'codex', + startupPrompt: 'hi' + }) + + expect(seedSpy).toHaveBeenCalledTimes(1) + expect(seedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: 'tab-cli-codex-seed:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + worktreeId: result.worktree.id, + prompt: 'hi' + }) + ) + }) + + it('keeps the startup-terminal success when Codex status seeding throws', async () => { + // Why: seeding is best-effort UI state — a failure after the terminal + // spawned must not surface as a startup-terminal failure warning. + const seedSpy = vi.spyOn(agentHookServer, 'seedCodexLaunchStatus').mockImplementation(() => { + throw new Error('seed boom') + }) + seedSpy.mockClear() + const metaById: Record = {} + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + agentCmdOverrides: {} + }), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli-codex-seed-throw' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-cli-codex-seed-throw' }), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term-cli-codex-seed-throw', + tabId: 'tab-cli-codex-seed-throw', + paneKey: 'tab-cli-codex-seed-throw:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ptyId: 'pty-cli-codex-seed-throw', + worktreeId: 'unused', + title: null, + surface: 'background' + } as never) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-cli-codex-seed-throw') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-cli-codex-seed-throw') + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-cli-codex-seed-throw', + head: 'def', + branch: 'runtime-cli-codex-seed-throw', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'runtime-cli-codex-seed-throw', + startupAgent: 'codex', + startupPrompt: 'hi' + }) + + expect(seedSpy).toHaveBeenCalledTimes(1) + expect(result.warning).toBeUndefined() + expect(result.startupTerminal).toMatchObject({ + spawned: true, + handle: 'term-cli-codex-seed-throw' + }) + }) + it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6c0f90c6f6be..5f878ff556ee 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -49,7 +49,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' -import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' +import { agentHookServer, type AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, @@ -21680,6 +21680,45 @@ export class OrcaRuntimeService { }) } + // Why: Codex fires no hook until its first prompt submission, so a + // runtime-built spawn (CLI/automation — no renderer pane to seed) shows no + // card status until the first turn (#6643). UI-built startups + // (startupProvided) seed through the renderer's initialAgentStatus path. + // Local only: remote pane status arrives relay-stamped with a connectionId, + // which a local-connection seed row would contradict. Best-effort: the + // startup terminal already spawned, so a seeding failure is logged instead + // of thrown — it must not surface as a startup-terminal failure. + private seedCodexLaunchStatusIfEligible(options: { + startupProvided: boolean + repo: { connectionId?: string | null } + createdWithAgent: TuiAgent | undefined + terminal: { paneKey?: string | null; tabId?: string | null } + worktreeId: string + prompt: string | undefined + }): void { + if ( + options.startupProvided || + repoIsRemote(options.repo) || + options.createdWithAgent !== 'codex' || + !options.terminal.paneKey + ) { + return + } + try { + agentHookServer.seedCodexLaunchStatus({ + paneKey: options.terminal.paneKey, + ...(options.terminal.tabId ? { tabId: options.terminal.tabId } : {}), + worktreeId: options.worktreeId, + ...(options.prompt ? { prompt: options.prompt } : {}) + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn( + `[worktree-create] Failed to seed the Codex launch status for ${options.worktreeId}: ${message}` + ) + } + } + async createManagedWorktree(args: { repoSelector: string name: string @@ -21862,6 +21901,14 @@ export class OrcaRuntimeService { ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), surface: 'background' } + this.seedCodexLaunchStatusIfEligible({ + startupProvided: Boolean(args.startup), + repo, + createdWithAgent: effectiveCreatedWithAgent, + terminal, + worktreeId: worktree.id, + prompt: agentStartup && args.startupPrompt ? args.startupPrompt : undefined + }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` @@ -22612,6 +22659,14 @@ export class OrcaRuntimeService { startupTerminalTabId = terminal.tabId ?? null startupTerminalPaneKey = terminal.paneKey ?? null startupTerminalPtyId = terminal.ptyId ?? null + this.seedCodexLaunchStatusIfEligible({ + startupProvided: Boolean(args.startup), + repo, + createdWithAgent: effectiveCreatedWithAgent, + terminal, + worktreeId: worktree.id, + prompt: agentStartup && args.startupPrompt ? args.startupPrompt : undefined + }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning