mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* fix(native-chat): fold thoughts and tool calls between replies into one row A model that thinks before every tool call drew a 'Thought for Ns' row between each call, and each thought also stopped the calls from merging, so a turn read as dozens of alternating rows. The transcript now draws an unbroken stretch of tool calls and thoughts within a turn as one tool run; thoughts read in order inside it when opened and are not counted in its label. * fix(native-chat): keep work runs whole while a turn edits files A live turn that had edited a file kept splitting its newest call or thought off the run (the row carrying the turn's diff rollup could not join), so a lone "Thought for Ns" row came back at the bottom. - Run boundaries are now one pure pass in src/shared (nativeChatWorkRunSpans), applied over the built transcript slots. Section heads end runs by sitting between rows, and rows inside an expanded subagent section are grouped the same way. - The row carrying the turn's diff rollup joins as the run's last member; the run takes the rollup from it and the bar from its head. - A collapsed run reserves one tool row plus a lead's words, not every thought's text. - Lone rows and runs draw through the same MessageRow, with one flat keyed line list, so a row becoming a run no longer remounts (a revealed diff card no longer re-fires its scroll). - A run opens by default while the reader holds one of its thoughts open; the run's own choice still wins. - A drawn thought with no visible text joins a run instead of splitting it. * fix(native-chat): pair each row's result with its own call; keep open thoughts out of runs - A structured journal row's call and result now share one id (the provider's call id, else the row's own id), so a diff or output can no longer pair with an earlier unanswered call in the same work run or folded message. A quiet command followed by edits now shows each diff card under its own call, and the rollup's reveal lands on it. - A thought still being thought, while its turn or subagent works, keeps its own row after the run and joins once it ends. - Removed the rule that opened a run because a thought inside it was open; a thought keeps its own open state inside its run. * fix(native-chat): an open thought the live line lets go of joins its run Only a subagent section keeps a still-running thought out of its run. At the top level the live line owns the open thought; when it lets go (a Stop in flight, a prompt waiting on the reader) the thought now joins the run at once instead of flashing as its own row until the turn ends. * test(native-chat): guard the run row memo; name the slot post-pass test after it Settled work runs must not rebuild their lines and thoughts on every stream frame of the live run. Renames the slot-level run test so it no longer shares a name with the shared span test.
231 lines
10 KiB
TypeScript
231 lines
10 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 type { NativeChatToolCallBlock } from './native-chat-types'
|
|
import {
|
|
isRunningStructuredAgentSessionToolAction,
|
|
isStructuredAgentSessionToolAction,
|
|
structuredAgentSessionToolCallBlock,
|
|
type StructuredAgentSessionToolAction
|
|
} from './structured-agent-session-tool-call-block'
|
|
|
|
export function activeStructuredAgentSessionTurnId(
|
|
items: readonly AgentJournalRenderItem[]
|
|
): string | null {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const turn = readAgentJournalTurn(items[index]?.body)
|
|
if (turn) {
|
|
return turn.state === 'running' ? 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
|
|
}
|
|
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 {
|
|
let newest: AgentJournalRenderItem | null = null
|
|
for (const item of items) {
|
|
if ((newest === null || item.sequence >= newest.sequence) && readAgentJournalTurn(item.body)) {
|
|
newest = item
|
|
}
|
|
}
|
|
return newest && readAgentJournalTurn(newest.body)?.state === 'running'
|
|
? { kind: 'turn', turnItemId: newest.itemId }
|
|
: AGENT_JOURNAL_THREAD_SCOPE
|
|
}
|
|
|
|
/** Whether that newest turn is still running, which is all most callers want. */
|
|
export function activeStructuredAgentSessionTurnIdBySequence(
|
|
items: Iterable<AgentJournalRenderItem>
|
|
): string | null {
|
|
const newest = newestStructuredAgentSessionTurnBySequence(items)
|
|
return newest?.state === 'running' ? newest.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]
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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
|
|
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
|
|
}
|