Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a14a423
feat(native-chat): register Codex default-mode helpers as subagents
brennanb2025 Sep 24, 2026
24e96c6
fix(native-chat): name a spawn row by its prompt until the roster hol…
brennanb2025 Sep 24, 2026
1a2d030
test(native-chat): a spawn call keeps its own row beside the roster r…
brennanb2025 Sep 24, 2026
221cef8
fix(native-chat): pair a structured tool row's output with its own call
brennanb2025 Sep 24, 2026
0bba1da
fix(native-chat): the Codex roster row follows the executions, so eve…
brennanb2025 Sep 24, 2026
0892906
fix(native-chat): a restored Codex call row names its helper, not its…
brennanb2025 Sep 24, 2026
13d58ab
fix(native-chat): register a Codex helper whose spawn call failed but…
brennanb2025 Sep 24, 2026
c89bb95
refactor(native-chat): each Codex thread's latest token total gets it…
brennanb2025 Sep 24, 2026
36a0ce3
refactor(native-chat): the messages a session list quotes get their o…
brennanb2025 Sep 24, 2026
d9f9f98
test(native-chat): a Codex helper's command is a live record until it…
brennanb2025 Sep 28, 2026
8781c41
fix(native-chat): every reader of a tool run pairs a result with the …
brennanb2025 Sep 28, 2026
69277a6
fix(native-chat): a Codex helper's turn ends on its own frames, not o…
brennanb2025 Sep 28, 2026
d5737e6
fix(native-chat): a tool result that names a call is never given to a…
brennanb2025 Sep 28, 2026
e387b16
test(native-chat): name the Codex default-mode test file after the su…
brennanb2025 Sep 28, 2026
3b74022
fix(native-chat): a Codex helper is known from any call that names it…
brennanb2025 Sep 29, 2026
3ed945d
fix(native-chat): every finished Codex collab call row carries what t…
brennanb2025 Sep 29, 2026
bbb86af
refactor(native-chat): the desktop run's tool pairing is a view of pa…
brennanb2025 Sep 29, 2026
2fd7d3c
test(native-chat): type the positional-client pairs without assertions
brennanb2025 Sep 29, 2026
97d36dc
fix(native-chat): a finished Codex collab call row says what the call…
brennanb2025 Sep 29, 2026
f1e0414
docs(native-chat): the collab call reader says which calls show the h…
brennanb2025 Sep 29, 2026
6639c68
fix(native-chat): a Codex spawn call no longer claims it ran an agent
brennanb2025 Sep 29, 2026
2654d95
test(native-chat): a Codex helper's row settles on the failed complet…
brennanb2025 Sep 29, 2026
0d97396
Merge remote-tracking branch 'origin/main' into brennanb2025/c8-codex…
brennanb2025 Sep 30, 2026
a16f2f4
Merge remote-tracking branch 'origin/main' into brennanb2025/c8-codex…
brennanb2025 Sep 30, 2026
4706d3d
fix(native-chat): a Codex helper's section opens while the parent spa…
brennanb2025 Sep 30, 2026
e904eec
Merge remote-tracking branch 'origin/main' into brennanb2025/c8-codex…
brennanb2025 Sep 30, 2026
70b75cc
fix(native-chat): a Codex collab call with a long prompt still names …
brennanb2025 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts",
"../src/renderer/src/components/native-chat/structured-agent-question-projection.ts",
"../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/relay/**/*",
Expand Down
52 changes: 22 additions & 30 deletions src/main/codex/codex-background-task-frames.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
codexSubagentLabel,
isCodexRootAgentActivity,
readCodexSubagentActivity
} from './codex-subagent-activity'
import { readCodexSubagentAnnouncements } from './codex-subagent-activity'
import { codexChildTurnState } from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexTurnId } from './codex-structured-thread-facts'

export type CodexAnnouncedChild = {
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
/** The reporting thread, for a spawn: the agent that spawned the child. */
spawnerThreadId: string | undefined
}

