Files
orca/src/shared/native-chat-ask-row.ts
T
Brennan Benson 4da485cac1 fix(native-chat): render the terminal-backed chat through the structured chat's turn status (#22090)
* fix(native-chat): render the terminal-backed chat through the structured chat's turn status

A terminal-backed chat (grok and omp always; Claude and Codex whenever the
structured lane is refused) could not say what its agent was doing. A
working agent drew three bouncing dots with no elapsed time, and an agent
stopped on a prompt that only its terminal showed drew nothing, so it looked
idle. A pending AskUserQuestion row read "Asked:" while the agent was still
waiting, because the pane treated the wait as the end of the turn.

The pane already held the facts: the host-stamped working epoch and the
hook's waiting/blocked state. It now feeds them to the same message list
props and components the structured lane uses, instead of opting out with
showTurnStatus={false}:

- The turn runs on while the agent waits on the reader, as a structured turn
  does behind its prompt card, so the "Working for" bar keeps counting and
  the live line yields to the wait.
- A wait the pane can draw as a card (approval or question) is shown by the
  card; a wait only the terminal shows is reported by the tail row the
  structured lane already uses for a pending question
  (NativeChatAwaitingInputRow).
- The hook wait is reconciled against the transcript's terminal marker, like
  hook 'working' is, because an interrupt at the prompt fires no hook.

showTurnStatus meant nothing once both lanes passed it, so it is gone, along
with showLiveTurnActivity (replaced by the awaitingInput fact), the
structuredActivityUi flag it fed to tool rows, and the transcript-guessing
typing indicator (native-chat-typing-indicator.ts,
NativeChatTypingIndicatorRow.tsx), which had no other consumer.

The interactive card's derivation moved into
useNativeChatInteractivePromptCard so the view can tell a wait the card
answers from one it cannot.

* test(native-chat): seed the hook wait through the store instead of a cast

* fix(native-chat): say "Awaiting user input" as a whole phrase when no question is named

A wait the pane cannot draw as a card, and a question whose text could not be
parsed, both drew "Awaiting user input:" with nothing after the colon. The row
now reads "Awaiting user input" when it has no question to name, in both chat
lanes. The pending-question view test now counts awaiting rows by attribute so
a second, unnamed row cannot slip past it.

* perf(native-chat): read the prompt's tool name only while a prompt is pending

Deriving the prompt card moved from the card into the pane's view, and with it
a store read of the status row's tool name, which changes on every tool call.
Every tool call therefore re-rendered the whole terminal-backed pane and its
transcript list, where before only the card re-rendered. The tool name only
matters beside a prompt, so read it only then.

* fix(native-chat): keep the terminal lane's turn clock and folds across a remount

Answering a prompt only the terminal shows means switching the pane to its
terminal, which unmounts the chat. On return the "Working for" clock restarted
from the agent's current state (seconds after the answer), and every turn the
pane had folded behind "Worked for N" came back unfolded with no bar.

- The running turn now counts from the start of the hook's unbroken run of
  mid-turn states under the current prompt (read from the status row's state
  history), not the current state's own start.
- Finished turns take their duration from the transcript's own timestamps
  (prompt to the agent's last row or its interruption), so history turns fold
  like a structured chat's. The latest turn still relies on what the pane saw.

* fix(native-chat): date the terminal lane's turns by the main agent and skip harness notices

Two ways the terminal-backed chat showed a wrong turn status:

- A background task or subagent keeps the status row 'working' after the
  main agent finishes, so the row's own start time carries into the next
  turn. A prompt sent while a background dev server ran showed "Working for"
  counted from the earlier turn. The clock now reads the main agent's own
  state and start time where the row carries them.
- Harness notices (task notifications, reminders) are user-role rows in the
  transcript that the chat does not draw. The finished-turn derivation
  treated them as new prompts, so a running turn was reported settled and
  folded its steps while it still ran, and a finished turn's "Worked for"
  stopped at the notice. They no longer start or end a turn.

* feat(agent-status): stamp each turn's start on the host

The hook server now records turnStartedAt on a status row when the main
agent's own turn-opening event arrives (the per-provider new-turn
classifier the observation boundary already uses; not a replay, a child
event or an identity-only row). Other events carry it; a session
boundary clears it, and a settled main agent running again with no
opening event drops it rather than count from the finished turn.

It rides the snapshot and the live push as an optional field, persists
with the row, lands on the renderer entry (kept within one state for
writers with no turn clock), and is part of the paired-client
projection key and equality. Old hosts send nothing; readers fall back
to stateStartedAt.

* fix(native-chat): time the terminal lane's turns from the host's turn stamp

The running clock reads the host's turnStartedAt, so a remount, a reload
(which starts with no state history) and child work holding the row open
no longer move it, and the same prompt sent again is a new turn. The
stateHistory walk and its prompt-equality rule are gone; an old host
falls back to the current state's start.

