Skip to content
Closed
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
175 changes: 166 additions & 9 deletions src/main/agent-hooks/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2349,6 +2349,44 @@ describe('AgentHookServer listener replay', () => {
}
})

it('broadcasts a clear when Codex SessionStart replaces a same-pane status', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const statusListener = vi.fn()
const clearListener = vi.fn()
const changeListener = vi.fn()
server.setListener(statusListener)
server.setPaneStatusClearListener(clearListener)
server.subscribeStatusChanges(changeListener)
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))
})

await postCodexHook({ hook_event_name: 'UserPromptSubmit', prompt: 'old session' })
await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'new-session' })
await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'new-session' })

expect(server.getStatusSnapshot()).toEqual([])
expect(statusListener).toHaveBeenCalledTimes(1)
expect(clearListener).toHaveBeenCalledTimes(1)
expect(clearListener).toHaveBeenCalledWith(PANE)
expect(changeListener).toHaveBeenLastCalledWith([])
const replayListener = vi.fn()
server.setListener(replayListener)
expect(replayListener).not.toHaveBeenCalled()
} finally {
server.stop()
}
})

it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
Expand Down Expand Up @@ -4208,7 +4246,7 @@ describe('Codex hook normalization', () => {
expect(result?.payload.toolInput).toBeUndefined()
})

it('SessionStart clears cached tool state from a prior session', () => {
it('SessionStart clears cached tool state without reporting working', () => {
// Seed a Stop snapshot with an assistant message.
_internals.normalizeHookPayload(
'codex',
Expand All @@ -4218,13 +4256,20 @@ describe('Codex hook normalization', () => {
}),
'production'
)
const result = _internals.normalizeHookPayload(
const started = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SessionStart' }),
buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-next' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.lastAssistantMessage).toBeUndefined()
const prompted = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'Next turn' }),
'production'
)
expect(started).toBeNull()
expect(prompted?.payload.state).toBe('working')
expect(prompted?.payload.lastAssistantMessage).toBeUndefined()
expect(prompted?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-next' })
})

it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => {
Expand All @@ -4236,13 +4281,24 @@ describe('Codex hook normalization', () => {
}),
'production'
)
const result = _internals.normalizeHookPayload(
const started = _internals.normalizeHookPayload(
'codex',
buildBody({ hook_event_name: 'SessionStart' }),
buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-fresh' }),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.prompt).toBe('')
const nextTool = _internals.normalizeHookPayload(
'codex',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'pwd' }
}),
'production'
)
expect(started).toBeNull()
expect(nextTool?.payload.state).toBe('working')
expect(nextTool?.payload.prompt).toBe('')
expect(nextTool?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-fresh' })
})
})

Expand Down Expand Up @@ -6410,6 +6466,107 @@ describe('Last-status persistence', () => {
})

describe('AgentHookServer ingestRemote', () => {
it('treats a relayed Codex SessionStart control event as an idempotent clear', () => {
const server = new AgentHookServer()
const clearListener = vi.fn()
const changeListener = vi.fn()
server.setPaneStatusClearListener(clearListener)
server.subscribeStatusChanges(changeListener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'remote old session', agentType: 'codex' }
},
'conn-1'
)
const clearEnvelope = {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'SessionStart',
payload: { state: 'done' as const, prompt: '', agentType: 'codex' as const }
}

server.ingestRemote(clearEnvelope, 'conn-1')
server.ingestRemote(clearEnvelope, 'conn-1')

expect(server.getStatusSnapshot()).toEqual([])
expect(clearListener).toHaveBeenCalledTimes(1)
expect(clearListener).toHaveBeenCalledWith(PANE)
expect(changeListener).toHaveBeenLastCalledWith([])
})

it('does not let a relayed Codex SessionStart clear a different agent status', () => {
const server = new AgentHookServer()
const clearListener = vi.fn()
server.setPaneStatusClearListener(clearListener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'parent session', agentType: 'claude' }
},
'conn-1'
)

server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'SessionStart',
payload: { state: 'done', prompt: '', agentType: 'codex' }
},
'conn-1'
)

expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: 'parent session', agentType: 'claude' })
])
expect(clearListener).not.toHaveBeenCalled()
})

it('does not let a stale connection clear a newer Codex status', () => {
const server = new AgentHookServer()
const clearListener = vi.fn()
server.setPaneStatusClearListener(clearListener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'new connection status', agentType: 'codex' }
},
'conn-new'
)
const clearEnvelope = {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'SessionStart',
payload: { state: 'done' as const, prompt: '', agentType: 'codex' as const }
}

