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 <noreply@anthropic.com>

* fix(agent-status): keep a Codex main turn waiting on approval through /side on paired views

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

* fix(agent-status): leave restored Codex rows to the existing title rules

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

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-24 17:01:30 -07:00
committed by GitHub
co-authored by Claude
parent ba43f8539c
commit 44f97fa958
4 changed files with 137 additions and 13 deletions
@@ -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
@@ -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')
})
})
@@ -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
@@ -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