Files
orca/src/shared/structured-agent-session-projection.ts
T
Brennan BensonandMerge Sim 2513e21390 fix(native-chat): publish structured session status from the host so the sidebar never goes stale (#18776)
* fix(native-chat): publish structured session status from the host

The sidebar learned whether a structured chat was mid-turn by replaying
the session journal in the renderer, through a reader whose lifetime was
tied to the chat pane. Hiding the pane stopped the reader before the
turn's settlement arrived, so the row stayed on "working" until the chat
was reopened. The same coupling meant a tab never opened this session
showed no status at all, and a reloaded renderer lost every settled row.

The host owns the journal, so it now projects each session's status once
per journal publication and fans the changes out on one stream per client
(`agentSession.subscribeStatus`). The projection survives eviction of an
idle session's provider child and is republished when readable sessions
are restored. The renderer bridge subscribes to that feed per runtime
target and never opens a transcript reader; the observation hook is gone.

Additive wire surface behind the existing structured capability; old
hosts reject the method and the renderer retries, showing no status.

* fix(native-chat): negotiate the status feed and stop losing a change on subscribe

The status stream is additive to a surface that already shipped, so a host
advertising agent-session.structured.v1 can still answer subscribeStatus with
method_not_found. Every renderer error path reconnected, so a remote host one
release behind got a relay round-trip every 5s and no sidebar status at all.
Give the method its own capability and probe it before subscribing; a failed
probe still retries, an absent capability does not.

Re-projecting on subscribe also wrote straight into the shared cache, so a
second client could pin the first to a stale summary. Route those diffs
through publish() before the arriving subscriber is registered.

* fix(native-chat): bound the status prompt, merge snapshots, and prove the unread path

One status frame carries every retained session and a send admits 256 KB per
prompt, so ~16 large-prompt sessions could push the snapshot past the 4 MB
outbound guard and into the retry loop. Bound latestPrompt to the same
200-char single-line preview every other agent-status row already carries.

A snapshot also replaced the cached map wholesale, so the empty first frame
from a restarting host retracted every row before restore republished them.
Merge instead; the tab map, not this feed, decides which sessions are listed.

Tests: the hidden-pane claim now sits at the host, where a journal with no
transcript subscriber is driven from running to idle; the RPC test reads a
real projection instead of its own stub.

* fix(native-chat): merge the duplicated status-event type import

* test(native-chat): pin the restart status publication, and log the unsupported host

Startup restore indexes a readable session and publishes its status, which is
what puts a never-reopened tab back in the sidebar. Only an Electron screenshot
covered that wiring; a sitting status subscriber now pins it directly.

The terminal "host too old" branch was silent, so a mixed-version report showed
an empty sidebar with nothing in the log to explain it.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 15:35:03 -07:00

200 lines
6.4 KiB
TypeScript

import { normalizePromptField } from './agent-status-field-normalization'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import { sha256 } from './sha256'
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 (body.kind === 'tool-call') {
return {
role: 'assistant',
blocks: [
{ type: 'tool-call', name: body.name, input: body.input, state: body.state },
...(body.output
? [
{
type: 'tool-result' as const,
output: boundedText(body.output),
isError: body.state === 'failed'
}
]
: [])
]
}
}
if (body.kind === 'diff') {
return {
role: 'assistant',
blocks: [
{ type: 'tool-call', name: 'Diff', input: { path: body.path } },
{ type: 'tool-result', output: boundedText(body.patch) }
]
}
}
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() }]
}
}
if (body.turnLifecycle) {
return null
}
return {
role: 'system',
blocks: [
{
type: 'text',
text: body.text,
...(body.providerFrame ? { providerFrame: body.providerFrame } : {})
}
]
}
}
export function projectStructuredItemsToNativeChat(
items: readonly AgentJournalRenderItem[]
): NativeChatMessage[] {
return items.flatMap((item) => {
const projected = itemBlocks(item)
return projected
? [
{
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
}
]
: []
})
}
export function projectStructuredItemToNativeChat(
item: AgentJournalRenderItem
): NativeChatMessage | null {
return projectStructuredItemsToNativeChat([item])[0] ?? null
}
export function activeStructuredAgentSessionTurnId(
items: readonly AgentJournalRenderItem[]
): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const body = items[index]?.body
if (body?.kind === 'status' && body.turnLifecycle) {
return body.turnLifecycle.state === 'running' ? body.turnLifecycle.turnId : null
}
}
return null
}
export function hasPersistedStructuredAgentSessionTurn(
items: readonly AgentJournalRenderItem[]
): boolean {
return items.some(
(item) =>
item.body.kind === 'message' && (item.body.role === 'user' || item.body.role === 'assistant')
)
}
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
export function structuredAgentSessionTabId(sessionId: string): string {
return `structured-agent-session-${sessionId}`
}
export function projectStructuredAgentSessionStatus(
items: readonly AgentJournalRenderItem[]
): StructuredAgentSessionProjectedStatus {
if (
items.some(
(item) =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending'
)
) {
return 'attention'
}
return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle'
}
/** The newest user prompt, as the sidebar quotes it. */
export function latestStructuredAgentSessionPrompt(
items: readonly AgentJournalRenderItem[]
): string {
for (let index = items.length - 1; index >= 0; index -= 1) {
const body = items[index]?.body
if (body?.kind === 'message' && body.role === 'user') {
return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
}
}
return ''
}
/** One projection shared by host and client: null status means "no turn yet", not idle.
* The prompt is bounded to the same preview every other agent-status row carries — a send
* admits 256 KB, and one status frame carries every retained session at once. */
export function projectStructuredAgentSessionStatusSummary(
items: readonly AgentJournalRenderItem[]
): { status: StructuredAgentSessionProjectedStatus | null; latestPrompt: string } {
if (!hasPersistedStructuredAgentSessionTurn(items)) {
return { status: null, latestPrompt: '' }
}
return {
status: projectStructuredAgentSessionStatus(items),
latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items))
}
}
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}`
}