mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user