Files
orca/src/shared/structured-agent-session-live-turn.ts
T
Brennan Benson d9de1fad1d Native chat: desktop and mobile read the host's working answer; plain refusal for a stopped agent's prompt (3/5, rebuild of #26867) (#28104)
* feat(native-chat): record which agent run wrote each journal item

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.

* 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.

* feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly

Desktop and mobile prefer the host's working and actionablePromptIds fields
when present (merged across batches like latestTurn) and fall back to today's
row derivation against an older host. A prompt an ended agent raised stops
looking active; answering it is refused before any provider sees it, with
"<Agent> has stopped, so your answer was not sent. Send a message to continue."
Cancelling it only dismisses it in the journal: it no longer triggers the
chat's Stop, pauses the queue or withdraws queued cards.

* refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.

* test(native-chat): fewer provenance tests: fold reopen and unstated-owner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).

* test(native-chat): drop current-work mirrors: the viewKey unit case and the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.

* test(native-chat): fewer client tests: one flow per client, one fallback each

Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows.

* test(native-chat): an unstamped roster entry from an older build reads as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.

* test(native-chat): build the older-build roster row inline, after main removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.

* refactor(native-chat): provenance is per item only; drop per-entry stamping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.

* fix(native-chat): an item's provenance is set when it is created; revisions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.

* test(native-chat): keep the provenance test below the production lines it covers

* fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt

The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation
receipt and without the dismissal's failure handling, so a receipt write failing after it would
answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which
carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a
null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's
adapter refuses a fence with no live session outright).
2026-10-14 23:41:52 -07:00

319 lines
14 KiB
TypeScript

