diff --git a/src/main/agent-hooks/server-codex-side-thread.test.ts b/src/main/agent-hooks/server-codex-side-thread.test.ts index 98661088e17..bf9d3a9a323 100644 --- a/src/main/agent-hooks/server-codex-side-thread.test.ts +++ b/src/main/agent-hooks/server-codex-side-thread.test.ts @@ -105,3 +105,67 @@ it('lets a new ephemeral Codex conversation take over a pane whose last turn fin server.stop() } }) + +// codex-rs/tui/src/app/thread_title.rs starts this rollout-less thread on the first user message (#30049). +const TITLE_THREAD = { session_id: SIDE, transcript_path: null } +const TITLE_PROMPT = 'Generate a concise, single-line task title of at most 36 characters' +// codex-rs/context-fragments/src/recap_prompt.rs; auto-recap runs after the user returns. +const RECAP_PROMPT = 'Write a brief catch-up for a user returning to this task. Return JSON' + +it.each([ + { thread: 'titles the thread during the main turn', prompt: TITLE_PROMPT, mainStopFirst: false }, + { thread: 'titles the thread after the main turn', prompt: TITLE_PROMPT, mainStopFirst: true }, + { thread: 'writes a recap', prompt: RECAP_PROMPT, mainStopFirst: true } +])('keeps the main Codex row when Codex $thread', async ({ prompt, mainStopFirst }) => { + const dir = mkdtempSync(join(tmpdir(), 'codex-side-thread-')) + dirs.push(dir) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const post = (payload: Record): Promise => + postHookEvent(server, buildBody(payload), '/hook/codex') + try { + const main = { session_id: MAIN, transcript_path: join(dir, 'rollout-main.jsonl') } + await post({ ...main, hook_event_name: 'UserPromptSubmit', prompt: 'main task' }) + if (mainStopFirst) { + await post({ ...main, hook_event_name: 'Stop' }) + } + const expected = { + state: mainStopFirst ? 'done' : 'working', + prompt: 'main task', + providerSession: { id: MAIN } + } + await post({ ...TITLE_THREAD, hook_event_name: 'SessionStart', source: 'startup' }) + expect(server.getStatusSnapshot()[0]).toMatchObject(expected) + await post({ ...TITLE_THREAD, hook_event_name: 'UserPromptSubmit', prompt }) + await post({ ...TITLE_THREAD, hook_event_name: 'Stop' }) + expect(server.getStatusSnapshot()[0]).toMatchObject(expected) + if (!mainStopFirst) { + await post({ ...main, hook_event_name: 'Stop' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ ...expected, state: 'done' }) + } + } finally { + server.stop() + } +}) + +it('lets a new ephemeral Codex conversation take over a row still working, without its old prompt', async () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-side-thread-')) + dirs.push(dir) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const post = (payload: Record): Promise => + postHookEvent(server, buildBody(payload), '/hook/codex') + try { + const main = { session_id: MAIN, transcript_path: join(dir, 'rollout-main.jsonl') } + await post({ ...main, hook_event_name: 'UserPromptSubmit', prompt: 'main task' }) + await post({ ...TITLE_THREAD, hook_event_name: 'SessionStart', source: 'startup' }) + await post({ ...TITLE_THREAD, hook_event_name: 'UserPromptSubmit', prompt: 'next task' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'next task', + providerSession: { id: SIDE } + }) + } finally { + server.stop() + } +}) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index d952649a6d1..b79a01220d2 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -134,8 +134,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.commitStatusRowMutation(rowBefore, previous) return previous } - if (payload.hookEventName === 'UserPromptSubmit') { - // Repeated prompt text still opens a new turn; OSC must not inherit the old completion stamp. + // Any submitted prompt (incl. a replayed Codex start) opens a new turn; OSC must not inherit the old stamp. + if (payload.hookEventName === 'UserPromptSubmit' || payload.hasExplicitPrompt === true) { this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } if (terminalOwnedPayload.connectionId) { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 45bf33969e8..6a56bbae1ab 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -67,6 +67,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Ephemeral forked Codex threads (/side) per pane; they never own the pane's status. */ codexEphemeralForkSessionsByPaneKey: Map + /** A rollout-less Codex start held until its prompt shows a new conversation or the hidden title thread. */ + codexHeldEphemeralStartByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ @@ -138,6 +140,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), codexEphemeralForkSessionsByPaneKey: new Map(), + codexHeldEphemeralStartByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), @@ -233,6 +236,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.codexEphemeralForkSessionsByPaneKey.delete(paneKey) + state.codexHeldEphemeralStartByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) @@ -283,6 +287,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexEphemeralForkSessionsByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexHeldEphemeralStartByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) @@ -324,6 +329,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() state.codexEphemeralForkSessionsByPaneKey.clear() + state.codexHeldEphemeralStartByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() state.musePaneStateByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index a0c929dd767..ad9c244b98f 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -16,6 +16,7 @@ import { normalizeAntigravityEvent } from './providers/antigravity-events' import { normalizeAmpEvent } from './providers/amp-events' import { normalizeClaudeEvent } from './providers/claude-events' import { codexHookEventForEndedTurn, normalizeCodexEvent } from './providers/codex-events' +import { codexHookEventForEphemeralThread } from './providers/codex-ephemeral-threads' import { normalizeGeminiEvent } from './providers/gemini-events' import { normalizeOpenCodeFamilyEvent } from './providers/opencode-family-events' import { normalizeCursorEvent } from './providers/cursor-events' @@ -56,10 +57,17 @@ export function normalizeProviderEvent(input: { previousOpenCodeMainAgent?: AgentMainAgentStatus }): ProviderDispatchResult { const { state, source, promptText, paneKey, hookPayload, envelope, extractedPrompt } = input - const eventName = + // Ephemeral side chats share the pane but must not replace its recorded main turn. + const codexEventName = source === 'codex' - ? codexHookEventForEndedTurn(state, paneKey, input.eventName, hookPayload) - : input.eventName + ? codexHookEventForEphemeralThread(state, paneKey, input.eventName, hookPayload) + : undefined + const eventName = + source !== 'codex' + ? input.eventName + : codexEventName === undefined + ? undefined + : codexHookEventForEndedTurn(state, paneKey, codexEventName, hookPayload) let resolvedPromptText = promptText let promptInteractionKey: string | undefined let hasTranscriptPromptEvidence = false diff --git a/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts b/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts index cae9826bbea..f4f1daf6b66 100644 --- a/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts +++ b/src/shared/agent-hook-listener/providers/codex-ephemeral-threads.ts @@ -4,35 +4,63 @@ import { readString } from '../tool-input-preview' // Enough for any run of /side chats in one pane; the oldest ended long ago. const MAX_FORKS_PER_PANE = 16 +// Codex's hidden temporary threads (tui/src/temporary_structured_request.rs) run hooks when managed config pins them on. +const CODEX_HIDDEN_THREAD_PROMPT_PREFIXES = [ + // tui/src/app/thread_title.rs + 'Generate a concise, single-line task title', + // context-fragments/src/recap_prompt.rs + 'Write a brief catch-up for a user returning to this task.' +] // A /side thread shares the pane/process but cannot change its status; identify it from SessionStart or the persisted pane thread. // Persisted identity survives restart and does not depend on parsed rollout availability. -export function isForeignEphemeralCodexThread( +// Returns the event to apply: undefined drops a foreign thread's hook. +export function codexHookEventForEphemeralThread( state: HookListenerState, paneKey: string, eventName: unknown, hookPayload: Record -): boolean { +): unknown { const sessionId = readString(hookPayload, 'session_id') if (!sessionId || readString(hookPayload, 'agent_id') || hookPayload.transcript_path !== null) { - return false + return eventName } const forks = state.codexEphemeralForkSessionsByPaneKey.get(paneKey) ?? [] if (forks.includes(sessionId)) { - return true + return undefined } - const source = eventName === 'SessionStart' ? readString(hookPayload, 'source') : undefined - if (source === 'fork') { + const recordFork = (): undefined => { state.codexEphemeralForkSessionsByPaneKey.set( paneKey, [...forks, sessionId].slice(-MAX_FORKS_PER_PANE) ) - return true + return undefined + } + const source = eventName === 'SessionStart' ? readString(hookPayload, 'source') : undefined + if (source === 'fork') { + return recordFork() + } + const row = state.lastStatusByPaneKey.get(paneKey) + // Codex's hidden threads start like a new conversation; only their prompt tells them apart. + if (source === 'startup' && rowHoldsOtherCodexThread(row, sessionId)) { + state.codexHeldEphemeralStartByPaneKey.set(paneKey, sessionId) + return undefined + } + if (state.codexHeldEphemeralStartByPaneKey.get(paneKey) === sessionId) { + if (eventName !== 'UserPromptSubmit') { + return undefined + } + state.codexHeldEphemeralStartByPaneKey.delete(paneKey) + const prompt = readString(hookPayload, 'prompt') + // Replay the held start so every receiver lets the new conversation take the pane. + return CODEX_HIDDEN_THREAD_PROMPT_PREFIXES.some((prefix) => prompt?.startsWith(prefix)) + ? recordFork() + : 'SessionStart' } if (source === 'startup' || source === 'clear') { - return false + return eventName } - return rowHoldsOtherCodexThread(state.lastStatusByPaneKey.get(paneKey), sessionId) + return rowHoldsOtherCodexThread(row, sessionId) ? undefined : eventName } /** Whether the pane's stored Codex row is a different thread, one with a rollout. */ diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index a1bdac79eed..c2bb1436e25 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -37,7 +37,6 @@ import { resolveCodexPaneStatus, setCodexMainAgentTurnState } from './codex-state' -import { isForeignEphemeralCodexThread } from './codex-ephemeral-threads' export function buildCodexStatusPayload( state: HookListenerState, @@ -161,11 +160,6 @@ export function normalizeCodexEvent( return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } - // Ephemeral side chats share the pane but must not replace its recorded main turn. - if (isForeignEphemeralCodexThread(state, paneKey, eventName, hookPayload)) { - return null - } - // Auto-allowed request_user_input still blocks on PreToolUse (Codex0.145+). const isUserInputPreTool = eventName === 'PreToolUse' &&