Files
orca/src/shared/structured-agent-session-projection.ts
T
Brennan Benson db574ae83a feat(native-chat): one current-work projection for every host reader; record death first
The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.
2026-10-12 16:00:31 -07:00

356 lines
14 KiB
TypeScript

import {
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeOptionalField,
normalizePromptField
} from './agent-status-field-normalization'
import {
AGENT_JOURNAL_MESSAGE_SEND_MODES,
AGENT_JOURNAL_MESSAGE_STATES,
type AgentJournalMessageItem,
type AgentJournalMessageSendMode,
type AgentJournalRenderItem,
type AgentJournalSubmission
} from './agent-session-journal-types'
import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome'
import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle'
import { agentJournalLinkageFields } from './agent-session-journal-producer'
import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block'
import { agentJournalItemRowOrigin } from './agent-session-journal-position'
import {
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH
} from './agent-status-types'
import { describeToolInput } from './native-chat-tool-summary'
import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn'
import {
hasStructuredAgentSessionRequest,
latestStructuredAgentSessionAssistantMessage,
latestStructuredAgentSessionPrompt,
latestStructuredAgentSessionRequest,
type StructuredAgentSessionLatestRequest
} from './structured-agent-session-latest-request'
import {
isStructuredAgentSessionToolAction,
structuredAgentSessionToolCallBlock
} from './structured-agent-session-tool-call-block'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import { sha256 } from './sha256'
import { readAgentMessageSource } from './agent-session-message-source'
import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at'
import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work'
import type { StructuredAgentSessionWorkScope } from './structured-agent-session-main-agent-working'
import { agentSessionCurrentContextRows } from './agent-session-context-clear'
// Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one
// import site.
export {
activeStructuredAgentSessionTurnId,
newestStructuredAgentSessionTurn
} from './structured-agent-session-live-turn'
export { hasUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch'
function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string {
return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head
}
/** The markers a clipped payload carries in its own text, anchored to the end
* so nothing that merely looks like one inside the body can match. */
const BOUNDED_TEXT_MARKERS = [
/\n… \(\d+ bytes\)$/,
/\n\[Orca: output truncated — \d+ bytes total, digest [0-9a-f]+\]$/
]
/** Recovers the clipped body from a bounded payload's text, and says whether a
* marker was there. A reader that treats the text as content renders the
* marker as a line of it — with a line number, which reads as a real position
* in the file — and reports the body as complete. */
export function stripBoundedTextMarker(text: string): { text: string; truncated: boolean } {
const stripped = BOUNDED_TEXT_MARKERS.reduce((value, marker) => value.replace(marker, ''), text)
return { text: stripped, truncated: stripped.length !== text.length }
}
function itemBlocks(item: AgentJournalRenderItem): {
role: NativeChatMessage['role']
blocks: NativeChatBlock[]
} | null {
const body = item.body
if (body.kind === 'message') {
return { role: body.role, blocks: body.blocks }
}
if (isStructuredAgentSessionToolAction(body)) {
const call = structuredAgentSessionToolCallBlock(body, item.itemId)
// The call and its output are one journal row, so the result names its call.
const { callId } = call
if (body.kind === 'diff') {
return {
role: 'assistant',
blocks: [call, { type: 'tool-result', output: boundedText(body.patch), callId }]
}
}
return {
role: 'assistant',
blocks: [
call,
...(body.output
? [
{
type: 'tool-result' as const,
output: boundedText(body.output),
// Output a call left when it was cut short is not an error it reported.
isError: agentJournalToolCallLifecycle(body) === 'failed',
callId
}
]
: [])
]
}
}
if (body.kind === 'approval') {
if (body.resolution.state === 'pending') {
return null
}
return {
role: 'system',
blocks: [
{
type: 'text',
text: `${body.title}\n${body.detail ?? ''}\n${body.resolution.state}`.trim()
}
]
}
}
if (body.kind === 'question') {
if (body.resolution.state === 'pending') {
return null
}
const choices = body.options.map((option) => option.label).join(' · ')
return {
role: 'system',
blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }]
}
}
// A turn record is timing, not content; a kind this build does not know is
// never painted as text either, so a newer host can add kinds freely.
if (body.kind !== 'status' || body.turnLifecycle) {
return null
}
return {
role: 'system',
blocks: [structuredAgentSessionStatusBlock(body, item.turnScope, item.itemId)]
}
}
function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode {
return AGENT_JOURNAL_MESSAGE_SEND_MODES.some((mode) => mode === value)
}
/** A state this build cannot name reads as completed: a newer host's row is never live here. */
function messageLifecycle(
body: AgentJournalMessageItem
): Pick<NativeChatMessage, 'state' | 'completedAt'> {
const state: string | undefined = body.state
if (state === undefined) {
return {}
}
return {
state: AGENT_JOURNAL_MESSAGE_STATES.find((known) => known === state) ?? 'completed',
...(body.completedAt !== undefined ? { completedAt: body.completedAt } : {})
}
}
const projectedItems = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
/** Deliberately NOT scoped by producer: every agent's rows are projected, and
* each message keeps its row's linkage so the transcript can keep a subagent's
* rows with that subagent. Every "what is this agent doing right now" scan
* renders only the session's own agent's. */
export function projectStructuredItemsToNativeChat(
items: readonly AgentJournalRenderItem[]
): NativeChatMessage[] {
const messages: NativeChatMessage[] = []
items.forEach((item) => {
const projected = projectStructuredItemToNativeChat(item)
if (projected) {
messages.push(projected)
}
})
return messages
}
export function projectStructuredItemToNativeChat(
item: AgentJournalRenderItem
): NativeChatMessage | null {
const cached = projectedItems.get(item)
if (cached !== undefined) {
return cached
}
// Reducer updates replace journal items, so unchanged rows keep their render caches.
const projected = itemBlocks(item)
const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined
const lifecycle = item.body.kind === 'message' ? messageLifecycle(item.body) : {}
const command = item.body.kind === 'message' ? item.body.command : undefined
// Read here too: a client's journal comes off the wire, from a host of any version.
const from = item.body.kind === 'message' ? readAgentMessageSource(item.body.from) : undefined
const message: NativeChatMessage | null = projected
? {
...agentJournalItemRowOrigin(item),
...agentJournalLinkageFields(item),
role: projected.role,
blocks: projected.blocks,
// A send mode this build cannot name renders as an ordinary message.
...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}),
...lifecycle,
...(command ? { command } : {}),
...(from && projected.role === 'user' ? { from } : {})
}
: null
projectedItems.set(item, message)
return message
}
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
export function structuredAgentSessionTabId(sessionId: string): string {
return `structured-agent-session-${sessionId}`
}
/** A prompt waiting on the user, unless the host's scope says an ended generation raised it:
* nothing can take its answer. */
function pendingStructuredAgentSessionPrompt(
scope?: StructuredAgentSessionWorkScope
): (item: AgentJournalRenderItem) => boolean {
return (item) =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending' &&
(!scope || scope.isCurrentItem(item.itemId))
}
/** `scope`: a host's (`StructuredAgentSessionWorkScope`), so work an ended generation left neither
* holds nor reads as Working. */
export function projectStructuredAgentSessionStatus(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null,
scope?: StructuredAgentSessionWorkScope
): StructuredAgentSessionProjectedStatus {
;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
if (items.some(pendingStructuredAgentSessionPrompt(scope))) {
return 'attention'
}
return owesStructuredAgentSessionWork(items, submissions, currentFence, scope)
? 'working'
: 'idle'
}
/** The activity fields a sidebar row shows beside the prompt, named as the agent-status
* entry names them so the client can hand them straight to a row. */
export type StructuredAgentSessionStatusProjection = {
status: StructuredAgentSessionProjectedStatus | null
latestPrompt: string
/** Present only while a turn is running — see showsAgentToolPreview, which reads
* these on any state that carries them. */
toolName?: string
toolInput?: string
lastAssistantMessage?: string
/** The latest request's verdict: its turn's, or `failure` for a send the agent or its start
* refused. A turn the provider gave none reads as its host-observed end. Present only while
* `status` is idle. */
turnOutcome?: AgentTurnOutcome
statusStartedAt?: number
}
/** One projection shared by host and client: null status means "no turn yet", not idle.
* Every text field is bounded to the same preview an agent-status row carries — a send
* admits 256 KB, and one status frame carries every retained session at once. The
* assistant line is bounded harder than the hook field it stands in for (a preview, not
* the 8 KB body): a streamed reply re-projects on every journal checkpoint, so the frame
* has to stay small even though the row only ever renders one line of it. */
export function projectStructuredAgentSessionStatusSummary(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null
): StructuredAgentSessionStatusProjection {
return projectStructuredAgentSessionStatusState(items, submissions, currentFence).summary
}
/** The summary plus the latest request it was read from, whatever the status, so the host's
* completion feed follows the same request the row reports without scanning again. */
export function projectStructuredAgentSessionStatusState(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null,
scope?: StructuredAgentSessionWorkScope
): {
summary: StructuredAgentSessionStatusProjection
latestRequest: StructuredAgentSessionLatestRequest | null
/** Whether a running turn or an unanswered send is still owed, even beneath a pending prompt. */
owesWork: boolean
/** Item ids of the approvals and questions waiting on the user: what makes the status `attention`. */
pendingPromptIds: string[]
} {
const latestPrompt = latestStructuredAgentSessionPrompt(items)
if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) {
return {
summary: { status: null, latestPrompt: '' },
latestRequest: null,
owesWork: false,
pendingPromptIds: []
}
}
;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
const status = projectStructuredAgentSessionStatus(items, submissions, currentFence, scope)
const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null
const toolName = statusToolCall
? normalizeOptionalField(statusToolCall.name, AGENT_STATUS_TOOL_NAME_MAX_LENGTH)
: undefined
const toolInput = statusToolCall
? normalizeOptionalField(
describeToolInput(statusToolCall.input),
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH
)
: undefined
const lastAssistantMessage = normalizeOptionalField(
latestStructuredAgentSessionAssistantMessage(items),
AGENT_STATUS_MAX_FIELD_LENGTH
)
const latestRequest = latestStructuredAgentSessionRequest(items, submissions)
// A verdict is a fact about a finished request: only an idle session has one to report.
const request = status === 'idle' ? latestRequest : null
const turnOutcome = request
? agentTurnVerdict({ state: request.turnState, outcome: request.outcome })
: null
const statusStartedAt = structuredAgentSessionStatusStartedAt(
status,
items,
submissions,
currentFence,
request
)
return {
latestRequest,
owesWork:
status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence, scope),
pendingPromptIds:
status === 'attention'
? items.filter(pendingStructuredAgentSessionPrompt(scope)).map((item) => item.itemId)
: [],
summary: {
status,
latestPrompt: normalizePromptField(latestPrompt),
...(toolName ? { toolName } : {}),
...(toolInput ? { toolInput } : {}),
...(lastAssistantMessage ? { lastAssistantMessage } : {}),
...(turnOutcome ? { turnOutcome } : {}),
...(statusStartedAt !== undefined ? { statusStartedAt } : {})
}
}
}
export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string {
const bytes = sha256(new TextEncoder().encode(sessionId))
const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('')
const leaf = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
return `${tabId}:${leaf}`
}