From 44f97fa95872ae29cd8a646684ecd9bfe618db47 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:01:30 -0700 Subject: [PATCH] fix(agent-status): keep a running Codex turn on paired and mobile views while /side is shown (#33308) * fix(agent-status): keep a running Codex turn on paired and mobile views while /side is shown Codex's terminal title follows the thread it displays, so opening /side idles the title while the main turn runs. The paired/mobile session projection read that idle title, newer than the main turn's last hook, as the turn ending and published a title-only done row with no prompt. A Codex row whose hooks report the main turn running now owns the title interval; the turn's end still arrives by hook or rollout. Co-Authored-By: Claude * fix(agent-status): keep a Codex main turn waiting on approval through /side on paired views Co-Authored-By: Claude * fix(agent-status): leave restored Codex rows to the existing title rules Co-Authored-By: Claude * fix(agent-status): stop a running Codex turn at a PTY process replacement A replacement process on the same PTY id invalidates its predecessor's title-interval evidence; record when that happened so a predecessor's running Codex hook cannot outlive it. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../runtime-mobile-agent-status-projection.ts | 47 +++++--- .../runtime-mobile-codex-side-title.test.ts | 100 ++++++++++++++++++ .../runtime/runtime-terminal-state-records.ts | 2 + .../output/terminal-title-observation.ts | 1 + 4 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/runtime-mobile-codex-side-title.test.ts diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 88a2f98cf47..30140c521ac 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -12,24 +12,48 @@ import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-statu import type { HookLiveAgentRow } from './runtime-terminal-contracts' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +type HookTurnFacts = Pick< + AgentStatusEntry, + 'state' | 'agentType' | 'mainAgent' | 'restoredUnconfirmed' +> + function workingHookOwnsIdleTitleInterval( - state: AgentStatusEntry['state'], + status: HookTurnFacts, observedAt: number, pty: RuntimePtyWorktreeRecord | null ): boolean { return ( - state === 'working' && + status.state === 'working' && pty?.lastAgentStatus === 'idle' && !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) && - observedAt > - Math.max( - pty.lastAgentStatusRichInvalidatedAtEpochMs ?? 0, - pty.lastAgentStatusStartedAtEpochMs ?? 0 - ) && + (codexHookReportsRunningTurn(status, observedAt, pty) || + observedAt > + Math.max( + pty.lastAgentStatusRichInvalidatedAtEpochMs ?? 0, + pty.lastAgentStatusStartedAtEpochMs ?? 0 + )) && Date.now() - observedAt <= AGENT_STATUS_STALE_AFTER_MS ) } +// Why: Codex's title follows the thread it displays, so a /side chat goes idle while the main +// turn runs; its end arrives by hook or host rollout, and the stale bound covers older hosts. +// A hook from before the PTY's current process describes that process's predecessor. +function codexHookReportsRunningTurn( + status: HookTurnFacts, + observedAt: number, + pty: RuntimePtyWorktreeRecord | null +): boolean { + const mainState = status.mainAgent?.state + return ( + status.agentType === 'codex' && + status.restoredUnconfirmed !== true && + mainState !== undefined && + mainState !== 'done' && + observedAt > (pty?.providerGenerationResetAtEpochMs ?? 0) + ) +} + // Why: only a completed turn's reply is final; mid-turn hook snapshots carry tool stdout. function completedTurnFields( status: AgentStatusEntry @@ -95,6 +119,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( return status } const richStatusCanOwnTitleInterval = + codexHookReportsRunningTurn(status, agentStatusAuthorityObservedAt(status), pty) || pty.lastAgentStatusRichInvalidatedAtEpochMs === null || agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs const titleEvidenceAt = pty.lastOscTitleEpochMs @@ -126,9 +151,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( (status.state === 'blocked' || status.state === 'waiting')) if (!titleConfirmsState) { // A repaint of the idle interval preceding this hook is not a new turn boundary. - if ( - workingHookOwnsIdleTitleInterval(status.state, agentStatusAuthorityObservedAt(status), pty) - ) { + if (workingHookOwnsIdleTitleInterval(status, agentStatusAuthorityObservedAt(status), pty)) { return status } if ( @@ -277,9 +300,7 @@ export function resolveRuntimeHookLiveAgentRow( if (live.payload.interactivePrompt != null) { return live } - if ( - workingHookOwnsIdleTitleInterval(live.payload.state, agentStatusAuthorityObservedAt(live), pty) - ) { + if (workingHookOwnsIdleTitleInterval(live.payload, agentStatusAuthorityObservedAt(live), pty)) { return live } // This is the pane's only wall-clock title timestamp comparable to when the hook evidence diff --git a/src/main/runtime/runtime-mobile-codex-side-title.test.ts b/src/main/runtime/runtime-mobile-codex-side-title.test.ts new file mode 100644 index 00000000000..b85b5bf2ef6 --- /dev/null +++ b/src/main/runtime/runtime-mobile-codex-side-title.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../shared/agent-status-types' +import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { makeTuiIdlePty } from './tui-idle-wait-test-harness' + +// Shape seen on a paired host with Codex 0.164: the main turn's last hook (PreToolUse for a long +// exec), then `/side` shows an idle side chat, so the title loses its spinner after the hook. +function runningExec(hookAt: number, agentType: string): AgentStatusEntry { + return { + state: 'working', + prompt: 'main task please', + updatedAt: hookAt, + stateStartedAt: hookAt, + agentType, + toolName: 'Bash', + toolInput: 'sleep 60', + mainAgent: { state: 'working', stateStartedAt: hookAt }, + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } +} + +function sideChatIdlePty(hookAt: number): RuntimePtyWorktreeRecord { + return makeTuiIdlePty({ + worktreeId: 'wt-1', + lastOscTitle: 'repo', + lastOscTitleEpochMs: hookAt + 8_000, + lastAgentStatus: 'idle', + lastAgentStatusStartedAtEpochMs: hookAt + 8_000, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 8_000 + }) +} + +describe('paired/mobile Codex status while a /side chat is shown', () => { + it('keeps the running main turn when the side chat idles the title', () => { + const hookAt = Date.now() - 10_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle( + runningExec(hookAt, 'codex'), + sideChatIdlePty(hookAt) + ) + expect(out).toMatchObject({ state: 'working', prompt: 'main task please' }) + }) + + it('keeps the main turn prompt once the title shows main working again', () => { + const hookAt = Date.now() - 20_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle(runningExec(hookAt, 'codex'), { + ...sideChatIdlePty(hookAt), + lastOscTitle: '⠋ repo', + lastOscTitleEpochMs: hookAt + 15_000, + lastAgentStatus: 'working', + lastAgentStatusStartedAtEpochMs: hookAt + 15_000, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 15_000 + }) + expect(out).toMatchObject({ state: 'working', prompt: 'main task please' }) + }) + + it('keeps a main turn waiting on approval and its prompt once the side chat closes', () => { + const hookAt = Date.now() - 20_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle( + { + ...runningExec(hookAt, 'codex'), + state: 'waiting', + mainAgent: { state: 'waiting', stateStartedAt: hookAt } + }, + { + ...sideChatIdlePty(hookAt), + lastOscTitle: '✋ repo', + lastOscTitleEpochMs: hookAt + 15_000, + lastAgentStatus: 'permission', + lastAgentStatusStartedAtEpochMs: hookAt + 15_000, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 15_000 + } + ) + expect(out).toMatchObject({ state: 'waiting', prompt: 'main task please' }) + }) + + it('does not carry a running turn across a replacement process on the same PTY', () => { + const hookAt = Date.now() - 20_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle(runningExec(hookAt, 'codex'), { + ...sideChatIdlePty(hookAt), + lastOscTitle: null, + lastOscTitleEpochMs: null, + lastAgentStatus: null, + lastAgentStatusStartedAtEpochMs: null, + lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 5_000, + providerGenerationResetAtEpochMs: hookAt + 5_000 + }) + expect(out).toBeNull() + }) + + it('still lets an idle title end the turn of an agent whose title follows that turn', () => { + const hookAt = Date.now() - 10_000 + const out = renewRuntimeMobileAgentStatusFromPtyTitle( + runningExec(hookAt, 'claude'), + sideChatIdlePty(hookAt) + ) + expect(out?.state).toBe('done') + }) +}) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 97a13e63fbe..54bceee49b1 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -102,6 +102,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null + /** When a replacement process took over this PTY id; earlier hooks describe its predecessor. */ + providerGenerationResetAtEpochMs?: number lastOscTitle: string | null lastOscTitleAt: number | null lastOscTitleEpochMs: number | null diff --git a/src/main/runtime/terminal/output/terminal-title-observation.ts b/src/main/runtime/terminal/output/terminal-title-observation.ts index dce03c15495..04a99d1291c 100644 --- a/src/main/runtime/terminal/output/terminal-title-observation.ts +++ b/src/main/runtime/terminal/output/terminal-title-observation.ts @@ -328,6 +328,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit pty.lastAgentStatusObservedLive = false pty.lastAgentStatusStartedAtEpochMs = null pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() + pty.providerGenerationResetAtEpochMs = pty.lastAgentStatusRichInvalidatedAtEpochMs pty.managementTitle = null pty.managementTitleAt = null pty.waitBlockedAt = null