export type CodexBackgroundTaskFrame =
| {
kind: 'subagent'
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
/** The reporting thread, for a `started` activity: the agent that spawned the child. */
spawnerThreadId: string | undefined
/** An item naming children, several for a call on more than one helper. */
kind: 'subagents'
children: CodexAnnouncedChild[]
}
| {
kind: 'turn'
Expand Down Expand Up @@ -78,23 +79,14 @@ export function readCodexBackgroundTaskFrame(
return null
}
const item = readCodexThreadItem(readRecord(event.params).item)
const activity = item && readCodexSubagentActivity(item)
if (
!activity ||
activity.agentThreadId === primaryThreadId ||
isCodexRootAgentActivity(activity)
) {
return null
}
return {
kind: 'subagent',
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
parentTurnId:
activity.kind === 'started' || activity.kind === 'interacted'
? readCodexTurnId(event.params)
: undefined,
// Only `started` names the spawner: other kinds ride whichever agent acted.
spawnerThreadId: activity.kind === 'started' ? event.threadId : undefined
}
const children = (item ? readCodexSubagentAnnouncements(item, primaryThreadId) : []).map(
(announcement) => ({
agentThreadId: announcement.agentThreadId,
label: announcement.label,
parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined,
// Only a spawn names the spawner: other announcements ride whichever agent acted.
spawnerThreadId: announcement.spawned ? event.threadId : undefined
})
)
return children.length > 0 ? { kind: 'subagents', children } : null
}
6 changes: 2 additions & 4 deletions src/main/codex/codex-background-task-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,8 @@ function command(
describe('readCodexBackgroundTaskFrame', () => {
it('reads activity as child metadata without inferring execution state', () => {
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
kind: 'subagent',
agentThreadId: CHILD,
label: 'count_a',
parentTurnId: PARENT_TURN
kind: 'subagents',
children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }]
})
})