server.ingestRemote(clearEnvelope, 'conn-old')

expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'new connection status',
agentType: 'codex',
connectionId: 'conn-new'
})
])
expect(clearListener).not.toHaveBeenCalled()

server.ingestRemote(clearEnvelope, 'conn-new')
expect(server.getStatusSnapshot()).toEqual([])
expect(clearListener).toHaveBeenCalledTimes(1)
})

it('stamps connectionId and forwards a valid relay envelope to the listener', () => {
const server = new AgentHookServer()
const payload = parseAgentStatusPayload(
Expand Down
51 changes: 51 additions & 0 deletions src/main/agent-hooks/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,17 @@ function trackEmptyPaneKeyHook(body: unknown): void {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
}

function hookBodyPaneKey(body: unknown): string | null {
if (typeof body !== 'object' || body === null) {
return null
}
const paneKey = (body as Record<string, unknown>).paneKey
if (typeof paneKey !== 'string') {
return null
}
return paneKey.trim() || null
}

function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean {
if (next.payload.state !== 'working') {
return false
Expand Down Expand Up @@ -830,6 +841,31 @@ export class AgentHookServer {
return enriched
}

private clearStatusForSessionStart(
paneKey: string,
previousStatus?: AgentHookEventPayload,
expectedConnectionId?: string
): void {
const status = previousStatus ?? this.state.lastStatusByPaneKey.get(paneKey)
// Why: a delayed relay notification must not clear a newer remote target's
// status if pane identity is ever reused across logical connections.
if (
status?.payload.agentType !== 'codex' ||
(expectedConnectionId !== undefined && status.connectionId !== expectedConnectionId)
) {
return
}
this.state.lastStatusByPaneKey.delete(paneKey)
// Why: SessionStart is an idle metadata boundary, so remove the stale row
// without emitting a synthetic visible status for the new session.
this.clearAssistantMessageRetry(paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onPaneStatusCleared?.(paneKey)
}

private clearAssistantMessageRetry(paneKey: string): void {
const timer = this.assistantMessageRetryTimers.get(paneKey)
if (!timer) {
Expand Down Expand Up @@ -1210,6 +1246,12 @@ export class AgentHookServer {
env: envelope.env,
expectedEnv: this.env
})
if (hookEventName === 'SessionStart' && normalizedPayload.agentType === 'codex') {
// New relays encode the metadata-only SessionStart as an empty done frame;
// pre-fix relays may encode it as working. Neither should become visible.
this.clearStatusForSessionStart(paneKey, undefined, trimmedConnectionId)
return
}
const event: AgentHookEventPayload = {
paneKey,
launchToken: envelope.launchToken,
Expand Down Expand Up @@ -1293,10 +1335,14 @@ export class AgentHookServer {

trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const paneKey = hookBodyPaneKey(aliasedBody)
const previousStatus = paneKey ? this.state.lastStatusByPaneKey.get(paneKey) : undefined
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
const enriched = this.applyNormalizedStatus(normalized)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
} else if (paneKey && previousStatus && !this.state.lastStatusByPaneKey.has(paneKey)) {
this.clearStatusForSessionStart(paneKey, previousStatus)
}

res.writeHead(204)
Expand Down Expand Up @@ -1409,6 +1455,11 @@ export class AgentHookServer {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.lastProviderSessionByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
Expand Down
4 changes: 4 additions & 0 deletions src/main/codex/codex-hook-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const CODEX_HOOK_EVENT_LABEL: Record<string, CodexEventLabel> = {
PreToolUse: 'pre_tool_use',
PermissionRequest: 'permission_request',
PostToolUse: 'post_tool_use',
SubagentStart: 'subagent_start',
SubagentStop: 'subagent_stop',
Stop: 'stop',
PreCompact: 'pre_compact',
PostCompact: 'post_compact'
Expand All @@ -22,6 +24,8 @@ export const CODEX_EVENT_NAME_BY_LABEL: Record<CodexEventLabel, string> = {
pre_tool_use: 'PreToolUse',
permission_request: 'PermissionRequest',
post_tool_use: 'PostToolUse',
subagent_start: 'SubagentStart',
subagent_stop: 'SubagentStop',
stop: 'Stop',
pre_compact: 'PreCompact',
post_compact: 'PostCompact'
Expand Down
Loading
Loading