// What the newest turn in a structured journal is doing right now, read off the
// tail of the item list. Every scan here stops at the turn's own record — the
// typed `turn` item, or the legacy status row that carries one — because state
// from an earlier turn is never this turn's state.
//
// These scans answer for the SESSION'S OWN agent. A subagent's rows share this
// journal and are usually the newer ones while a child runs, so each scan skips
// anything a subagent produced; the transcript still renders every agent.
//
// Each scan reads the turn record BEFORE it checks the producer, which is only
// safe because a turn row can never carry linkage: a turn is the SESSION'S unit
// of work, and no producer of a turn-bearing body stamps one. Both lanes were
// checked — Claude's turn rows are built with no linkage at all, Codex writes
// turn rows only for its primary thread (the one thread it never stamps), the
// compact row passes only a fence, and the stale-turn and dead-generation
// sweeps name no producer, so their turn revisions keep the turn row's own
// (none). So a child-linked row can never be what terminates one of these
// scans. Re-check that before giving any of those sites a producer.
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalRenderItem,
type AgentJournalTurnLifecycle,
type AgentJournalTurnScope
} from './agent-session-journal-types'
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import type { AgentSessionLatestTurn, AgentSessionSubscribeEvent } from './agent-session-wire'
import { readAgentJournalTurn } from './agent-session-turn-record'
import { isAgentSessionContextClear } from './agent-session-context-clear'
import type { NativeChatToolCallBlock } from './native-chat-types'
import {
isRunningStructuredAgentSessionToolAction,
isStructuredAgentSessionToolAction,
structuredAgentSessionToolCallBlock,
type StructuredAgentSessionToolAction
} from './structured-agent-session-tool-call-block'
/** `current`: a host's answer to whether the running turn's item is current work
* (`StructuredAgentSessionWorkScope`); one an ended generation opened is none. */
export function activeStructuredAgentSessionTurnId(
items: readonly AgentJournalRenderItem[],
current?: (item: AgentJournalRenderItem) => boolean
): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
if (isAgentSessionContextClear(item?.body)) {
return null
}
const turn = readAgentJournalTurn(item?.body)
if (turn) {
return turn.state === 'running' && (!current || (item && current(item))) ? turn.turnId : null
}
}
return null
}
/** The newest turn record for items a caller holds unordered, so a reader that already has them
* need not render and sort a whole snapshot to ask. Sequence is the ordering key the render pass
* sorts on, and ties resolve to the later-reduced item exactly as that stable sort would. */
export function newestStructuredAgentSessionTurnBySequence(
items: Iterable<AgentJournalRenderItem>
): AgentJournalTurnLifecycle | null {
let newestSequence = 0
let newest: AgentJournalTurnLifecycle | null = null
for (const item of items) {
if (item.sequence < newestSequence) {
continue
}
if (isAgentSessionContextClear(item.body)) {
newestSequence = item.sequence
newest = null
continue
}
const turn = readAgentJournalTurn(item.body)
if (turn) {
newestSequence = item.sequence
newest = turn
}
}
return newest
}
/** The scope a row written now joins: the running turn, or the conversation when none runs.
* By sequence, for items held unordered. */
export function liveStructuredAgentSessionTurnScope(
items: Iterable<AgentJournalRenderItem>
): AgentJournalTurnScope {
const running = runningStructuredAgentSessionTurnItemBySequence(items)
return running ? { kind: 'turn', turnItemId: running.item.itemId } : AGENT_JOURNAL_THREAD_SCOPE
}
/** The newest turn record's item when that turn is running, by sequence. */
export function runningStructuredAgentSessionTurnItemBySequence(
items: Iterable<AgentJournalRenderItem>
): { item: AgentJournalRenderItem; turnId: string } | null {
let newest: AgentJournalRenderItem | null = null
for (const item of items) {
if (
(newest === null || item.sequence >= newest.sequence) &&
(readAgentJournalTurn(item.body) || isAgentSessionContextClear(item.body))
) {
newest = item
}
}
const turn = newest ? readAgentJournalTurn(newest.body) : null
return newest && turn?.state === 'running' ? { item: newest, turnId: turn.turnId } : null
}
/** The newest turn record whatever state it ended in, STATE INCLUDED. Restart resume compares both
* halves against the teardown marker: the id alone cannot tell a turn that was interrupted from
* one that finished, and offering a finished chat is the failure this feature exists to avoid.
* The running-only readers above would answer null for exactly the sessions this has to identify,
* because eviction settles them to `interrupted`.
*
* Scans backwards rather than by sequence because every caller passes a rendered snapshot, which
* is already in that order. Use the by-sequence reader above for items held unordered. */
export function newestStructuredAgentSessionTurn(
items: readonly AgentJournalRenderItem[]
): AgentJournalTurnLifecycle | null {
return latestStructuredAgentSessionTurn(items)?.turn ?? null
}
/** The same record as a page publishes it, with the identity a client keys the turn by. */
export function latestStructuredAgentSessionTurn(
items: readonly AgentJournalRenderItem[]
): AgentSessionLatestTurn | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
if (isAgentSessionContextClear(item?.body)) {
return null
}
const turn = readAgentJournalTurn(item?.body)
if (item && turn) {
return { itemId: item.itemId, observedAt: item.observedAt, turn }
}
}
return null
}
/** The turn the session's own agent is running, for a client: the host's answer over the whole
* journal, which no loaded window can hide. Temporary: an older host sends none, so its clients
* still read the loaded rows' newest record; delete that arm once such hosts age out. */
export function runningStructuredAgentSessionTurnId(state: HostTurnSource): string | null {
if (state.latestTurn === undefined) {
return activeStructuredAgentSessionTurnId(state.items)
}
return state.latestTurn?.turn.state === 'running' ? state.latestTurn.turn.turnId : null
}
/** The scope a row of that running turn names, read the same way. */
export function runningStructuredAgentSessionTurnScope(
state: HostTurnSource
): AgentJournalTurnScope {
if (state.latestTurn === undefined) {
return liveStructuredAgentSessionTurnScope(state.items)
}
return state.latestTurn?.turn.state === 'running'
? { kind: 'turn', turnItemId: state.latestTurn.itemId }
: AGENT_JOURNAL_THREAD_SCOPE
}
type HostTurnSource = {
items: readonly AgentJournalRenderItem[]
latestTurn?: AgentSessionLatestTurn | null
}
/** The host's answer once `event` applies. A batch carrying rows restates it, so one without it
* came from an older host and falls back to the rows rather than keep a claim nothing renews. */
export function latestTurnAfterStructuredAgentSessionBatch(
previous: AgentSessionLatestTurn | null | undefined,
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
): AgentSessionLatestTurn | null | undefined {
const { items, removedItemIds, submissions } = event.batch
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
return carriesRows || event.latestTurn !== undefined ? event.latestTurn : previous
}
/** The host's actionable prompts once `event` applies, under `latestTurn`'s rule: a batch carrying
* rows restates them, so one without them came from an older host and drops the claim. */
export function actionablePromptIdsAfterStructuredAgentSessionBatch(
previous: string[] | undefined,
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
): string[] | undefined {
const { items, removedItemIds, submissions } = event.batch
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
return carriesRows || event.actionablePromptIds !== undefined
? event.actionablePromptIds
: previous
}
/** The host's working answer once `event` applies, under `latestTurn`'s rule. */
export function hostWorkingAfterStructuredAgentSessionBatch(
previous: boolean | undefined,
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
): boolean | undefined {
const { items, removedItemIds, submissions } = event.batch
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
return carriesRows || event.working !== undefined ? event.working : previous
}
/** Whether a frame's turn, prompts and working answer, when it names them, are what the state
* already holds: a generation's end can change them with no row, so a frame that does is never a
* no-op. */
export function restatesStructuredAgentSessionHostWork(
event: Pick<
Extract<AgentSessionSubscribeEvent, { type: 'batch' }>,
'latestTurn' | 'actionablePromptIds' | 'working'
>,
state: {
latestTurn?: AgentSessionLatestTurn | null
actionablePromptIds?: string[]
working?: boolean
}
): boolean {
return (
(event.latestTurn === undefined ||
JSON.stringify(event.latestTurn) === JSON.stringify(state.latestTurn)) &&
(event.actionablePromptIds === undefined ||
event.actionablePromptIds.join('\n') === state.actionablePromptIds?.join('\n')) &&
(event.working === undefined || event.working === state.working)
)
}
/** Whether the session's own agent is working: the host's answer when it gave one, else what the
* client derives from its rows (an older host). */
export function structuredAgentSessionHostSaysWorking(
hostWorking: boolean | undefined,
derived: () => boolean
): boolean {
return hostWorking ?? derived()
}
/** Whether a pending prompt waits on the person: the host names the ones that do, and a prompt an
* agent that has ended raised is not among them. An older host names none: every pending one. */
export function isActionableStructuredAgentSessionPrompt(
itemId: string,
actionablePromptIds: readonly string[] | undefined
): boolean {
return actionablePromptIds === undefined || actionablePromptIds.includes(itemId)
}
/**
* Whether the newest thing the active turn produced is the model's own reasoning.
*
* This is what "thinking" has to mean for the indicator to be honest: the turn is reasoning
* *right now*. The older rule — "the turn has produced no renderable output yet" — reports
* thinking while the request is merely in flight, and stops reporting it the moment a tool call
* lands, which is usually when reasoning actually starts.
*/
export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurnSource): boolean {
// The host's answer outranks a loaded record, whose newest revision may be off the window.
const hostRunning = latestTurn === undefined ? null : latestTurn?.turn.state === 'running'
let newestContentIsReasoning: boolean | null = null
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
const body = item?.body
if (isAgentSessionContextClear(body)) {
return false
}
const turn = readAgentJournalTurn(body)
if (turn) {
return (hostRunning ?? turn.state === 'running') && newestContentIsReasoning === true
}
if (newestContentIsReasoning !== null || !isRootAgentJournalItem(item)) {
continue
}
if (body?.kind === 'message') {
// A row that says it ended is not reasoning now; a host that keeps no state says nothing.
newestContentIsReasoning =
body.role === 'reasoning' && (body.state === undefined || body.state === 'running')
} else if (
body?.kind === 'tool-call' ||
body?.kind === 'diff' ||
body?.kind === 'approval' ||
body?.kind === 'question'
) {
newestContentIsReasoning = false
}
// A status row is a notice, not newer transcript content.
}
// The record is above the loaded rows, so every loaded root row is newer than it.
return hostRunning === true && newestContentIsReasoning === true
}
/** The tool the status row names for the SESSION'S OWN agent, as the chat draws it: the running
* turn's newest running call, else its newest tool action whatever it settled to, so the line
* never blanks mid-turn. Nothing is named unless the scan reaches a RUNNING turn record, so an
* ended turn's calls never surface; a mid-turn send's user row is not a boundary. */
export function statusStructuredAgentSessionToolCall(
items: readonly AgentJournalRenderItem[]
): NativeChatToolCallBlock | null {
let newest: { action: StructuredAgentSessionToolAction; itemId: string } | null = null
let running: { action: StructuredAgentSessionToolAction; itemId: string } | null = null
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
if (!item) {
continue
}
const body = item.body
if (isAgentSessionContextClear(body)) {
return null
}
const turn = readAgentJournalTurn(body)
if (turn) {
const named = turn.state === 'running' ? (running ?? newest) : null
// Built only for the winner: the host re-projects this on every journal change.
return named ? structuredAgentSessionToolCallBlock(named.action, named.itemId) : null
}
if (running || !isStructuredAgentSessionToolAction(body) || !isRootAgentJournalItem(item)) {
continue
}
newest ??= { action: body, itemId: item.itemId }
if (isRunningStructuredAgentSessionToolAction(body)) {
running = { action: body, itemId: item.itemId }
}
}
return null
}