Stop native chat jumping when Codex starts a foreground command (#26503)

* Keep foreground Codex commands out of the background strip

* Run the command-strip host regression in Node

* Exercise retained commands after their source turns end
This commit is contained in:
Brennan Benson
2026-10-08 13:02:03 -07:00
committed by GitHub
parent 5de53910aa
commit a062abffd2
18 changed files with 549 additions and 80 deletions
@@ -66,6 +66,30 @@ function alias(
}
describe('projectAgentChildWorkViews', () => {
it('keeps Codex foreground commands in status records but out of all surface views', () => {
const foreground = record('foreground', {
provider: 'codex',
kind: 'command',
residency: 'foreground'
})
const records = [
foreground,
record('background', { provider: 'codex', kind: 'command', residency: 'background' }),
record('claude', { kind: 'command', residency: 'foreground' }),
record('agent', { provider: 'codex', residency: 'foreground' })
]
expect(agentChildWorkLiveness([foreground])).toBe('monitoring')
expect(projectAgentChildWorkViews(records, []).map((view) => view.id)).toEqual([
'background',
'claude',
'agent'
])
expect(
projectAgentChildWorkViews([{ ...foreground, residency: 'background' }], [])
).toHaveLength(1)
expect(foreground.residency).toBe('foreground')
})
it('carries what a surface reads and drops host bookkeeping', () => {
const [view] = projectAgentChildWorkViews(
[
+7 -3
View File
@@ -108,14 +108,18 @@ function resolvedOwner(
: undefined
}
/** The one record-to-view projection; every surface and every legacy shape starts here. */
/** Every surface starts here; foreground Codex tools stay in status records and tool rows. */
export function projectAgentChildWorkViews(
records: readonly AgentChildWorkInput[],
aliases: readonly AgentChildWorkViewAlias[]
): AgentChildWorkView[] {
const byId = new Map(records.map((record) => [record.childWorkId, record]))
const visible = records.filter(
(record) =>
record.provider !== 'codex' || record.kind !== 'command' || record.residency !== 'foreground'
)
const byId = new Map(visible.map((record) => [record.childWorkId, record]))
const aliasesByChild = groupedBy(aliases, (alias) => alias.childWorkId)
return records.map((record) => {
return visible.map((record) => {
const providerId = providerIdFor(record, aliasesByChild.get(record.childWorkId) ?? [])
const owner = resolvedOwner(record, byId)
return {
@@ -17,6 +17,49 @@ function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] =
}
describe('structuredSessionBackgroundTasksView', () => {
it('does not mount the strip for a foreground command from an older host', () => {
const id = 'codex-command:primary:pwd'
const foreground = new Set([id])
expect(
structuredSessionBackgroundTasksView(
{ state: 'monitoring', tasks: [{ id, kind: 'command' }] },
'turn-1',
foreground
)
).toMatchObject({ show: false, isMonitoring: false, tasks: [] })
})
it('removes the foreground command from child views and legacy tasks together', () => {
const id = 'codex-command:primary:pwd'
const shell = { ...view('live', 'command'), providerId: id }
const agent = view('live')
const tasks = [
{ id, kind: 'command' as const },
{ id: 'child-agent', kind: 'agent' as const }
]
const shown = structuredSessionBackgroundTasksView(
{ state: 'monitoring', tasks, children: [shell, agent] },
'turn-1',
new Set([id])
)
expect(shown).toMatchObject({ show: true, tasks: [tasks[1]], children: [agent] })
})
it('keeps unrelated rosters and commands from earlier turns during the current turn', () => {
const tasks = [
{ id: 'codex-command:primary:server', kind: 'command' as const },
{ id: 'claude-shell', kind: 'command' as const },
{ id: 'codex-command:thread:child:pwd', kind: 'command' as const }
]
const shown = structuredSessionBackgroundTasksView(
{ state: 'monitoring', tasks },
'turn-2',
new Set(['codex-command:primary:pwd'])
)
expect(shown.show).toBe(true)
expect(shown.tasks).toBe(tasks)
})
it('shows finished children until the next turn, but they hold nothing open', () => {
const finished = structuredSessionBackgroundTasksView(
{ state: 'monitoring', children: [view('settled')] },
@@ -1,13 +1,7 @@
// The background-tasks strip's view of one session's wire state.
//
// The strip reports work that is IN FLIGHT, whether or not it outlived a turn:
// a fan-out's children keep reporting long after the parent settles, and a
// foreground fan-out is running work while the turn is still open. It stays
// mounted through a running turn — turn state is not a filter on the rows,
// because the producers publish only tasks they still have live evidence for.
// A host that publishes its child records sends the running ones only, and no
// roster once none runs, so the strip hides. Only an idle session with live
// work lets the strip animate or speak for itself.
// Hosts select background work; newer clients also exclude their current turn's commands
// from an older host's roster. Child agents and earlier commands stay visible mid-turn.
import type {
AgentSessionBackgroundTask,
@@ -17,7 +11,7 @@ import { agentChildWorkLiveness } from './agent-status-child-work-liveness'
import type { AgentChildWorkView } from './agent-status-child-work-view'
export type StructuredSessionBackgroundTasksView = {
/** The strip renders whenever the host reports rows — mid-turn included. */
/** Background rows remain visible during a foreground turn. */
show: boolean
/** Idle-only: gates the animated monitoring indicator and conversation
* commands, never the strip itself. A running turn owns the voice. */
@@ -32,24 +26,47 @@ export type StructuredSessionBackgroundTasksView = {
/** One shared empty list: a roster with no rows of a kind keeps the strip's memo on every render. */
const NO_TASKS: AgentSessionBackgroundTask[] = []
const NO_FOREGROUND_COMMANDS: ReadonlySet<string> = new Set()
function withoutForeground<T>(
rows: T[],
foreground: ReadonlySet<string>,
id: (row: T) => string | undefined
): T[] {
const visible = (row: T) => !foreground.has(id(row) ?? '')
return rows.every(visible) ? rows : rows.filter(visible)
}
export function structuredSessionBackgroundTasksView(
backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
turnId: string | null
turnId: string | null,
foregroundCommands: ReadonlySet<string> = NO_FOREGROUND_COMMANDS
): StructuredSessionBackgroundTasksView {
const monitoring = backgroundTasks?.state === 'monitoring'
// Decoded once where the frame entered the client's state, so its identity holds between frames.
const children = monitoring ? backgroundTasks.children : undefined
const children =
monitoring && backgroundTasks.children
? withoutForeground(backgroundTasks.children, foregroundCommands, (child) => child.providerId)
: undefined
const tasks = withoutForeground(
backgroundTasks?.tasks ?? NO_TASKS,
foregroundCommands,
(task) => task.id
)
// Only running children arrive; a roster with none holds nothing open either way.
// An older host's roster whose listed rows have all finished shows nothing that runs.
const onlyFinished =
!children && !backgroundTasks?.tasks?.length && Boolean(backgroundTasks?.settledTasks?.length)
const show = monitoring && !onlyFinished
const onlyFinished = !children && !tasks.length && Boolean(backgroundTasks?.settledTasks?.length)
const onlyForeground =
foregroundCommands.size > 0 &&
!children?.length &&
!tasks.length &&
Boolean(backgroundTasks?.children?.length || backgroundTasks?.tasks?.length)
const show = monitoring && !onlyFinished && !onlyForeground
const liveWork = children ? agentChildWorkLiveness(children) !== null : show
return {
show,
isMonitoring: turnId === null && liveWork,
tasks: backgroundTasks?.tasks ?? NO_TASKS,
tasks,
// Running work only, from any host: an older host's finished rows are not shown either.
settledTasks: NO_TASKS,
...(children ? { children } : {}),
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import type { AgentSessionBackgroundTaskState, AgentSessionLatestTurn } from './agent-session-wire'
import { structuredSessionForegroundCommands } from './structured-session-foreground-commands'
const pwd = 'codex-command:primary:pwd'
const server = 'codex-command:primary:server'
const running = {
itemId: 'turn-row-2',
observedAt: 20,
turn: { turnId: 'turn-2', state: 'running' }
} satisfies AgentSessionLatestTurn
function tool(callId: string, turnItemId: string): AgentJournalRenderItem {
return {
itemId: `tool-${callId}`,
sequence: 2,
revision: 1,
observedAt: 21,
turnScope: { kind: 'turn', turnItemId },
body: { kind: 'tool-call', callId, name: 'shell', input: null, state: 'running' }
}
}
const roster: AgentSessionBackgroundTaskState = {
state: 'monitoring',
tasks: [
{ id: pwd, kind: 'command' },
{ id: server, kind: 'command' },
{ id: 'codex-command:thread:child:exec', kind: 'command' },
{ id: 'claude-task', kind: 'command' }
]
}
describe('structuredSessionForegroundCommands', () => {
it('handles a roster arriving before its tool row, without hiding earlier work', () => {
expect([
...structuredSessionForegroundCommands(roster, {
items: [tool('server', 'turn-row-1')],
latestTurn: running
})
]).toEqual([pwd])
expect([
...structuredSessionForegroundCommands(roster, {
items: [tool('server', 'turn-row-1'), tool('pwd', running.itemId)],
latestTurn: running
})
]).toEqual([pwd])
})
it('keeps an earlier command whose journal row is outside the loaded page', () => {
const children = [
{
id: 'child-server',
providerId: server,
kind: 'command' as const,
state: 'working' as const,
membership: 'live' as const,
firstObservedAt: 10,
observedAt: 21,
stoppable: false,
invocation: { invocationId: server, generation: 1 }
}
]
expect([
...structuredSessionForegroundCommands(
{ ...roster, children },
{
items: [],
latestTurn: running
}
)
]).toEqual([pwd])
})
it('reveals surviving commands when the source turn ends', () => {
expect(
structuredSessionForegroundCommands(roster, {
items: [],
latestTurn: { ...running, turn: { ...running.turn, state: 'completed' } }
}).size
).toBe(0)
})
it('uses loaded turn rows when an older host has no latest-turn field', () => {
const turn: AgentJournalRenderItem = {
itemId: running.itemId,
sequence: 1,
revision: 1,
observedAt: 20,
body: { kind: 'turn', ...running.turn }
}
expect([
...structuredSessionForegroundCommands(roster, {
items: [turn, tool('server', 'turn-row-1')]
})
]).toEqual([pwd])
})
})
@@ -0,0 +1,64 @@
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import type { AgentSessionBackgroundTaskState, AgentSessionLatestTurn } from './agent-session-wire'
import {
runningStructuredAgentSessionTurnId,
runningStructuredAgentSessionTurnScope
} from './structured-agent-session-live-turn'
const PRIMARY_COMMAND_PREFIX = 'codex-command:primary:'
export function codexPrimaryCommandTaskId(callId: string): string {
return `${PRIMARY_COMMAND_PREFIX}${encodeURIComponent(callId)}`
}
/** Older hosts list foreground shells too; their journal still identifies which turn owns one. */
export function structuredSessionForegroundCommands(
roster: AgentSessionBackgroundTaskState | null | undefined,
source: { items: readonly AgentJournalRenderItem[]; latestTurn?: AgentSessionLatestTurn | null }
): ReadonlySet<string> {
const foreground = new Set<string>()
const turnId = runningStructuredAgentSessionTurnId(source)
if (turnId === null || !roster) {
return foreground
}
const scope = runningStructuredAgentSessionTurnScope(source)
const owningTurnItems = new Map<string, string>()
for (const item of source.items) {
if (
isRootAgentJournalItem(item) &&
item.body.kind === 'tool-call' &&
item.body.callId &&
item.turnScope?.kind === 'turn'
) {
owningTurnItems.set(codexPrimaryCommandTaskId(item.body.callId), item.turnScope.turnItemId)
}
}
const firstObserved = new Map(
(roster.children ?? []).flatMap((child) =>
child.providerId ? [[child.providerId, child.firstObservedAt] as const] : []
)
)
const ids = new Set([
...(roster.tasks ?? []).map((task) => task.id),
...(roster.children ?? []).flatMap((child) => child.providerId ?? [])
])
for (const id of ids) {
if (!id.startsWith(PRIMARY_COMMAND_PREFIX)) {
continue
}
const observedAt = firstObserved.get(id)
const owningTurnItem = owningTurnItems.get(id)
const earlierTurn =
owningTurnItem !== undefined && scope.kind === 'turn'
? owningTurnItem !== scope.turnItemId
: observedAt !== undefined &&
source.latestTurn != null &&
observedAt < source.latestTurn.observedAt
// With no ownership yet, keep an older host's start frame in the tool row until the turn ends.
if (!earlierTurn) {
foreground.add(id)
}
}
return foreground
}