Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/main/agent-hooks/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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()
}
})
})
37 changes: 37 additions & 0 deletions src/main/agent-hooks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
type ParsedAgentStatusPayload,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import { buildLaunchStatusSeedPayload } from '../../shared/agent-launch-status-seed'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
Expand Down Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions src/main/runtime/orca-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
getSettings: () => ({ ...store.getSettings(), agentCmdOverrides: {} }),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
getSettings: () => ({ ...store.getSettings(), agentCmdOverrides: {} }),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta> = {}
const runtimeStore = {
Expand Down
Loading
Loading