diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts index 3e6d4a604ce..8e636e00efc 100644 --- a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -314,4 +314,67 @@ describe('AgentHookServer Codex subagent transcript polling', () => { first.stop() } }) + + it('keeps the final reconciled roster when marking an unresolved child unverifiable', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-final-reconcile-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const completedLaterPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + `${spawnLine(CHILD_ID, '/root/completes-late')}${spawnLine(SECOND_CHILD_ID, '/root/missing')}` + ) + writeFileSync( + completedLaterPath, + line({ type: 'event_msg', payload: { type: 'task_started' } }) + ) + const first = new AgentHookServer() + let server: AgentHookServer | undefined + await first.start({ env: 'production', userDataPath: dir }) + try { + const env = first.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({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + first.stop() + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) + setTimeout(() => { + appendFileSync( + completedLaterPath, + line({ type: 'event_msg', payload: { type: 'task_complete' } }) + ) + }, 3_500).unref() + await vi.waitFor( + () => { + const status = server?.getStatusSnapshot()[0] + expect(status?.reconcileDiagnostic).toEqual({ + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: expect.any(Number) + }) + expect(status?.subagents).toEqual([expect.objectContaining({ id: SECOND_CHILD_ID })]) + }, + { timeout: 7_000, interval: 100 } + ) + } finally { + server?.stop() + first.stop() + } + }) }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e171e6b3a7c..d6e28083800 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1730,6 +1730,7 @@ export class AgentHookServer { ...(terminal ? { state: 'done' as const } : {}), ...(subagents ? { subagents } : { subagents: undefined }) } + let latest = current if ( JSON.stringify(nextPayload) !== JSON.stringify(current.payload) || (terminal && current.restoredUnconfirmed) @@ -1742,6 +1743,7 @@ export class AgentHookServer { } this.state.lastStatusByPaneKey.set(paneKey, next) original = next + latest = next this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitEnrichedStatus(next) @@ -1751,10 +1753,10 @@ export class AgentHookServer { const unreadable = [...transcript.subagents.values()].some((child) => child.unresolvedSince) if ( unreadable && - (current.reconcileDiagnostic === undefined || current.reconcileDiagnostic === null) + (latest.reconcileDiagnostic === undefined || latest.reconcileDiagnostic === null) ) { const diagnostic: EnrichedAgentHookEventPayload = { - ...current, + ...latest, reconcileDiagnostic: { kind: 'unverifiable', reason: 'transcript-unreadable', diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 89c38a6003f..c6fc79cd253 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -597,6 +597,11 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolAgentType: 'Review', claudeRunningNonAgentTask: true, providerSessionOnly: true, + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'remote-contact-lost', + observedAt: 1234 + }, providerSession: { key: 'session_id', id: 'ssh-relay-session-1', @@ -625,11 +630,23 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { key: 'session_id', id: 'ssh-relay-session-1', transcriptPath: '/tmp/ssh-relay-session-1.jsonl' + }, + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'remote-contact-lost', + observedAt: 1234 } }), 'conn-hook-metadata' ) ) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'remote-contact-lost', + observedAt: 1234 + } + }) ingestSpy.mockRestore() }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fa..37ed5dca3d1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1507,6 +1507,7 @@ export class SshRelaySession { isReplay?: unknown providerSession?: unknown providerSessionOnly?: unknown + reconcileDiagnostic?: unknown shedFields?: unknown claudeRunningNonAgentTask?: unknown payload?: unknown @@ -1542,6 +1543,7 @@ export class SshRelaySession { isReplay: envelope.isReplay === true ? true : undefined, providerSession: envelope.providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + reconcileDiagnostic: envelope.reconcileDiagnostic, // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. shedFields: envelope.shedFields, claudeRunningNonAgentTask: diff --git a/src/relay/agent-hook-status-cache.test.ts b/src/relay/agent-hook-status-cache.test.ts new file mode 100644 index 00000000000..232e91a049f --- /dev/null +++ b/src/relay/agent-hook-status-cache.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createHookListenerState, type AgentHookEventPayload } from '../shared/agent-hook-listener' +import { applyRelayHookEvent } from './agent-hook-status-cache' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function event( + hookEventName: string, + reconcileDiagnostic?: AgentHookEventPayload['reconcileDiagnostic'] +): AgentHookEventPayload { + return { + paneKey: PANE_KEY, + source: 'codex', + connectionId: null, + hookEventName, + ...(reconcileDiagnostic !== undefined ? { reconcileDiagnostic } : {}), + payload: { state: 'working', prompt: 'prompt', agentType: 'codex' } + } +} + +describe('relay agent-hook status cache', () => { + it('clears an inherited Codex reconciliation diagnostic at SessionStart', () => { + const state = createHookListenerState() + const metadata = new Map() + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const options = { + state, + previous: undefined, + source: 'codex' as const, + metadata, + persist: vi.fn(), + clearPaneState: vi.fn(), + forward + } + + applyRelayHookEvent({ + ...options, + event: event('PostToolUse', { + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: 100 + }) + }) + const previous = state.lastStatusByPaneKey.get(PANE_KEY) + expect(previous?.reconcileDiagnostic).toBeDefined() + + applyRelayHookEvent({ + ...options, + previous, + event: event('SessionStart') + }) + + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.reconcileDiagnostic).toBeNull() + expect(forward.mock.calls.at(-1)?.[0].reconcileDiagnostic).toBeNull() + }) +}) diff --git a/src/relay/agent-hook-status-cache.ts b/src/relay/agent-hook-status-cache.ts index 7103c3a4b79..0182dfd91d5 100644 --- a/src/relay/agent-hook-status-cache.ts +++ b/src/relay/agent-hook-status-cache.ts @@ -34,10 +34,14 @@ export function applyRelayHookEvent(options: { clearPaneState: (paneKey: string) => void forward: (envelope: AgentHookRelayEnvelope) => void }): void { - const cachedEvent = resolveCachedClaudeCompactOwnership(options.previous, options.event) - options.state.lastStatusByPaneKey.delete(options.event.paneKey) - options.state.lastStatusByPaneKey.set(options.event.paneKey, cachedEvent) - options.metadata.set(options.event.paneKey, { + const diagnosticAwareEvent = + options.event.payload.agentType === 'codex' && options.event.hookEventName === 'SessionStart' + ? { ...options.event, reconcileDiagnostic: null } + : options.event + const cachedEvent = resolveCachedClaudeCompactOwnership(options.previous, diagnosticAwareEvent) + options.state.lastStatusByPaneKey.delete(diagnosticAwareEvent.paneKey) + options.state.lastStatusByPaneKey.set(diagnosticAwareEvent.paneKey, cachedEvent) + options.metadata.set(diagnosticAwareEvent.paneKey, { source: options.source, env: options.env, version: options.version