mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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:
@@ -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
|
||||
|
||||
+53
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user