From 81498fd192bbb8aa33130944628c1d593dd9c3ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:42:36 -0700 Subject: [PATCH] fix(codex): seed the spawn-window agent status from launch metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI publishes no hook while its TUI idles — measured on codex-cli 0.147.0, a fresh or `codex resume` TUI posts zero hooks for 40s, and SessionStart only fires alongside the first UserPromptSubmit. A Codex pane therefore has no status row at all between spawn and the user's first message (#6643). Generalize the existing `paneStartup.initialAgentStatus` seam, which already covered Command Code's identical spawn window, into a declarative `seedsLaunchStatus` capability on TUI_AGENT_CONFIG and one shared payload builder. A submitted prompt seeds `working`; a promptless or draft launch seeds the idle `sessionBoundary` done row Claude's SessionStart already uses (STA-3386), so an idle TUI never spins a phantom spinner and completion-reactive consumers skip it. Runtime-spawned startup terminals (CLI create, automations, backend-spawned startups) mount no renderer pane, so main seeds those from the same builder via AgentHookServer.seedLaunchAgentStatus. Any existing row always wins. --- src/main/agent-hooks/server.test.ts | 168 ++++++++++++++++++ src/main/agent-hooks/server.ts | 37 ++++ src/main/runtime/orca-runtime.test.ts | 157 ++++++++++++++++ src/main/runtime/orca-runtime.ts | 61 ++++++- .../terminal-pane/pty-connection.test.ts | 76 ++++++++ .../terminal-pane/pty-connection.ts | 24 +-- src/renderer/src/hooks/useComposerState.ts | 10 +- ...gent-in-new-tab-launch-status-seed.test.ts | 125 +++++++++++++ .../src/lib/launch-agent-in-new-tab.ts | 16 +- .../worktree-creation-flow-startup.test.ts | 73 ++++++++ .../src/lib/worktree-creation-flow-startup.ts | 15 +- src/shared/agent-launch-status-seed.test.ts | 54 ++++++ src/shared/agent-launch-status-seed.ts | 34 ++++ src/shared/tui-agent-config.ts | 12 +- 14 files changed, 839 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-launch-status-seed.test.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow-startup.test.ts create mode 100644 src/shared/agent-launch-status-seed.test.ts create mode 100644 src/shared/agent-launch-status-seed.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 23aef22a7b4..0212a006ee6 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -9173,3 +9173,171 @@ describe('AgentHookServer closed-tab suppression bound', () => { expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) }) }) + +describe('seedLaunchAgentStatus', () => { + const SEED = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', agentType: 'codex' as const } + + it('publishes a working row at spawn, before any hook arrives', () => { + const server = new AgentHookServer() + + server.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + prompt: 'say hi', + agentType: 'codex' + }) + ]) + }) + + it('publishes an idle session-boundary row for a promptless spawn', () => { + const server = new AgentHookServer() + + server.seedLaunchAgentStatus({ ...SEED, prompt: '' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: '', + agentType: 'codex', + sessionBoundary: true + }) + ]) + }) + + it('stays one row under duplicate seeds and notifies once', () => { + const server = new AgentHookServer() + const changeListener = vi.fn() + server.subscribeStatusChanges(changeListener) + + server.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + const afterFirst = server.getStatusSnapshot() + server.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual(afterFirst) + expect(changeListener).toHaveBeenCalledTimes(1) + }) + + it('never overwrites a status the pane already reported', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' })) + }) + + server.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', agentType: 'codex' }) + ]) + } finally { + server.stop() + } + }) + + it('does not resurrect a retired pane', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + + server.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('lets the first real turn replace the seed and keeps its 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.seedLaunchAgentStatus({ ...SEED, prompt: '' }) + + // Measured on codex-cli 0.147: SessionStart never arrives while the TUI is + // idle — it fires alongside the first UserPromptSubmit, on the same 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', + sessionBoundary: undefined, + 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!', + sessionBoundary: undefined + }) + ]) + } finally { + server.stop() + } + }) + + it('stays a single row when the same hook event is delivered twice', 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.seedLaunchAgentStatus({ ...SEED, prompt: 'say hi' }) + + // Two managed hook installs (user home + per-account home) can both deliver. + 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 f819414ad4b..9b4a3f0962a 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -63,6 +63,7 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { buildLaunchStatusSeedPayload } from '../../shared/agent-launch-status-seed' import { resolveAgentStatusIdentity, shouldSuppressInheritedTerminalStatus @@ -1788,6 +1789,42 @@ export class AgentHookServer { } } + /** + * Publish the spawn-window row for a runtime-spawned pane whose agent stays + * hook-silent until its first prompt (#6643). Renderer-mounted panes seed + * themselves from `paneStartup.initialAgentStatus`; a runtime-spawned PTY has + * no such pane, so main seeds it from the same launch metadata. + * + * Any existing row wins — a real hook or OSC status that already landed (a + * `PermissionRequest` waiting, say) is never overwritten, and later events + * replace the seed normally. + */ + seedLaunchAgentStatus(seed: { + paneKey: string + tabId?: string + worktreeId?: string + agentType: AgentType + prompt: string + }): void { + const paneKey = this.resolvePaneKeyAlias(seed.paneKey.trim()) + if (this.state.lastStatusByPaneKey.has(paneKey)) { + return + } + const payload = normalizeAgentStatusPayload( + buildLaunchStatusSeedPayload(seed.agentType, seed.prompt) + ) + if (!payload) { + return + } + this.ingestTerminalStatus({ + paneKey: seed.paneKey, + ...(seed.tabId ? { tabId: seed.tabId } : {}), + ...(seed.worktreeId ? { worktreeId: seed.worktreeId } : {}), + connectionId: null, + payload + }) + } + ingestTerminalStatus(event: { paneKey: string tabId?: string diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3ee35d809df..c7aedc9e67b 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' @@ -44293,6 +44294,162 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) }) + it('seeds the spawn-window Codex status for a CLI-created local worktree', async () => { + // Why: a runtime-spawned startup terminal mounts no renderer pane, so nothing + // else covers Codex's hook-silent spawn window (#6643). + const seedSpy = vi.spyOn(agentHookServer, 'seedLaunchAgentStatus').mockImplementation(() => {}) + // Why: vi.spyOn reuses an existing spy on the shared agentHookServer singleton, + // so a sibling test's call would otherwise carry into this count. + 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) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-cli-codex-seed' }), + 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({ + paneKey: 'tab-cli-codex-seed:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + tabId: 'tab-cli-codex-seed', + worktreeId: result.worktree.id, + agentType: 'codex', + prompt: 'hi' + }) + }) + + it('keeps the startup terminal successful when Codex status seeding throws', async () => { + // Why: seeding is best-effort presence state — the terminal already spawned, + // so a seeding failure must not surface as a startup-terminal warning. + const seedSpy = vi.spyOn(agentHookServer, 'seedLaunchAgentStatus').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) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-cli-codex-seed-throw' }), + 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 7f3697454be..e9c515cb3e2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -52,7 +52,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, @@ -453,6 +453,7 @@ import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' +import { agentSeedsLaunchStatus } from '../../shared/agent-launch-status-seed' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -22193,6 +22194,44 @@ export class OrcaRuntimeService { }) } + /** + * Seed the spawn-window status row for a startup terminal the runtime spawned + * itself. Renderer-mounted panes seed from `paneStartup.initialAgentStatus`, + * but a runtime-spawned PTY (CLI create, automation, backend-spawned startup) + * mounts no such pane, so nothing else covers Codex's hook-silent spawn window + * (#6643). Best-effort: the terminal already spawned, so a seeding failure is + * logged rather than reported as a startup-terminal failure. + * + * Local repos only — a remote pane's rows arrive relay-stamped with a + * connectionId that this local-connection row would contradict. + */ + private seedRuntimeLaunchAgentStatus(options: { + repo: { connectionId?: string | null } + agent: TuiAgent | undefined + paneKey: string | null | undefined + tabId: string | null | undefined + worktreeId: string + prompt: string + }): void { + if (!agentSeedsLaunchStatus(options.agent) || repoIsRemote(options.repo) || !options.paneKey) { + return + } + try { + agentHookServer.seedLaunchAgentStatus({ + paneKey: options.paneKey, + ...(options.tabId ? { tabId: options.tabId } : {}), + worktreeId: options.worktreeId, + agentType: options.agent, + prompt: options.prompt + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn( + `[worktree-create] Failed to seed the launch status for ${options.worktreeId}: ${message}` + ) + } + } + async createManagedWorktree(args: { repoSelector: string name: string @@ -22375,6 +22414,16 @@ export class OrcaRuntimeService { ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), surface: 'background' } + this.seedRuntimeLaunchAgentStatus({ + repo, + agent: effectiveCreatedWithAgent, + paneKey: terminal.paneKey, + tabId: terminal.tabId, + worktreeId: worktree.id, + // Why: a draft paste leaves the prompt unsent, so it seeds the idle + // presence row rather than claiming a turn is already running. + prompt: effectiveDraftPaste || !agentStartup ? '' : (args.startupPrompt ?? '') + }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` @@ -23125,6 +23174,16 @@ export class OrcaRuntimeService { startupTerminalTabId = terminal.tabId ?? null startupTerminalPaneKey = terminal.paneKey ?? null startupTerminalPtyId = terminal.ptyId ?? null + this.seedRuntimeLaunchAgentStatus({ + repo, + agent: effectiveCreatedWithAgent, + paneKey: terminal.paneKey, + tabId: terminal.tabId, + worktreeId: worktree.id, + // Why: a draft paste leaves the prompt unsent, so it seeds the idle + // presence row rather than claiming a turn is already running. + prompt: effectiveDraftPaste || !agentStartup ? '' : (args.startupPrompt ?? '') + }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 98a26a1d6a0..8572190a5d3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4127,6 +4127,82 @@ describe('connectPanePty', () => { ) }) + it('seeds a Codex spawn row over SSH before any hook arrives', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('ssh-a', 'pty-codex-seed') + const transport = createMockTransport(sshPtyId) + transport.getConnectionId.mockReturnValue('ssh-a') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-a' }], + sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]) + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: "codex 'Fix the status'", + initialAgentStatus: { agent: 'codex', prompt: 'Fix the status' } + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(sshPtyId) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { state: 'working', prompt: 'Fix the status', agentType: 'codex' }, + undefined, + undefined, + { connectionId: 'ssh-a' } + ) + }) + + it('seeds a promptless Codex spawn as an idle session-boundary row, not a spinner', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('ssh-a', 'pty-codex-idle-seed') + const transport = createMockTransport(sshPtyId) + transport.getConnectionId.mockReturnValue('ssh-a') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'ssh-a' }], + sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]) + } + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'codex', + initialAgentStatus: { agent: 'codex', prompt: '' } + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(sshPtyId) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { state: 'done', prompt: '', agentType: 'codex', sessionBoundary: true }, + undefined, + undefined, + { connectionId: 'ssh-a' } + ) + }) + it('seeds a working status from Command Code thinking output without a startup prompt', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 14a514cfee5..d22e80330a8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -304,6 +304,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { buildLaunchStatusSeedPayload } from '../../../../shared/agent-launch-status-seed' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { @@ -1292,6 +1293,9 @@ export function connectPanePty( const launchToken = paneStartup?.launchConfig ? (paneStartup.launchToken ?? createBrowserUuid()) : undefined + // Why: the seed's prompt is empty when the launch does not submit one; only a + // non-empty prompt means "this launch already started a turn". + const submittedInitialStatusPrompt = paneStartup?.initialAgentStatus?.prompt || undefined const startupDraftAgent = paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent const startupDraftAgentConfig = startupDraftAgent ? TUI_AGENT_CONFIG[startupDraftAgent] : null const startupDraftPrompt = @@ -2863,14 +2867,10 @@ export function connectPanePty( if (!initialStatus || !routing) { return } - const statusPayload = { - state: 'working' as const, - prompt: initialStatus.prompt, - agentType: resolveCompatibleAgentTypeForOwner( - initialStatus.agent, - getAuthoritativePaneAgent() - ) - } + const statusPayload = buildLaunchStatusSeedPayload( + resolveCompatibleAgentTypeForOwner(initialStatus.agent, getAuthoritativePaneAgent()), + initialStatus.prompt + ) if (paneStartup.launchConfig) { useAppStore .getState() @@ -3835,10 +3835,12 @@ export function connectPanePty( ...(paneStartup?.resumeProviderSession ? { resumeProviderSession: paneStartup.resumeProviderSession } : {}), - ...((paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt) - ? { agentPrompt: paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt } + // Why: a promptless launch seed carries an empty prompt (it only asks for an + // idle presence row), so it must not shadow the pane's unsent draft here. + ...((submittedInitialStatusPrompt ?? paneStartup?.draftPrompt) + ? { agentPrompt: submittedInitialStatusPrompt ?? paneStartup?.draftPrompt } : {}), - ...(paneStartup?.initialAgentStatus?.prompt + ...(submittedInitialStatusPrompt ? { agentPromptDelivery: 'auto-submit' as const } : paneStartup?.draftPrompt ? { agentPromptDelivery: 'draft' as const } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 1b26a98dfe5..9c0f05dcb80 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -52,6 +52,7 @@ import type { } from '../../../shared/orca-yaml-hook-types' import type { ProjectGroup } from '../../../shared/project-group-types' import type { TuiAgent } from '../../../shared/tui-agent' +import { agentSeedsLaunchStatus } from '../../../shared/agent-launch-status-seed' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { SetupDecision, SparsePreset } from '../../../shared/worktree/create-types' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' @@ -3812,8 +3813,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote }) - const shouldSeedInitialAgentStatus = - tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0 + // Why: these agents publish no hook until their first prompt, so the new + // workspace's tab status stays empty for the whole spawn window unless + // launch metadata seeds it. An unsent draft seeds the idle row, not `working`. + const shouldSeedInitialAgentStatus = agentSeedsLaunchStatus(tuiAgent) + const seededStartupPrompt = startupPlan?.draftPrompt ? '' : submitStartupPrompt.trim() // Why: backend startup is safe only for self-contained launch commands; agents needing post-ready paste stay on the renderer path. const composerTelemetry: AgentStartedTelemetry = { @@ -3933,7 +3937,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? { initialAgentStatus: { agent: tuiAgent, - prompt: submitStartupPrompt.trim() + prompt: seededStartupPrompt } } : {}), diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-launch-status-seed.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-launch-status-seed.test.ts new file mode 100644 index 00000000000..a024db00aaf --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-launch-status-seed.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockQueueTabStartupCommand = vi.fn() + +const store = { + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null as string | null + }, + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + allWorktrees: vi.fn(() => [{ id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree' }]), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + createTab: vi.fn(() => ({ id: 'tab-1' })), + queueTabInitialCwd: vi.fn(), + queueTabStartupCommand: mockQueueTabStartupCommand, + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn(), + seedNativeChatLaunchDraft: vi.fn() +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => null })) +vi.mock('@/lib/native-chat-transcript-readability', () => ({ + isNativeChatTranscriptLocalReadable: () => true +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: () => false, + isWebTerminalSurfaceTabId: () => false +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: vi.fn().mockResolvedValue(true) +})) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: (_stored: unknown, terminalIds: string[]) => terminalIds +})) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ + seedNativeChatAppliedSessionOptions: vi.fn() +})) + +// Why: Codex posts no hook while its TUI idles (measured on codex-cli 0.147 — +// SessionStart fires only alongside the first UserPromptSubmit), so the launch +// must carry the spawn-window status itself or the pane stays absent (#6643). +describe('launchAgentInNewTab launch-status seed', () => { + beforeEach(() => { + vi.clearAllMocks() + store.createTab.mockReturnValue({ id: 'tab-1' }) + }) + + it('queues a working seed for a Codex argv prompt launch', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', prompt: 'fix the spinner' }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + initialAgentStatus: { agent: 'codex', prompt: 'fix the spinner' } + }) + ) + }) + + it('queues a promptless Codex seed so the pane is present at spawn', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ initialAgentStatus: { agent: 'codex', prompt: '' } }) + ) + }) + + it('seeds an unsent Codex draft as presence only, never as a running turn', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + promptDelivery: 'draft' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ initialAgentStatus: { agent: 'codex', prompt: '' } }) + ) + }) + + it('keeps seeding Command Code, which shares the hook-silent spawn window', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'command-code', worktreeId: 'wt-1', prompt: 'fix the spinner' }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + initialAgentStatus: { agent: 'command-code', prompt: 'fix the spinner' } + }) + ) + }) + + it('leaves agents that publish a startup row unseeded', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1', prompt: 'fix the spinner' }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.not.objectContaining({ initialAgentStatus: expect.anything() }) + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d46cb530bd6..331aff9ed88 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -22,6 +22,7 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { agentSeedsLaunchStatus } from '../../../shared/agent-launch-status-seed' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' import type { TuiAgent } from '../../../shared/tui-agent' @@ -195,8 +196,19 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - ...(agent === 'command-code' && hasPrompt && promptDelivery === 'auto-submit' - ? { initialAgentStatus: { agent, prompt: trimmedPrompt } } + // Why: these agents publish no hook until their first prompt, so the tab + // status stays empty for the whole spawn window unless launch metadata + // seeds it. An unsent draft seeds the idle row, not `working`. + ...(agentSeedsLaunchStatus(agent) + ? { + initialAgentStatus: { + agent, + prompt: + hasPrompt && promptDelivery === 'auto-submit' && !startupPlan.draftPrompt + ? trimmedPrompt + : '' + } + } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(agent), diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.test.ts b/src/renderer/src/lib/worktree-creation-flow-startup.test.ts new file mode 100644 index 00000000000..ecbf711f26f --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-startup.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import { buildWorktreeCreationStartupOpt } from './worktree-creation-flow-startup' + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: {} }) } })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }) +})) + +function makeRequest(overrides: Partial): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'wt', + quickPrompt: '', + startupPlan: { launchCommand: 'codex', launchConfig: undefined }, + ...overrides + } as WorktreeCreationRequest +} + +// Why: Codex posts no hook while its TUI idles (measured on codex-cli 0.147 — +// SessionStart fires only alongside the first UserPromptSubmit), so a new +// workspace's first pane stays absent from the status surface unless the launch +// metadata carries the spawn-window row (#6643). +describe('buildWorktreeCreationStartupOpt launch-status seed', () => { + it('seeds a working row for a Codex workspace created with a prompt', () => { + const startup = buildWorktreeCreationStartupOpt( + makeRequest({ agent: 'codex', quickPrompt: ' fix the spinner ' }), + false + ) + + expect(startup?.initialAgentStatus).toEqual({ agent: 'codex', prompt: 'fix the spinner' }) + }) + + it('seeds a promptless Codex workspace so the pane is present at spawn', () => { + const startup = buildWorktreeCreationStartupOpt(makeRequest({ agent: 'codex' }), false) + + expect(startup?.initialAgentStatus).toEqual({ agent: 'codex', prompt: '' }) + }) + + it('does not claim a running turn when the prompt rides as an unsent draft', () => { + const startup = buildWorktreeCreationStartupOpt( + makeRequest({ + agent: 'codex', + quickPrompt: 'fix the spinner', + startupPlan: { + launchCommand: 'codex', + draftPrompt: 'fix the spinner' + } as WorktreeCreationRequest['startupPlan'] + }), + false + ) + + expect(startup?.initialAgentStatus).toEqual({ agent: 'codex', prompt: '' }) + }) + + it('leaves agents that publish a startup row unseeded', () => { + const startup = buildWorktreeCreationStartupOpt( + makeRequest({ agent: 'claude', quickPrompt: 'fix the spinner' }), + false + ) + + expect(startup?.initialAgentStatus).toBeUndefined() + }) + + it('seeds nothing when the backend already spawned the first terminal', () => { + const startup = buildWorktreeCreationStartupOpt( + makeRequest({ agent: 'codex', quickPrompt: 'fix the spinner' }), + true + ) + + expect(startup).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 4c946e0cf63..a2b4bbedd2c 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { agentSeedsLaunchStatus } from '../../../shared/agent-launch-status-seed' import type { WorktreeStartupPayload } from '@/lib/worktree-activation' import type { WorktreeCreationPhase, @@ -27,10 +28,16 @@ export function buildWorktreeCreationStartupOpt( // the sole signal that this launch starts with unsent context in the TUI. ...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), - // Why: command-code shows its prompt in the tab status before the first - // hook fires, so the prompt is threaded through here. - ...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0 - ? { initialAgentStatus: { agent: request.agent, prompt: request.quickPrompt.trim() } } + // Why: these agents publish no hook until their first prompt, so the tab + // status stays empty for the whole spawn window unless launch metadata + // seeds it. An unsent draft seeds the idle row, not `working`. + ...(agentSeedsLaunchStatus(request.agent) + ? { + initialAgentStatus: { + agent: request.agent, + prompt: plan.draftPrompt || request.launchDraftPrompt ? '' : request.quickPrompt.trim() + } + } : {}), ...(request.quickTelemetry ? { telemetry: request.quickTelemetry } : {}) } diff --git a/src/shared/agent-launch-status-seed.test.ts b/src/shared/agent-launch-status-seed.test.ts new file mode 100644 index 00000000000..9c7c47a976d --- /dev/null +++ b/src/shared/agent-launch-status-seed.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { agentSeedsLaunchStatus, buildLaunchStatusSeedPayload } from './agent-launch-status-seed' + +describe('agentSeedsLaunchStatus', () => { + it('covers Codex, whose idle TUI posts no hook until the first prompt', () => { + expect(agentSeedsLaunchStatus('codex')).toBe(true) + }) + + it('keeps covering Command Code', () => { + expect(agentSeedsLaunchStatus('command-code')).toBe(true) + }) + + it('excludes agents that publish a SessionStart row at TUI open', () => { + expect(agentSeedsLaunchStatus('claude')).toBe(false) + expect(agentSeedsLaunchStatus('gemini')).toBe(false) + }) + + it('is false for an unknown launch agent', () => { + expect(agentSeedsLaunchStatus(undefined)).toBe(false) + }) +}) + +describe('buildLaunchStatusSeedPayload', () => { + it('reports working when the launch submits a prompt', () => { + expect(buildLaunchStatusSeedPayload('codex', 'say hi')).toEqual({ + state: 'working', + prompt: 'say hi', + agentType: 'codex' + }) + }) + + it('trims the submitted prompt', () => { + expect(buildLaunchStatusSeedPayload('codex', ' say hi ')).toMatchObject({ + state: 'working', + prompt: 'say hi' + }) + }) + + it('lands an idle session-boundary row when the launch submits nothing', () => { + expect(buildLaunchStatusSeedPayload('codex', '')).toEqual({ + state: 'done', + prompt: '', + agentType: 'codex', + sessionBoundary: true + }) + }) + + it('treats a whitespace-only prompt as no prompt, so no phantom spinner runs', () => { + expect(buildLaunchStatusSeedPayload('codex', ' \n ')).toMatchObject({ + state: 'done', + sessionBoundary: true + }) + }) +}) diff --git a/src/shared/agent-launch-status-seed.ts b/src/shared/agent-launch-status-seed.ts new file mode 100644 index 00000000000..d074a8498f5 --- /dev/null +++ b/src/shared/agent-launch-status-seed.ts @@ -0,0 +1,34 @@ +import type { AgentType, ParsedAgentStatusPayload } from './agent-status-types' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' + +/** Launch metadata for a pane whose agent publishes no hook until its first prompt. + * `prompt` is empty when the launch does not submit one. */ +export type AgentLaunchStatusSeed = { agent: TuiAgent; prompt: string } + +/** + * True when the agent's hook stream stays silent until the user's first prompt, + * so Orca must seed the spawn-window row from launch metadata instead (#6643). + */ +export function agentSeedsLaunchStatus(agent: TuiAgent | null | undefined): agent is TuiAgent { + return agent != null && TUI_AGENT_CONFIG[agent].seedsLaunchStatus === true +} + +/** + * The status row a launch publishes before any hook arrives. + * + * A submitted prompt means the turn is already running, so the row is `working`. + * A promptless launch lands the same idle session-boundary `done` row Claude's + * SessionStart uses (STA-3386): `working` would spin on an idle TUI, and + * `sessionBoundary` keeps completion-reactive consumers (notifications, + * automation runs, unread badges) out of it. + */ +export function buildLaunchStatusSeedPayload( + agentType: AgentType | undefined, + prompt: string +): ParsedAgentStatusPayload { + const trimmed = prompt.trim() + return trimmed + ? { state: 'working', prompt: trimmed, ...(agentType ? { agentType } : {}) } + : { state: 'done', prompt: '', ...(agentType ? { agentType } : {}), sessionBoundary: true } +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ea7cb092c68..6d612edd3d9 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -44,6 +44,10 @@ export type TuiAgentConfig = { windowsShiftEnterEncoding?: 'csi-u' /** Ctrl+Enter encoding for agents that consume CSI-u without active kitty flags. */ ctrlEnterEncoding?: 'csi-u' + /** This agent publishes no hook until the user's first prompt, so a launched + * pane has no status row during the spawn window. Orca seeds one from launch + * metadata instead — see agent-launch-status-seed.ts (#6643). */ + seedsLaunchStatus?: true } export const TUI_AGENT_CONFIG: Record = { @@ -84,7 +88,10 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'codex', promptInjectionMode: 'argv', preflightTrust: 'codex', - draftPasteReadySignal: 'codex-composer-prompt' + draftPasteReadySignal: 'codex-composer-prompt', + // Why: measured on codex-cli 0.147 — an idle TUI (fresh or `resume`) posts zero + // hooks; SessionStart fires only alongside the first UserPromptSubmit. + seedsLaunchStatus: true }, autohand: { detectCmd: 'autohand', @@ -231,7 +238,8 @@ export const TUI_AGENT_CONFIG: Record = { // Why: `--trust` skips the first-run trust prompt so it doesn't consume the task text. launchCmd: 'command-code --trust', expectedProcess: 'command-code', - promptInjectionMode: 'argv' + promptInjectionMode: 'argv', + seedsLaunchStatus: true }, continue: { // Why: Continue's CLI binary is `cn`; `continue` is a bash/zsh builtin and would resolve to the shell keyword.