diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index e98caaf5f83..169688ac5d3 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -6434,6 +6434,26 @@ describe('Last-status persistence', () => { }) ]) + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: '11111111-2222-4333-8444-555555555555', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test' } + }), + '/hook/codex' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + model: 'gpt-5.4', + subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })] + }) + ]) + await postHookEvent( server, buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index fa50511ca44..7fe09e92733 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -26,7 +26,7 @@ import { resolveHookSource, preparePendingGrokResultDiscovery, seedClaudeSubagentRosterFromSnapshots, - seedCodexSubagentRosterFromSnapshots, + seedCodexStateFromSnapshot, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -1830,20 +1830,14 @@ export class AgentHookServer { ) } // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. - if (entry.payload.subagents) { - if (entry.payload.agentType === 'codex') { - seedCodexSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } else if (entry.payload.agentType === 'claude') { - seedClaudeSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude' && entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) } hydrated += 1 } else { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7f09d8d7a6c..5109c66f4af 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -19,12 +19,14 @@ import { import { isAbsolute, join } from 'node:path' import { + AGENT_MODEL_MAX_LENGTH, normalizeAgentStatusPayload, parseAgentStatusPayload, type AgentStatusState, type AgentSubagentSnapshot, type ParsedAgentStatusPayload } from './agent-status-types' +import { normalizeOptionalField } from './agent-status-field-normalization' import { isAskUserQuestionTool } from './agent-question-answered-intent' import { claudeRosterHasWorkingSubagent, @@ -3037,15 +3039,29 @@ function getOrCreateCodexSubagentRoster( return roster } -export function seedCodexSubagentRosterFromSnapshots( +export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, - snapshots: readonly AgentSubagentSnapshot[] + payload: Pick ): void { - if (snapshots.length === 0 || state.codexSubagentRosterByPaneKey.has(paneKey)) { - return + const snapshots = payload.subagents ?? [] + if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) + } + if (!state.codexLeadStateByPaneKey.has(paneKey)) { + // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. + state.codexLeadStateByPaneKey.set(paneKey, { + // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. + state: + payload.state === 'done' + ? 'done' + : payload.state === 'waiting' && + !snapshots.some((snapshot) => snapshot.state === 'waiting') + ? 'waiting' + : 'working', + model: payload.model + }) } - seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) } function buildCodexStatusPayload( @@ -3175,7 +3191,7 @@ function normalizeCodexEvent( state.codexLeadStateByPaneKey.set(paneKey, { state: stateName, model: - readString(hookPayload, 'model') ?? + normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) const effectiveState = codexRosterEffectiveState( diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts new file mode 100644 index 00000000000..42f074f0fd6 --- /dev/null +++ b/src/shared/codex-subagent-roster.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import { + codexRosterToSnapshots, + finishCodexSubagent, + upsertCodexSubagent, + type CodexSubagentRoster +} from './codex-subagent-roster' + +describe('Codex subagent roster', () => { + it('normalizes retained identity fields before storing them', () => { + const roster: CodexSubagentRoster = new Map() + + upsertCodexSubagent( + roster, + ' child-1 ', + { + agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`, + model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`, + state: 'working' + }, + 10 + ) + + const snapshot = codexRosterToSnapshots(roster)?.[0] + expect([...roster.keys()]).toEqual(['child-1']) + expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH) + expect(snapshot?.agentType).not.toContain('\n') + expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) + + finishCodexSubagent(roster, ' child-1 ') + expect(roster.size).toBe(0) + }) + + it('rejects an id that would normalize to an invisible child', () => { + const roster: CodexSubagentRoster = new Map() + + upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10) + + expect(roster.size).toBe(0) + }) +}) diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 6114a59b3d0..5c4d3e6d7a0 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -1,4 +1,10 @@ -import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types' +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_MAX_SUBAGENTS, + AGENT_TYPE_MAX_LENGTH, + type AgentSubagentSnapshot +} from './agent-status-types' +import { normalizeOptionalField } from './agent-status-field-normalization' const CODEX_SUBAGENT_ID_MAX_LENGTH = 64 @@ -21,29 +27,32 @@ export function upsertCodexSubagent( }, now: number ): void { - if (id.length === 0 || id.length > CODEX_SUBAGENT_ID_MAX_LENGTH) { + const normalizedId = id.trim() + if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) { return } - const existing = roster.get(id) + const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH) + const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH) + const existing = roster.get(normalizedId) if (existing) { - existing.agentType = fields.agentType ?? existing.agentType - existing.model = fields.model ?? existing.model + existing.agentType = agentType ?? existing.agentType + existing.model = model ?? existing.model existing.state = fields.state return } if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { return } - roster.set(id, { - agentType: fields.agentType, - model: fields.model, + roster.set(normalizedId, { + agentType, + model, state: fields.state, startedAt: now }) } export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void { - roster.delete(id) + roster.delete(id.trim()) } export function seedCodexSubagentRoster( @@ -54,15 +63,12 @@ export function seedCodexSubagentRoster( if (snapshot.state !== 'working' && snapshot.state !== 'waiting') { continue } - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { - return - } - roster.set(snapshot.id, { - agentType: snapshot.agentType, - model: snapshot.model, - state: snapshot.state, - startedAt: snapshot.startedAt - }) + upsertCodexSubagent( + roster, + snapshot.id, + { agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state }, + snapshot.startedAt + ) } }