Native chat: read one saved row per subagent (part 1 of 2) (#27914)

* feat(chat): read one saved row per subagent

Adds the singular `subagent` journal block (one row per child, revised in
place, with a plain-text twin for older clients) and teaches every reader
to draw it: desktop and phone fold a turn's sibling rows into the existing
"Ran N subagents" row, the client and off-page rosters name children from
their own rows, the reopen pass settles a row a dead host left working,
and the phone and worker-transcript wires bound it. No producer writes the
block yet; that switch lands in the follow-up.

* fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows

- background is an open boolean so a later build's value never fails a chat load
- a helper's own helpers fold under a groupId of their own, so the phone opens them apart
- each subagent's own row rides along with the page window instead of counting as conversation

* refactor(chat): a subagent's group entry as one object
This commit is contained in:
Brennan Benson
2026-10-12 14:21:25 -07:00
committed by GitHub
parent de5470a900
commit fe1f0223af
21 changed files with 851 additions and 48 deletions
@@ -171,6 +171,36 @@ describe('buildMobileNativeChatTransientData', () => {
expect(folded.map((message) => message.id)).toEqual(['failure', 'sentence', 'a1'])
})
it('draws a turn’s subagents, each written as its own row, as one roster row', () => {
const own = (id: string): NativeChatMessage => ({
id: `subagent:${id}`,
role: 'system',
blocks: [
{ type: 'text', text: `Kicked off subagent ${id}` },
{ type: 'subagent', id, label: id, state: 'working', groupId: 'turn-1' }
],
timestamp: 0,
source: 'transcript'
})
const folded = foldMobileNativeChatMessages([
user('u1', 'audit'),
own('a'),
assistant('a1', 'started'),
own('b')
])
expect(folded.map((message) => message.id)).toEqual(['u1', 'subagent:a', 'a1'])
expect(folded[1]?.blocks).toEqual([
{
type: 'subagent-group',
groupId: 'turn-1',
agents: [
{ id: 'a', label: 'a', state: 'working' },
{ id: 'b', label: 'b', state: 'working' }
]
}
])
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
@@ -7,6 +7,7 @@ import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journa
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { isWordlessProviderFrameMessage } from '../../../src/shared/native-chat-provider-frame-summary'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { foldSubagentItems } from '../../../src/shared/native-chat-subagent-items'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
isImageSourceUserTurn,
@@ -64,9 +65,11 @@ export function foldMobileNativeChatMessages(
// goes too, before folding, so it never splits a tool run.
return stripNoiseMessages(
foldToolMessages(
normalizeImageTranscriptMessages(
messages.filter(
(message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message)
foldSubagentItems(
normalizeImageTranscriptMessages(
messages.filter(
(message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message)
)
)
)
)
@@ -0,0 +1,153 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import type { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-host-database-test-support'
import {
journaledSubagent,
subagentJournalBody,
subagentJournalItemIdentity
} from './journal-subagent-item'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: codexProviderHandle('thread-1')
}
function child(overrides: Partial<NativeChatSubagentBlock> = {}): NativeChatSubagentBlock {
return {
type: 'subagent',
id: 'child-1',
label: 'Audit',
state: 'working',
groupId: 'turn-1',
run: 'call-1',
startedAt: 10,
...overrides
}
}
let root: string
let clock = 1_000
const journals = createTrackedJournalOpener()
async function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
stateDirectory: root,
now: () => ++clock,
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-subagent-item-'))
clock = 1_000
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('a subagent’s own journal row', () => {
it('is one key per child, whatever its run or state', () => {
expect(subagentJournalItemIdentity('child-1')).toEqual(subagentJournalItemIdentity('child-1'))
expect(subagentJournalItemIdentity('child-1')).not.toEqual(
subagentJournalItemIdentity('child-2')
)
// Two long ids sharing a head stay two rows.
const head = 'x'.repeat(600)
expect(subagentJournalItemIdentity(`${head}a`)).not.toEqual(
subagentJournalItemIdentity(`${head}b`)
)
})
it('carries the block beside the sentence an older client draws instead', () => {
const body = subagentJournalBody(child())
expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true)
const broken = { ...body, blocks: [{ type: 'subagent', id: 'child-1', label: 'Audit' }] }
expect(AgentJournalItemBodySchema.safeParse(broken).success).toBe(false)
expect(body.blocks).toEqual([{ type: 'text', text: 'Kicked off subagent Audit' }, child()])
const stopped = subagentJournalBody(
child({ state: 'stopped', settledAt: 20, outcomeBasis: 'stop-acknowledged' })
)
expect(stopped.blocks[0]).toEqual({ type: 'text', text: 'Ran subagent Audit (stopped)' })
})
it('bounds the provider strings it stores', () => {
const body = subagentJournalBody(child({ label: '😀'.repeat(400), id: 'i'.repeat(900) }))
const block = body.blocks.find(isSubagentBlock)!
expect(block.label.length).toBeLessThanOrEqual(512)
expect(block.label.at(-1)).toBe('…')
// No lone surrogate before the ellipsis.
expect(block.label.at(-2)).toMatch(/[\uDC00-\uDFFF]/)
expect(block.id.length).toBe(512)
})
it('is read back by key, and every revision stays the row its spawn created', async () => {
const journal = await open()
await journal.appendItem(
subagentJournalItemIdentity('earlier'),
subagentJournalBody(child({ id: 'earlier' })),
{
fence: 0,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
}
)
await journal.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), {
fence: 0,
turnScope: { kind: 'turn', turnItemId: 'turn-1' }
})
const spawned = journal.snapshot().items.at(-1)!
await journal.appendItem(
subagentJournalItemIdentity('child-1'),
subagentJournalBody(child({ state: 'completed', run: 'call-2', settledAt: 30 })),
{ fence: 0, turnScope: { kind: 'turn', turnItemId: 'turn-9' } }
)
expect(journaledSubagent(journal, 'child-1')).toMatchObject({
state: 'completed',
run: 'call-2'
})
expect(journaledSubagent(journal, 'never-spawned')).toBeNull()
const revised = journal.item(agentJournalItemKey(subagentJournalItemIdentity('child-1')))!
expect(revised.revision).toBe(2)
expect(revised.sequence).toBe(spawned.sequence)
expect(revised.turnScope).toEqual({ kind: 'turn', turnItemId: 'turn-1' })
})
it('reads unverifiable once a host that died left it working, twin and all', async () => {
const live = await open()
await live.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), {
fence: 0,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
await live.close()
const reopened = await open()
const row = reopened.snapshot().items.at(-1)!
expect(row.recovered).toBe(true)
expect(row.body).toMatchObject({
blocks: [
{ type: 'text', text: 'Ran subagent Audit (unverifiable)' },
{ type: 'subagent', id: 'child-1', state: 'unverifiable', run: 'call-1' }
]
})
// Contact was lost at an unknown moment, so no run length is invented.
expect(row.body.kind === 'message' && row.body.blocks[1]).not.toHaveProperty('settledAt')
})
})
@@ -0,0 +1,58 @@
// One subagent's own journal row: its key, its body, and reading it back.
//
// Keyed by the child's own id, so every change and every later run of the child revises the one
// row its spawn created, and a provider process that restarts finds that row by key instead of
// scanning for it.
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import { subagentFallbackText } from '../../../shared/native-chat-subagent-items'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../../shared/native-chat-subagent-summary'
import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types'
import { boundSubagentEntryId } from '../subagent-entry-id-bounds'
/** Scoped by the journal, which is one session's with one provider. */
export function subagentJournalItemIdentity(id: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `subagent:${boundSubagentEntryId(id)}` }
}
/** The row: the block, and the sentence an older client draws in its place. */
export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJournalMessageItem {
const bounded: NativeChatSubagentBlock = {
...block,
id: boundSubagentEntryId(block.id),
label: boundLabel(block.label),
groupId: boundSubagentEntryId(block.groupId),
...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}),
...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {})
}
return {
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: subagentFallbackText(bounded) }, bounded]
}
}
/** The child's saved row, read by key; null when none was written. */
export function journaledSubagent(
journal: { itemBody: (itemId: string) => AgentJournalItemBody | null },
id: string
): NativeChatSubagentBlock | null {
const body = journal.itemBody(agentJournalItemKey(subagentJournalItemIdentity(id)))
return body?.kind === 'message' ? (body.blocks.find(isSubagentBlock) ?? null) : null
}
/** The bound every reader clips a label to. A clip never splits a surrogate pair: a lone one is
* malformed in a durable row. */
function boundLabel(label: string): string {
if (label.length <= MAX_SUBAGENT_FIELD_CHARS) {
return label
}
const keep = MAX_SUBAGENT_FIELD_CHARS - 1
const last = label.charCodeAt(keep - 1)
return `${label.slice(0, last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep)}…`
}
@@ -1,6 +1,6 @@
// A roster row left claiming live children by a host that is gone.
//
// The writing host revises its `subagent-group` rows in place while it can see
// The writing host revises its subagent rows in place while it can see
// the children, and sweeps whatever is still `working` when the provider goes
// away. A host that DIED — crash, quit, force-restart — does neither: its last
// revision goes on saying `working`, and nothing replays those children, so no
@@ -32,11 +32,14 @@ import {
isSettledBackgroundTaskState,
normalizeBackgroundTaskState
} from '../../../shared/native-chat-background-task-row'
import { subagentFallbackText } from '../../../shared/native-chat-subagent-items'
import {
isBackgroundTaskBlock,
isSubagentBlock,
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatBackgroundTaskBlock,
type NativeChatSubagentBlock,
type NativeChatSubagentGroupBlock
} from '../../../shared/native-chat-types'
@@ -81,7 +84,11 @@ export function lostLiveWorkJournalBody(
}
function hasStaleLiveWork(block: NativeChatBlock): boolean {
return hasWorkingChild(block) || hasLiveBackgroundTask(block)
return hasWorkingChild(block) || isWorkingSubagent(block) || hasLiveBackgroundTask(block)
}
function isWorkingSubagent(block: NativeChatBlock): block is NativeChatSubagentBlock {
return isSubagentBlock(block) && normalizeSubagentState(block.state) === 'working'
}
function hasWorkingChild(block: NativeChatBlock): block is NativeChatSubagentGroupBlock {
@@ -103,35 +110,39 @@ function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackg
* down as how long it ran. Readers already draw an unverifiable child with no
* stamp as having no known run length. */
function settleBlocks(blocks: readonly NativeChatBlock[], lostAt?: number): NativeChatBlock[] {
const backgroundTaskTwinText = new Map<string, string>()
const twinText = new Map<string, string>()
const settled = blocks.map((block) => {
if (hasWorkingChild(block)) {
return settleGroup(block, lostAt)
}
if (isWorkingSubagent(block)) {
const next: NativeChatSubagentBlock = {
...block,
state: 'unverifiable',
...(lostAt !== undefined ? { settledAt: lostAt } : {})
}
twinText.set(subagentFallbackText(block), subagentFallbackText(next))
return next
}
if (hasLiveBackgroundTask(block)) {
const next = settleBackgroundTask(block)
backgroundTaskTwinText.set(
backgroundTaskFallbackText(block),
backgroundTaskFallbackText(next)
)
twinText.set(backgroundTaskFallbackText(block), backgroundTaskFallbackText(next))
return next
}
return block
})
const withBackgroundTaskTwins = settled.map((block) =>
block.type === 'text'
? { ...block, text: backgroundTaskTwinText.get(block.text) ?? block.text }
: block
const withTwins = settled.map((block) =>
block.type === 'text' ? { ...block, text: twinText.get(block.text) ?? block.text } : block
)
const rosters = withBackgroundTaskTwins.filter(isSubagentGroupBlock)
const rosters = withTwins.filter(isSubagentGroupBlock)
const only = rosters.length === 1 ? rosters[0] : undefined
if (!only) {
return withBackgroundTaskTwins
return withTwins
}
// The plain-text twin is all a client without the block type ever shows, so it
// has to move with the block or the two would disagree about the same row.
const twin = subagentGroupFallbackText(only.agents)
return withBackgroundTaskTwins.map((block) =>
return withTwins.map((block) =>
block.type === 'text' && isSubagentGroupFallbackText(block.text)
? { ...block, text: twin }
: block
@@ -20,6 +20,10 @@ import {
readAgentSessionHydrationPage
} from './agent-session-history-page'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import {
subagentJournalBody,
subagentJournalItemIdentity
} from '../agent-session-journal/journal-subagent-item'
// A backward page windows over the session's own rows, and a subagent's rows in
// that range ride along, so a burst cannot crowd the conversation off the page.
@@ -146,6 +150,28 @@ describe('a history page over a session with a subagent', () => {
expect(tail.hasOlder).toBe(false)
})
it("carries a fan-out's subagent rows without counting them as conversation", async () => {
await append(named('own-', 1, 5))
for (const id of named('task-', 1, 300)) {
await journal.appendItem(
subagentJournalItemIdentity(id),
subagentJournalBody({ type: 'subagent', id, label: id, state: 'working', groupId: 't' }),
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
await append(['own-6'])
const tail = read({ direction: 'tail', limit: 3 })
expect(textsOf(tail)).toEqual([
'own-4',
'own-5',
...named('Kicked off subagent task-', 1, 300),
'own-6'
])
expect(tail.hasOlder).toBe(true)
})
it('still bounds a page by bytes, and paging back still reaches every row', async () => {
const large = 'x'.repeat(250 * 1024)
await append(['own-1'])
@@ -185,6 +211,33 @@ describe('a history page names the subagents whose roster row is older than it',
])
})
it('names a subagent whose own row is above the opening page, by that row', async () => {
await append(['own-1'])
await journal.appendItem(
subagentJournalItemIdentity('task-1'),
subagentJournalBody({
type: 'subagent',
id: 'task-1',
label: 'run task-1',
state: 'working',
groupId: 'turn-1',
run: 'call-1'
}),
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await append(named('own-', 2, 200))
await append(['child-2'], child)
const page = readAgentSessionHydrationPage(journal)
expect(page.subagentRoster).toEqual([
expect.objectContaining({
itemId: 'orca:subagent%3Atask-1',
entry: { id: 'task-1', label: 'run task-1', state: 'working' }
})
])
})
it('names nothing beside the items when the page carries the roster row', async () => {
await append(['own-1'])
await appendRoster(['task-1'])
@@ -10,6 +10,7 @@
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
import { latestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn'
import { isSubagentBlock } from '../../../shared/native-chat-types'
import type {
AgentJournalCursor,
AgentJournalRenderItem,
@@ -55,17 +56,18 @@ export type AgentSessionHistoryScope = 'every-agent' | 'own-agent'
/**
* The newest `limit` of the session's own rows, in whole sequence groups, and every
* row after the oldest of them: a subagent's rows ride along with the conversation
* they happened in, so its burst cannot crowd the conversation off the page. The page
* stays contiguous and starts at its first item, as the cursor means. Fewer own rows
* than `limit` reach back to the start. With no subagent rows this is the newest
* `limit` rows, as before.
* row after the oldest of them: a subagent's rows, and each subagent's own row, ride
* along with the conversation they happened in, so a burst or a fan-out cannot crowd
* the conversation off the page. The page stays contiguous and starts at its first
* item, as the cursor means; the byte budget still bounds it. Fewer own rows than
* `limit` reach back to the start. With no subagent rows this is the newest `limit`
* rows, as before.
*/
function conversationWindow(
items: readonly AgentJournalRenderItem[],
limit: number
): AgentJournalRenderItem[] {
const own = items.filter(isRootAgentJournalItem)
const own = items.filter((item) => isRootAgentJournalItem(item) && !isSubagentRow(item))
if (own.length === items.length) {
return newestWholeSequenceGroups(items, limit)
}
@@ -74,6 +76,10 @@ function conversationWindow(
return start === undefined ? [...items] : items.filter((item) => item.sequence >= start)
}
function isSubagentRow(item: AgentJournalRenderItem): boolean {
return item.body.kind === 'message' && item.body.blocks.some(isSubagentBlock)
}
export function readAgentSessionHistory(
journal: AgentSessionJournal,
request: AgentSessionHistoryRequest,
@@ -11,10 +11,8 @@ import {
} from '../../../shared/agent-session-journal-producer'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionSubagentRosterEntry } from '../../../shared/agent-session-wire'
import {
isSubagentGroupBlock,
type NativeChatSubagentEntry
} from '../../../shared/native-chat-types'
import { namedSubagents } from '../../../shared/native-chat-subagent-items'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
/** Bounds the field well inside the page's envelope reserve. */
const MAX_ENTRIES = 64
@@ -24,7 +22,7 @@ function rosterEntries(item: AgentJournalRenderItem): readonly NativeChatSubagen
if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') {
return []
}
return item.body.blocks.flatMap((block) => (isSubagentGroupBlock(block) ? block.agents : []))
return item.body.blocks.flatMap(namedSubagents)
}
/** `journal` in journal order; `page` a run of it. Undefined when the page names every
@@ -7,7 +7,7 @@ import type { NativeChatBlock, NativeChatSubagentState } from '../../../shared/n
type WorkerTranscriptActivityBlock = Extract<
NativeChatBlock,
{ type: 'subagent-group' | 'background-task' }
{ type: 'subagent-group' | 'subagent' | 'background-task' }
>
export type WorkerTranscriptActivityBlockBounders = {
@@ -39,6 +39,17 @@ export function boundWorkerTranscriptActivityBlock(
}))
}
}
if (block.type === 'subagent') {
return {
...block,
id: bounders.boundEntryId(block.id),
label: bounders.clipMetadata(block.label),
state: clipSubagentState(block.state, bounders),
groupId: bounders.clipMetadata(block.groupId),
...(block.parentId !== undefined ? { parentId: bounders.boundEntryId(block.parentId) } : {}),
...(block.run !== undefined ? { run: bounders.clipMetadata(block.run) } : {})
}
}
const { outputFile, ...carried } = block
if (outputFile) {
bounders.markClipped('Background task output paths were omitted from transcript output.')
@@ -139,7 +139,11 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native
input: boundToolInput(block.input, budget, 0, state)
}
}
if (block.type === 'subagent-group' || block.type === 'background-task') {
if (
block.type === 'subagent-group' ||
block.type === 'subagent' ||
block.type === 'background-task'
) {
return boundWorkerTranscriptActivityBlock(block, {
clipMetadata: (value) => clipMetadata(value, state),
clipText: (value) => clipText(value, state),
@@ -32,4 +32,21 @@ describe('mobile subagent roster bounds', () => {
const block = sanitizeNativeChatRpcBlock(rosterBlock(['task-1']), 'mobile')
expect(block.type === 'subagent-group' && block.agents[0]?.id).toBe('task-1')
})
it('bounds a subagent’s own row like a roster’s entry, and leaves other clients its fields', () => {
const own: NativeChatBlock = {
type: 'subagent',
id: `${'p'.repeat(512)}a`,
label: 'l'.repeat(600),
state: 'working',
groupId: 'turn-1',
run: 'call-1',
outcomeBasis: 'stop-acknowledged'
}
const block = sanitizeNativeChatRpcBlock(own, 'mobile')
expect(block).toMatchObject({ type: 'subagent', groupId: 'turn-1', run: 'call-1' })
expect(block.type === 'subagent' && block.id.length).toBe(512)
expect(block.type === 'subagent' && block.label.length).toBeLessThan(600)
expect(sanitizeNativeChatRpcBlock(own, 'runtime')).toBe(own)
})
})
@@ -72,6 +72,17 @@ export function sanitizeNativeChatRpcBlock(
}))
}
}
if (block.type === 'subagent') {
return {
...block,
id: boundSubagentEntryId(block.id),
label: clip(block.label, MAX_SUBAGENT_FIELD_CHARS),
state: clipSubagentState(block.state),
groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS),
...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}),
...(block.run !== undefined ? { run: clip(block.run, MAX_SUBAGENT_FIELD_CHARS) } : {})
}
}
return block
}
@@ -240,6 +240,34 @@ describe("a subagent's rows live in its own section", () => {
expect(new Set(slots.map(nativeChatSlotKey)).size).toBe(slots.length)
})
it('opens each under the row its turn’s subagents draw as, from their own rows alike', () => {
const own = (agentId: string, label: string) =>
row(
`subagent:${agentId}`,
[
{ type: 'text', text: `Ran subagent ${label}` },
{ type: 'subagent', id: agentId, label, state: 'completed', groupId: 'turn-1' }
],
{ role: 'system' }
)
const rows = [
row('ask', say('review the PR'), { role: 'user' }),
own('task-1', 'explore'),
row('child-look', say('Looking at the diff.'), by('task-1')),
row('answer', say('Delegated.')),
own('task-2', 'explore'),
row('child-2-look', say('Me too.'), by('task-2'))
]
expect(outline(slotsOf(rows, { 'task-2': true }))).toEqual([
'ask',
'subagent:task-1',
'•task-1',
'•task-2 open',
'>child-2-look',
'answer'
])
})
// The parent's live line speaks for the session's own agent only, so a child's open block draws.
it("draws a working agent's open reasoning in its section", () => {
const thinking = row('child-think', say('Comparing the two diffs'), {
@@ -110,6 +110,15 @@ const Block = openDiscriminatedUnion(
groupId: z.string(),
agents: z.array(SubagentEntry)
}),
// `outcomeBasis` and `background` stay open for the same reason `state` does.
SubagentEntry.extend({
type: z.literal('subagent'),
groupId: z.string(),
parentId: z.string().optional(),
run: z.string().optional(),
outcomeBasis: z.string().optional(),
background: z.boolean().optional()
}),
// `kind` and `state` stay open strings for the same reason a child's
// lifecycle does: a vocabulary a newer build writes must not turn the row
// malformed. The renderer falls back on anything it cannot name.
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import {
foldSubagentItems,
namedSubagents,
subagentFallbackText
} from './native-chat-subagent-items'
import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection'
import { formatWorkerTranscriptMessage } from './worker-transcript-text'
import type {
NativeChatMessage,
NativeChatSubagentBlock,
NativeChatSubagentGroupBlock
} from './native-chat-types'
function subagent(
id: string,
overrides: Partial<NativeChatSubagentBlock> = {}
): NativeChatSubagentBlock {
return { type: 'subagent', id, label: id, state: 'working', groupId: 'turn-1', ...overrides }
}
function item(sequence: number, block: NativeChatSubagentBlock): NativeChatMessage {
return {
id: `subagent:${block.id}`,
role: 'system',
blocks: [{ type: 'text', text: subagentFallbackText(block) }, block],
timestamp: sequence,
source: 'transcript',
journalPosition: { sequence, index: 0 }
}
}
function text(sequence: number, role: NativeChatMessage['role'], body: string): NativeChatMessage {
return {
id: `row-${sequence}`,
role,
blocks: [{ type: 'text', text: body }],
timestamp: sequence,
source: 'transcript',
journalPosition: { sequence, index: 0 }
}
}
function groupOf(message: NativeChatMessage | undefined): NativeChatSubagentGroupBlock {
const [block] = message?.blocks ?? []
if (block?.type !== 'subagent-group') {
throw new Error('expected a roster row')
}
return block
}
describe('a turn’s subagents drawn as one row', () => {
it('folds siblings into one roster row at the first one’s place, whatever lies between', () => {
const rows = foldSubagentItems([
text(1, 'user', 'audit it'),
item(2, subagent('a', { label: 'Audit' })),
text(3, 'assistant', 'and another'),
item(4, subagent('b', { label: 'Review', state: 'completed', settledAt: 9 }))
])
expect(rows.map((row) => row.id)).toEqual(['row-1', 'subagent:a', 'row-3'])
expect(groupOf(rows[1])).toEqual({
type: 'subagent-group',
groupId: 'turn-1',
agents: [
{ id: 'a', label: 'Audit', state: 'working' },
{ id: 'b', label: 'Review', state: 'completed', settledAt: 9 }
]
})
})
it('keeps another turn’s, or another spawner’s, subagents in rows of their own', () => {
const rows = foldSubagentItems([
item(1, subagent('a')),
item(2, subagent('b', { groupId: 'turn-2' })),
item(3, subagent('c', { parentId: 'a' }))
])
expect(rows.map((row) => groupOf(row).agents.map((agent) => agent.id))).toEqual([
['a'],
['b'],
['c']
])
// The phone keys a group's open state by groupId, so a helper's helpers get their own.
expect(new Set(rows.map((row) => groupOf(row).groupId)).size).toBe(3)
})
it('tells same-named siblings apart without taking a name a sibling already has', () => {
const rows = foldSubagentItems([
item(1, subagent('a', { label: 'Audit' })),
item(2, subagent('b', { label: 'Audit' })),
item(3, subagent('c', { label: 'Audit 2' }))
])
expect(groupOf(rows[0]).agents.map((agent) => agent.label)).toEqual([
'Audit',
'Audit 3',
'Audit 2'
])
})
it('hands back the same row while no sibling changed, and a new one when one did', () => {
const a = item(1, subagent('a'))
const b = item(2, subagent('b'))
const first = foldSubagentItems([a, b])[0]
expect(foldSubagentItems([a, b])[0]).toBe(first)
const settled = item(2, subagent('b', { state: 'completed' }))
const next = foldSubagentItems([a, settled])[0]
expect(next).not.toBe(first)
// The sibling that did not change keeps its line, so its memoized row stays put.
expect(groupOf(next).agents[0]).toBe(groupOf(first).agents[0])
})
it('leaves an older host’s roster row, and rows that are not one subagent, as they came', () => {
const roster: NativeChatMessage = {
...text(1, 'system', 'Ran 1 subagent'),
blocks: [
{ type: 'text', text: 'Ran 1 subagent' },
{
type: 'subagent-group',
groupId: 'turn-0',
agents: [{ id: 'old', label: 'old', state: 'completed' }]
}
]
}
const mixed: NativeChatMessage = {
...text(2, 'assistant', 'x'),
blocks: [subagent('z'), { type: 'tool-call', name: 'Agent', input: {} }]
}
const messages = [roster, mixed]
expect(foldSubagentItems(messages)).toBe(messages)
})
it('is what the transcript draws, so the row folds no tool run apart', () => {
const rows = projectNativeChatTranscriptMessages([
text(1, 'user', 'go'),
{
...text(2, 'assistant', ''),
blocks: [{ type: 'tool-call', name: 'Agent', input: {}, callId: 'c1' }]
},
item(3, subagent('a')),
{ ...text(4, 'tool', ''), blocks: [{ type: 'tool-result', output: 'ok', callId: 'c1' }] }
])
expect(rows.map((row) => row.blocks.map((block) => block.type))).toEqual([
['text'],
['tool-call', 'tool-result'],
['subagent-group']
])
})
})
describe('the sentence an older client draws', () => {
it('claims no liveness it cannot keep, and names an adverse outcome', () => {
expect(subagentFallbackText(subagent('a', { label: 'Audit' }))).toBe(
'Kicked off subagent Audit'
)
expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'completed' }))).toBe(
'Ran subagent Audit'
)
expect(subagentFallbackText(subagent('a', { label: 'Audit', state: 'failed' }))).toBe(
'Ran subagent Audit (failed)'
)
})
it('is what a text surface prints, once, and the block alone still prints it', () => {
const own = item(1, subagent('a', { label: 'Audit' }))
expect(formatWorkerTranscriptMessage(own)).toBe('[system] Kicked off subagent Audit')
expect(formatWorkerTranscriptMessage({ ...own, blocks: own.blocks.slice(1) })).toBe(
'[system] [subagents] Kicked off subagent Audit'
)
})
})
describe('the subagents a row names', () => {
it('reads an older host’s roster and a subagent’s own row alike', () => {
expect(
namedSubagents(subagent('a', { run: 'r', outcomeBasis: 'stop-acknowledged', tokens: 3 }))
).toEqual([{ id: 'a', label: 'a', state: 'working', tokens: 3 }])
expect(
namedSubagents({
type: 'subagent-group',
groupId: 'g',
agents: [{ id: 'b', label: 'b', state: 'idle' }]
})
).toEqual([{ id: 'b', label: 'b', state: 'idle' }])
expect(namedSubagents({ type: 'text', text: 'x' })).toEqual([])
})
})
+171
View File
@@ -0,0 +1,171 @@
// A subagent's own row, and the group its siblings draw as.
//
// The host writes one row per subagent, where it was spawned, and revises that row alone for
// every change, so no row ever lists siblings and nothing caps how many a turn spawns. "Ran N
// subagents" is how readers draw the siblings one turn spawned: every transcript (desktop and
// the phone) folds them here into the roster row older hosts wrote, at the first sibling's
// place, so one renderer draws both. Ordinals that tell same-named siblings apart are derived
// here too, never stored.
import { normalizeSubagentState } from './native-chat-subagent-summary'
import {
isSubagentBlock,
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatMessage,
type NativeChatSubagentBlock,
type NativeChatSubagentEntry,
type NativeChatSubagentGroupBlock
} from './native-chat-types'
/** The plain sentence an older client draws in the block's place, frozen at write time. Like the
* group's, it claims no live count: `Kicked off` only says no outcome had landed yet. */
export function subagentFallbackText(block: NativeChatSubagentEntry): string {
const state = normalizeSubagentState(block.state)
if (state === 'working') {
return `Kicked off subagent ${block.label}`
}
const adverse = state === 'failed' || state === 'stopped' || state === 'unverifiable'
return `Ran subagent ${block.label}${adverse ? ` (${state})` : ''}`
}
/** The entry a group row draws for this subagent: the block less what only its writer reads. */
export function subagentEntryOf(
block: NativeChatSubagentBlock,
label = block.label
): NativeChatSubagentEntry {
return {
id: block.id,
label,
state: block.state,
...(block.tokens !== undefined ? { tokens: block.tokens } : {}),
...(block.startedAt !== undefined ? { startedAt: block.startedAt } : {}),
...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {})
}
}
/** The subagents a block names: an older host's group row lists them, a subagent's own row is one. */
export function namedSubagents(block: NativeChatBlock): readonly NativeChatSubagentEntry[] {
if (isSubagentGroupBlock(block)) {
return block.agents
}
return isSubagentBlock(block) ? [subagentEntryOf(block)] : []
}
/** The subagent a row is, when it is one: its block, beside nothing but its twin sentence. */
function subagentItemBlock(message: NativeChatMessage): NativeChatSubagentBlock | null {
let found: NativeChatSubagentBlock | null = null
for (const block of message.blocks) {
if (isSubagentBlock(block) && found === null) {
found = block
} else if (block.type !== 'text') {
return null
}
}
return found
}
type Folded = { members: readonly NativeChatMessage[]; row: NativeChatMessage }
/** Keyed by the first sibling: an unchanged group hands back the same row, so the transcript's
* memoized rows stay put while one sibling streams. */
const foldedGroups = new WeakMap<NativeChatMessage, Folded>()
const foldedEntries = new WeakMap<NativeChatSubagentBlock, NativeChatSubagentEntry>()
/** `messages` in transcript order. Sibling subagent rows become one roster row at the first
* sibling's place; every other row is handed back as it came. */
export function foldSubagentItems(
messages: readonly NativeChatMessage[]
): readonly NativeChatMessage[] {
const groups = new Map<string, NativeChatMessage[]>()
let any = false
for (const message of messages) {
const block = subagentItemBlock(message)
if (block === null) {
continue
}
any = true
// Also the folded row's groupId: a helper's own helpers keep open state apart from the turn's.
const key =
block.parentId === undefined ? block.groupId : JSON.stringify([block.groupId, block.parentId])
const siblings = groups.get(key)
if (siblings) {
siblings.push(message)
} else {
groups.set(key, [message])
}
}
if (!any) {
return messages
}
const rows = new Map<NativeChatMessage, NativeChatMessage | null>()
for (const [groupId, members] of groups) {
const [first, ...rest] = members
rows.set(first!, groupRow(groupId, members))
for (const member of rest) {
rows.set(member, null)
}
}
const folded: NativeChatMessage[] = []
for (const message of messages) {
const row = rows.get(message)
if (row === undefined) {
folded.push(message)
} else if (row !== null) {
folded.push(row)
}
}
return folded
}
function groupRow(groupId: string, members: readonly NativeChatMessage[]): NativeChatMessage {
const first = members[0]!
const held = foldedGroups.get(first)
if (
held &&
held.members.length === members.length &&
held.members.every((member, index) => member === members[index])
) {
return held.row
}
const blocks = members.map((member) => subagentItemBlock(member)!)
const group: NativeChatSubagentGroupBlock = {
type: 'subagent-group',
groupId,
agents: labelledEntries(blocks)
}
const row: NativeChatMessage = { ...first, blocks: [group] }
foldedGroups.set(first, { members: [...members], row })
return row
}
/** Two siblings can share a name; an ordinal keeps their lines apart without inventing a name the
* provider never sent. Probed over the labels already taken, so a derived `Audit 2` never
* collides with a sibling the provider itself named `Audit 2`. */
function labelledEntries(blocks: readonly NativeChatSubagentBlock[]): NativeChatSubagentEntry[] {
const taken = new Set<string>()
const repeated = new Set<string>()
for (const block of blocks) {
if (taken.has(block.label)) {
repeated.add(block.label)
}
taken.add(block.label)
}
const claimed = new Set<string>()
return blocks.map((block) => {
let label = block.label
if (repeated.has(label) && claimed.has(label)) {
for (let ordinal = 2; taken.has(label) || claimed.has(label); ordinal++) {
label = `${block.label} ${ordinal}`
}
}
claimed.add(label)
const held = foldedEntries.get(block)
if (held?.label === label) {
return held
}
const entry = subagentEntryOf(block, label)
foldedEntries.set(block, entry)
return entry
})
}
@@ -12,6 +12,7 @@ import { compareAgentJournalPositions } from './agent-session-journal-position'
import { agentJournalItemSubagentId } from './agent-session-journal-producer'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages } from './native-chat-tool-fold'
import { foldSubagentItems } from './native-chat-subagent-items'
import { nativeChatTurnMembership, type NativeChatTurnJournal } from './native-chat-turn-membership'
/** Timestamp, then id. A null timestamp sorts first so a source that cannot supply
@@ -85,7 +86,7 @@ export type NativeChatTranscriptProjection = {
const NO_SUBAGENT_ROWS: ReadonlyMap<string, readonly NativeChatSubagentRow[]> = new Map()
function projectRows(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
return stripNoiseMessages(foldToolMessages(messages))
return stripNoiseMessages(foldToolMessages(foldSubagentItems(messages)))
}
function sortedCopy(
+23
View File
@@ -162,6 +162,24 @@ export type NativeChatSubagentGroupBlock = {
agents: NativeChatSubagentEntry[]
}
/** One subagent's own row: written where it was spawned and revised in place for every later
* change and run. Siblings sharing `groupId` and `parentId` draw as one group, which readers
* derive; no row lists them together. */
export type NativeChatSubagentBlock = NativeChatSubagentEntry & {
type: 'subagent'
/** The turn that spawned it: siblings with the same key draw together. */
groupId: string
/** The subagent that spawned it. Absent ⇒ the session's own agent did. */
parentId?: string
/** The provider's id for the run `state` describes. Absent when the provider names none. */
run?: string
/** `stop-acknowledged`: stopped because the agent accepted a Stop, not because the child
* reported its own end; that run's own ending may still replace it. */
outcomeBasis?: 'stop-acknowledged'
/** Told to outlive the turn that spawned it. */
background?: true
}
/** One provider background task — a shell command, workflow or monitor run
* beside the turn — as its own durable row, revised in place from the
* provider's lifecycle frames. Sibling of the roster block rather than a
@@ -203,6 +221,7 @@ export type NativeChatBlock =
| NativeChatToolResultBlock
| NativeChatImageRefBlock
| NativeChatSubagentGroupBlock
| NativeChatSubagentBlock
| NativeChatBackgroundTaskBlock
/** A transcript row. Structured rows carry the journal row's producer linkage, so
@@ -362,6 +381,10 @@ export function isSubagentGroupBlock(
return block.type === 'subagent-group'
}
export function isSubagentBlock(block: NativeChatBlock): block is NativeChatSubagentBlock {
return block.type === 'subagent'
}
export function isBackgroundTaskBlock(
block: NativeChatBlock
): block is NativeChatBackgroundTaskBlock {
@@ -104,4 +104,29 @@ describe('the client roster of subagents', () => {
expect(named.has('agent-599')).toBe(true)
expect(named.has('agent-0')).toBe(false)
})
it('names a subagent from its own row, and forgets it with that row', () => {
const own: AgentJournalRenderItem = {
itemId: 'orca:subagent:a',
revision: 1,
sequence: 4,
observedAt: 4,
body: {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: 'Kicked off subagent review' },
{ type: 'subagent', id: 'a', label: 'review', state: 'working', groupId: 't', run: 'x' }
]
}
}
const named = fold([own])
expect(named.get('a')).toEqual({
entry: { id: 'a', label: 'review', state: 'working' },
rosterItemId: 'orca:subagent:a',
rosterPosition: { sequence: 4, index: 0 },
rosterRevision: 1
})
expect(foldStructuredAgentSubagentRoster(named, [], ['orca:subagent:a']).size).toBe(0)
})
})
@@ -15,7 +15,8 @@ import {
compareAgentJournalPositions
} from './agent-session-journal-position'
import type { AgentSessionHistoryPage, AgentSessionSubagentRosterEntry } from './agent-session-wire'
import { isSubagentGroupBlock, type NativeChatSubagentEntry } from './native-chat-types'
import { namedSubagents } from './native-chat-subagent-items'
import type { NativeChatSubagentEntry } from './native-chat-types'
/** One subagent as the first roster row naming it records it. */
export type StructuredAgentSubagentRosterEntry = {
@@ -93,18 +94,13 @@ export function foldStructuredAgentSubagentRoster(
if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') {
continue
}
for (const block of item.body.blocks) {
if (!isSubagentGroupBlock(block)) {
continue
}
for (const entry of block.agents) {
take({
entry,
rosterItemId: item.itemId,
rosterPosition: agentJournalItemPosition(item),
rosterRevision: item.revision
})
}
for (const entry of item.body.blocks.flatMap(namedSubagents)) {
take({
entry,
rosterItemId: item.itemId,
rosterPosition: agentJournalItemPosition(item),
rosterRevision: item.revision
})
}
}
const next = draft.next
@@ -125,10 +121,8 @@ function rosterAgentIds(item: AgentJournalRenderItem): ReadonlySet<string> | nul
if (!isRootAgentJournalItem(item) || item.body.kind !== 'message') {
return null
}
const groups = item.body.blocks.filter(isSubagentGroupBlock)
return groups.length === 0
? null
: new Set(groups.flatMap((group) => group.agents.map((agent) => agent.id)))
const named = item.body.blocks.flatMap(namedSubagents)
return named.length === 0 ? null : new Set(named.map((agent) => agent.id))
}
/** Folds a history page: its rows, its removals, and the entries it names beside them. */
+7
View File
@@ -11,6 +11,7 @@ import {
isSubagentGroupFallbackText,
subagentGroupFallbackText
} from './native-chat-subagent-summary'
import { subagentFallbackText } from './native-chat-subagent-items'
import type { NativeChatMessage } from './native-chat-types'
export function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
@@ -44,6 +45,12 @@ export function formatWorkerTranscriptMessage(message: NativeChatMessage): strin
if (block.type === 'subagent-group') {
return standIns.get(index) ?? null
}
if (block.type === 'subagent') {
// A subagent's own row is its block and its twin, nothing else.
return message.blocks.some((other) => other.type === 'text')
? null
: `[subagents] ${subagentFallbackText(block)}`
}
// The journal deliberately admits block types this build does not know, and
// a newer remote host can send one over the wire. Degrade to a marker rather
// than reading fields off a shape that has none.