Files
orca/src/shared/worker-transcript-text.ts
T
Brennan Benson e8e144bf3c fix(native-chat): a subagent's words are presented as that subagent's, never the parent's (#23605)
* fix(native-chat): a subagent's words are presented as that subagent's, never the parent's

The journal already names the agent that produced every row, but the transcript
projection dropped it, so a subagent's prose rendered as the parent's reply, its
tool calls folded into the parent's runs, and a settled turn could fold down to
a subagent's words as its only visible answer.

The transcript message now keeps the row's producer. The fold keeps each agent's
calls in that agent's own run, a turn's answer is the session's own agent's last
prose, and a subagent's row names the subagent on desktop, mobile and a worker's
transcript text.

* test(native-chat): give the window fixture's slot the attribution field it now carries

* fix(mobile): read the subagent label the row is given, and pin the caption

* fix(native-chat): keep interleaved agents in order and each agent's own run live

Review follow-ups:
- the fold is main's adjacency fold plus one condition: a row never folds into
  another agent's run, so an agent's later call stays below its subagent's work
  instead of jumping back into its earlier row
- each agent has its own live frontier, so a parent still inside its spawn call
  reads as running while its subagent works below it
- mobile names no one on a row whose only content is hidden behind its settled turn
- a pending question from a subagent keeps its producer
- worker reads serve only the producing agent's id, bounded like the roster key
  that names it, and drop the provenance fields
- the single-message worker formatter is private, so no caller can drop names
2026-09-28 15:28:01 -07:00

133 lines
5.5 KiB
TypeScript

/**
* The one plain-text rendering of a worker transcript message.
*
* The CLI prints `worker-read --source transcript` with it, and `terminal read` serves a structured
* worker's recent output through it, so a peer sees the same text either way. Shared rather than
* copied: two renderings would let the two surfaces disagree about what a tool call looked like.
*/
import { claimBackgroundTaskTwins } from './native-chat-background-task-row'
import {
isSubagentGroupFallbackText,
subagentGroupFallbackText
} from './native-chat-subagent-summary'
import type { NativeChatMessage } from './native-chat-types'
import { agentJournalItemSubagentId } from './agent-session-journal-producer'
import {
nativeChatSubagentLabel,
nativeChatSubagentLabels
} from './native-chat-subagent-attribution'
/** Every message in one read, each tagged with who it speaks as. A subagent is
* named by the roster on the same page. */
export function formatWorkerTranscriptMessages(messages: readonly NativeChatMessage[]): string[] {
const subagentLabels = nativeChatSubagentLabels(messages)
return messages.map((message) => formatWorkerTranscriptMessage(message, subagentLabels))
}
/** Who a line speaks as. A subagent's lines are tagged as its own, so a peer reading
* a worker's transcript never takes a subagent's words for the worker's. */
function workerTranscriptSpeaker(
message: NativeChatMessage,
subagentLabels: ReadonlyMap<string, string> | undefined
): string {
if (agentJournalItemSubagentId(message) === null) {
return message.role
}
const label = nativeChatSubagentLabel(subagentLabels, message)
return label === undefined ? `${message.role}, subagent` : `${message.role}, subagent ${label}`
}
function formatWorkerTranscriptMessage(
message: NativeChatMessage,
subagentLabels: ReadonlyMap<string, string>
): string {
// Every roster block is written beside a plain-text twin carrying the same
// sentence, for clients that cannot draw the block. Text surfaces are those
// clients, so they print the twin and drop the block. The renderer reaches the
// same single print from the other side but not by the same rule: it drops
// every fallback-shaped text block as soon as any group is present and draws
// each group, so it never has to decide which twin belongs to which group.
const standIns = claimSubagentGroupTwins(message.blocks)
// A background task's row is the same shape: one block, one frozen twin. Its
// twin is matched on exact text rather than by shape, because the sentence is
// often the provider's own and has none.
const taskTwins = claimBackgroundTaskTwins(message.blocks)
const blocks = message.blocks.map((block, index) => {
if (block.type === 'text') {
return block.text
}
if (block.type === 'background-task') {
return taskTwins.unpairedRows.get(index) ?? null
}
if (block.type === 'tool-call') {
return `[tool ${block.name}] ${safeJson(block.input)}`
}
if (block.type === 'tool-result') {
return `[tool result${block.isError ? ' error' : ''}] ${block.output}`
}
if (block.type === 'image-ref') {
return block.url ? `[image] ${block.url}` : `[image omitted]`
}
if (block.type === 'subagent-group') {
return standIns.get(index) ?? null
}
// 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.
return '[unsupported block]'
})
return `[${workerTranscriptSpeaker(message, subagentLabels)}] ${blocks.filter((line) => line !== null).join('\n')}`.trimEnd()
}
/** For each roster block, the sentence it must print itself — absent when a twin
* beside it already prints one.
*
* Exact-text claims are settled for EVERY group before any leftover twin is
* claimed by position: claiming in block order let an earlier group consume a
* later group's twin, silencing the earlier roster while the later one printed
* twice. The positional fallback stays because a roster written by a newer build
* holds a state this build reads as `unverifiable`, so its frozen twin can never
* equal the sentence recomputed here and a text match alone would print it
* twice. A group left with no twin prints its own: the wire admits a roster that
* arrived without one, and dropping that would lose the sentence altogether. */
function claimSubagentGroupTwins(blocks: NativeChatMessage['blocks']): Map<number, string> {
const twins = new Map<string, number>()
let remainingTwins = 0
const groups: { index: number; sentence: string }[] = []
blocks.forEach((block, index) => {
if (block.type === 'text' && isSubagentGroupFallbackText(block.text)) {
twins.set(block.text, (twins.get(block.text) ?? 0) + 1)
remainingTwins += 1
} else if (block.type === 'subagent-group') {
groups.push({ index, sentence: subagentGroupFallbackText(block.agents) })
}
})
const standIns = new Map<number, string>()
const unclaimed = groups.filter((group) => {
const count = twins.get(group.sentence) ?? 0
if (count === 0) {
return true
}
twins.set(group.sentence, count - 1)
remainingTwins -= 1
return false
})
for (const group of unclaimed) {
if (remainingTwins > 0) {
remainingTwins -= 1
continue
}
standIns.set(group.index, `[subagents] ${group.sentence}`)
}
return standIns
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value)
} catch {
return '[unserializable input]'
}
}