fix(agent-status): keep Codex's hidden title-thread prompt off the sidebar heading (#33746)

* fix(agent-status): keep Codex's hidden title-thread prompt off the sidebar heading

Codex titles a new thread in a hidden ephemeral thread. When managed config pins hooks on,
that thread's SessionStart (source startup) took the pane and its prompt became the row heading.
Drop its prompt as a foreign ephemeral thread and keep the row's prompt across rollout-less starts.

Fixes #30049

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(agent-status): hold a rollout-less Codex start until its prompt shows what it is

The hidden title thread's SessionStart no longer swaps the row's state, session or tool snapshot.
A held start is dropped with the title prompt, or replayed as SessionStart on a new conversation's
first prompt so relayed desktops still let it take the pane. Covers a main Stop arriving first.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(agent-status): also hold Codex's hidden recap thread and clear the old completion stamp on takeover

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-25 21:00:28 -07:00
committed by GitHub
co-authored by Claude
parent bbaa33ce65
commit 4c9a6a4055
6 changed files with 120 additions and 20 deletions
@@ -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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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()
}
})
@@ -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) {
@@ -67,6 +67,8 @@ export type HookListenerState = {
codexLeadStateByPaneKey: Map<string, CodexLeadTurnState>
/** Ephemeral forked Codex threads (/side) per pane; they never own the pane's status. */
codexEphemeralForkSessionsByPaneKey: Map<string, string[]>
/** A rollout-less Codex start held until its prompt shows a new conversation or the hidden title thread. */
codexHeldEphemeralStartByPaneKey: Map<string, string>
/** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */
grokActiveTurnByPaneKey: Map<string, GrokActiveTurn>
/** 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()
@@ -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
@@ -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<string, unknown>
): 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. */
@@ -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' &&