The latest turn is settled from host facts: once the main agent is done
(outside a session boundary) its duration is the done stamp minus the
turn start, so it folds after a remount. When the host went quiet
mid-turn (the store's staleness dropped the row) the pane passes null,
so a still-waiting turn no longer reads "Worked for 30m" and folds.
Older turns keep their transcript durations.

* test(agent-status): pin the host's turn start on the live status push

* fix(native-chat): keep omp's turn working while its latest row is a tool call

The terminal-backed chat settles a hook 'working' turn when the transcript's
last row is an assistant row written after the turn began, a recovery for a
Stop hook the host can miss. omp writes a timestamp on every row and has no
transcript turn markers, so every tool call mid-turn tripped it: the pane
dropped its "Working for" bar and showed Send instead of Stop while the
command ran.

omp's own runtime delivers its turn end (agent_end, retried until the host
takes it and held back while the run continues), so its hook 'working' is
never a dropped Stop. The prose recovery now skips it.

* test(native-chat): read the clock once in the host-ended remount test

The test stamped the row's done time and the turn start from two separate
Date.now() reads, so whenever a millisecond passed between them the expected
"Worked for 1m 30s" came out as 1m 29s. It failed that way once under load.

* test(sync-runtime-graph): carry the turn stamp in the hot-path projection reference

The hot-path suite compares the agent-status projection against its own copy of
the serializer; that copy lacked the new turnStartedAt key.
2026-09-29 01:30:54 -07:00

102 lines
3.7 KiB
TypeScript

// The native-chat row that stands in for a question tool call. Both platform
// UIs read this copy (desktop as its i18n fallbacks, mobile directly) so the two
// can never describe the same pending question differently.
import { isAskUserQuestionTool } from './agent-question-answered-intent'
import { parseAskFromToolInput } from './native-chat-ask'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
import { pairToolBlocks } from './native-chat-tool-fold'
export const NATIVE_CHAT_ASK_ROW_COPY = {
awaiting: 'Awaiting user input:',
awaitingUnnamed: 'Awaiting user input',
asked: 'Asked:',
questionCount: '{{value0}} questions'
} as const
/** What was asked: the one question, or every question of a grouped prompt. One
* row stands for the whole prompt, so a grouped prompt names its count on the
* line rather than quoting its first question as though it were the only one. */
export type NativeChatAskRowSubject =
| { kind: 'question'; text: string }
| { kind: 'questions'; questions: string[] }
/** Whether this block is a question tool call, and so is drawn as the awaiting
* row rather than as an ordinary tool line. */
export function isNativeChatAskCall(block: NativeChatBlock): boolean {
return isToolCallBlock(block) && block.state !== 'failed' && isAskUserQuestionTool(block.name)
}
/** Remove each summarized call together with its FIFO result, preserving failed calls. */
export function nativeChatAskRunBlocks(blocks: NativeChatBlock[]): {
asks: NativeChatBlock[]
unansweredAsks: NativeChatBlock[]
work: NativeChatBlock[]
} {
if (!blocks.some(isNativeChatAskCall)) {
return { asks: [], unansweredAsks: [], work: blocks }
}
const removed = new Set<NativeChatBlock>()
const asks: NativeChatBlock[] = []
const unansweredAsks: NativeChatBlock[] = []
for (const { call, result } of pairToolBlocks(blocks)) {
if (!call || !isNativeChatAskCall(call) || result?.isError) {
continue
}
asks.push(call)
if (!result) {
unansweredAsks.push(call)
}
removed.add(call)
if (result) {
removed.add(result)
}
}
return {
asks,
unansweredAsks,
work: removed.size ? blocks.filter((block) => !removed.has(block)) : blocks
}
}
/** Whether this run asks the reader anything. Decided by the tool name alone,
* because that already says the agent is blocked on an answer — a payload this
* cannot parse must not put the raw call back on screen as the row it replaced. */
export function hasNativeChatAskCall(blocks: readonly NativeChatBlock[]): boolean {
return blocks.some(isNativeChatAskCall)
}
/** The questions one call names, dropping any it states blankly. */
function askCallQuestions(block: NativeChatBlock): string[] {
if (!isToolCallBlock(block)) {
return []
}
const prompt = parseAskFromToolInput(block.name, block.input)
return prompt
? prompt.questions.map((question) => question.question.trim()).filter((text) => text.length > 0)
: []
}
/**
* The subject for the whole run's question activity, or null when nothing in it
* names a question — the row then stands on its label alone, which still tells
* the reader the turn is theirs to unblock.
*
* Aggregated across calls, not taken from one: Codex journals a separate call
* per question of the same prompt, so a per-call row would stack three pulsing
* lines for what the reader was asked once.
*/
export function nativeChatAskRunSubject(
blocks: readonly NativeChatBlock[]
): NativeChatAskRowSubject | null {
const questions = blocks.filter(isNativeChatAskCall).flatMap(askCallQuestions)
if (questions.length === 0) {
return null
}
if (questions.length > 1) {
return { kind: 'questions', questions }
}
const text = questions[0]
return text ? { kind: 'question', text } : null
}