mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user