Expand Down
16 changes: 9 additions & 7 deletions src/main/codex/codex-background-task-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,15 @@ export class CodexBackgroundTaskTracker {
: [])
]
const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId)
if (frame?.kind === 'subagent') {
this.executions.register(
frame.agentThreadId,
frame.label,
frame.parentTurnId,
frame.spawnerThreadId
)
if (frame?.kind === 'subagents') {
for (const child of frame.children) {
this.executions.register(
child.agentThreadId,
child.label,
child.parentTurnId,
child.spawnerThreadId
)
}
} else if (frame?.kind === 'thread-closed') {
this.executions.closeThread(frame.threadId)
} else if (frame && frame.threadId !== this.primaryThreadId) {
Expand Down
30 changes: 14 additions & 16 deletions src/main/codex/codex-child-work-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,13 @@ export class CodexChildWorkEvidence {
commands: readonly CodexBackgroundCommandChange[]
): void {
this.queueCommands(commands)
const threadId = this.childThread(event, frame)
if (threadId === null) {
return
}
const facts = this.factsFor(threadId)
if (facts && event.threadId === threadId) {
this.record(facts, event)
for (const threadId of this.childThreads(event, frame)) {
const facts = this.factsFor(threadId)
if (facts && event.threadId === threadId) {
this.record(facts, event)
}
this.queueChild(threadId)
}
this.queueChild(threadId)
}

/** The provider session is gone, with the commands it ended: no child it still ran can report
Expand All @@ -125,17 +123,17 @@ export class CodexChildWorkEvidence {
return pending.map((edge) => edge(observedAt))
}

private childThread(
private childThreads(
event: CodexBackgroundTaskEvent,
frame: CodexBackgroundTaskFrame | null
): string | null {
const threadId =
frame?.kind === 'subagent'
? frame.agentThreadId
): string[] {
const threadIds =
frame?.kind === 'subagents'
? frame.children.map((child) => child.agentThreadId)
: frame || CHILD_FRAME_METHODS.has(event.method)
? event.threadId
: null
return threadId === this.primaryThreadId ? null : threadId
? [event.threadId]
: []
return threadIds.filter((threadId) => threadId !== this.primaryThreadId)
}

/** A command belongs to the child thread that launched it; the session's own agent is no owner.
Expand Down
154 changes: 154 additions & 0 deletions src/main/codex/codex-collab-agent-item-translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// A Codex `collabAgentToolCall` item → a tool row that names the helper it acted on.
//
// The row is the call the agent made (`spawn_agent`, `wait_agent`, `close_agent`, …); the helper
// itself is the subagent roster's row. A helper is named the way the roster names it, so the two
// rows read as the same child.

import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { CODEX_COLLAB_ROW_AGENTS_KEY } from '../../shared/codex-collab-agent-tools'
import {
boundInlineText,
boundToolInput,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import {
codexCollabHelperLabel,
codexCollabToolName,
readCodexCollabAgentToolCall,
type CodexCollabAgentToolCall
} from './codex-collab-agent-tool-call'
import { readString } from './codex-item-field-readers'
import { codexItemRunState } from './codex-item-run-state'
import type { CodexThreadItem } from './codex-thread-item-identity'

/** The roster's name for a helper thread, or null for one it holds no name for. */
export type CodexHelperName = (threadId: string) => string | null

/** Who the call acted on. A spawn names its helper by its prompt until the roster holds the
* thread it became; a helper with no name (its spawn was never seen) is named by its thread id. */
function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string {
if (call.tool === 'spawnAgent') {
const spawned = call.receiverThreadIds[0]
return (spawned && helperName?.(spawned)) || codexCollabHelperLabel(call.prompt) || ''
}
return call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId).join(', ')
}

/** Codex's own words for a helper's `CollabAgentStatus`, as its client shows them. */
const HELPER_STATUS_TEXT = new Map<string, string>([
['pendingInit', 'Pending init'],
['running', 'Running'],
['interrupted', 'Interrupted'],
['completed', 'Completed'],
['shutdown', 'Shutdown'],
['notFound', 'Not found']
])

const CALL_STATUS_TEXT = new Map<string, string>([
['completed', 'Completed'],
['failed', 'Failed'],
['interrupted', 'Interrupted']
])

type HelperState = CodexCollabAgentToolCall['states'][number]

/** A helper's status summarised as Codex's own client does: a completed helper with its last
* reply, an errored one with its error. */
function statusSummary(state: HelperState): string | null {
if (state.status === 'errored') {
return `Error - ${state.message ?? 'Agent errored'}`
}
if (state.status === 'completed' && state.message) {
return `Completed - ${state.message}`
}
return state.status === null ? null : (HELPER_STATUS_TEXT.get(state.status) ?? state.status)
}

/** A finished call's output, worded as Codex's own client words the call. Every finished call has
* one: a client that pairs results by position (one predating result call ids) would otherwise
* draw each later output in the run under the call before its own. The row label already names
* the helper, so the output does not. */
function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null {
if (call.status === null || call.status === 'inProgress') {
return null
}
// The status a spawn or close reports predates what it did (a closed helper reads `running`),
// so these say what the call did.
switch (call.tool) {
case 'spawnAgent':
return call.receiverThreadIds.length > 0 ? 'Spawned' : 'Agent spawn failed'
case 'sendInput':
return 'Sent input'
Comment on lines +80 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '60,145p' src/main/codex/codex-collab-agent-item-translation.ts

Repository: stablyai/orca

Length of output: 3473


🏁 Script executed:

set -e
file="$(fd -t f -i 'codex-collab-call-rows-on-positional-clients.test.ts' . | head -n 1)"
printf 'FILE=%s\n' "$file"
rg -n -C 12 "failed|spawn" "$file"

Repository: stablyai/orca

Length of output: 8645


Do not report failed sendInput or closeAgent calls as successful.

When call.status is failed or interrupted, outputText still returns Sent input or Closed. The spawnAgent branch already returns Agent spawn failed when no receiver thread exists.

Suggested fix
    case 'sendInput':
-     return 'Sent input'
+     return call.status === 'completed'
+       ? 'Sent input'
+       : (CALL_STATUS_TEXT.get(call.status) ?? call.status)
    case 'closeAgent':
-     return 'Closed'
+     return call.status === 'completed'
+       ? 'Closed'
+       : (CALL_STATUS_TEXT.get(call.status) ?? call.status)

case 'closeAgent':
return 'Closed'
Comment on lines +80 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed calls claim success

When send_input or close_agent fails or is interrupted, its output still says “Sent input” or “Closed.” A failed close can leave the helper running, so this wording may tell users it was closed when it was not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed closeAgent still reads Closed. Unlike the spawnAgent branch, which distinguishes Spawned from Agent spawn failed, this keys only on the tool, so a close with status: 'failed' — the shape codex-default-mode-subagents.test.ts:382 models, where the helper is deliberately left running — renders state: failed with output Closed. Gating on call.status (as resumeAgent already does) would keep the wording honest; the call-envelope status is the close's own outcome, only the agentsStates snapshot predates it.

case 'resumeAgent':
return (call.states[0] && statusSummary(call.states[0])) ?? 'Error - Agent resume failed'
Comment on lines +84 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Successful resume reports failure

A completed resume_agent call can have no helper status in agentsStates. In that case, this branch outputs “Error - Agent resume failed” instead of using the call’s completed status, making a successful resume look like an error.

}
const reports = call.states.flatMap((state) => {
// A wait returns what each finished helper said.
const text =
call.tool === 'wait' && state.status === 'completed' && state.message
? state.message
: statusSummary(state)
return text === null ? [] : [{ threadId: state.threadId, text }]
})
if (reports.length === 0) {
// A wait's end names only helpers that finished (none when it timed out; v2's never names any).
return call.tool === 'wait'
? 'Finished waiting'
: (CALL_STATUS_TEXT.get(call.status) ?? call.status)
Comment on lines +97 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed wait sounds successful

If a wait_agent call fails or is interrupted without a helper status report, this branch displays “Finished waiting” even though the row is marked failed. That makes it harder to tell why the wait ended.

Suggested change
return call.tool === 'wait'
? 'Finished waiting'
: (CALL_STATUS_TEXT.get(call.status) ?? call.status)
return call.tool === 'wait' && call.status === 'completed'
? 'Finished waiting'
: (CALL_STATUS_TEXT.get(call.status) ?? call.status)

}
if (reports.length === 1 && call.receiverThreadIds.length === 1) {
return reports[0].text
}
return reports
.map(({ threadId, text }) => `${helperName?.(threadId) ?? threadId}: ${text}`)
.join('\n')
}

/** The prompt is clipped on its own: bounded with the rest, a long one would clip away the
* helper's name and ids the row is read by. */
function collabRowInput(fields: Record<string, unknown>, prompt: string | null): unknown {
const bounded = boundToolInput(fields, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
if (bounded !== fields) {
return bounded
}
const input = prompt
? { ...fields, prompt: boundInlineText(prompt, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
: fields
return Object.keys(input).length > 0 ? input : null
}

/** `started` is the call's started item, when the caller still holds it. */
export function codexCollabAgentToolCallBody(
item: CodexThreadItem,
helperName?: CodexHelperName,
started?: CodexThreadItem
): AgentJournalItemBody | null {
const call = readCodexCollabAgentToolCall(item, started)
if (!call) {
return null
}
const description = helperNames(call, helperName)
const model = readString(item, 'model')
const reasoningEffort = readString(item, 'reasoningEffort')
const fields = {
// `description` is the key the row label reads, so the helper's name leads the row.
...(description ? { description } : {}),
...(model ? { model } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(call.receiverThreadIds.length > 0
? { [CODEX_COLLAB_ROW_AGENTS_KEY]: call.receiverThreadIds }
: {})
}
const text = outputText(call, helperName)
const output = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return {
kind: 'tool-call',
name: codexCollabToolName(call),
callId: call.id,
input: collabRowInput(fields, call.prompt),
state: codexItemRunState(item),
...(output === null ? {} : { output: output.bounded })
}
}
Loading
Loading