mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +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.
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
// An unbroken stretch of tool calls and thoughts between what the agent says reads as one
|
|
// collapsible row. Thoughts sit inside it rather than splitting it, so a model that thinks
|
|
// before every call still shows one "Ran 5 commands" row, not ten alternating rows.
|
|
|
|
import { isNativeChatAskCall } from './native-chat-ask-row'
|
|
import { deriveNativeChatRowContent } from './native-chat-row-content'
|
|
import { isStoppedBeforeStartBlock } from './native-chat-stopped-before-start'
|
|
import { pairToolBlocks } from './native-chat-tool-fold'
|
|
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
|
|
|
|
/** `lead`: the agent's words with calls already folded under them, which may head a run but
|
|
* never join one. */
|
|
export type NativeChatWorkRunMember = 'lead' | 'tool' | 'thought'
|
|
|
|
/** What a drawn row contributes to a work run, or null when it ends one. Rows that carry
|
|
* anything besides words and plain tool activity (a roster, a background task, a question,
|
|
* an approval's receipt) keep their own row. `scopeIsWorking`: the row's turn or subagent is
|
|
* still running. */
|
|
export function nativeChatWorkRunMember(
|
|
message: NativeChatMessage,
|
|
hasReceipt: boolean,
|
|
scopeIsWorking: boolean
|
|
): NativeChatWorkRunMember | null {
|
|
if (hasReceipt) {
|
|
return null
|
|
}
|
|
if (message.role === 'reasoning') {
|
|
// A thought still being thought stays in view on its own row; it joins once it ends. Any
|
|
// other drawn thought joins, and one with no visible text draws nothing inside the run.
|
|
return scopeIsWorking && message.state === 'running' ? null : 'thought'
|
|
}
|
|
const content = deriveNativeChatRowContent(message.blocks)
|
|
if (
|
|
message.role !== 'assistant' ||
|
|
content.tools.length === 0 ||
|
|
content.subagentGroups.length > 0 ||
|
|
content.backgroundTasks.length > 0 ||
|
|
message.blocks.some(
|
|
(block) => isStoppedBeforeStartBlock(block) || (block.type === 'text' && block.providerFrame)
|
|
) ||
|
|
content.tools.some(isNativeChatAskCall)
|
|
) {
|
|
return null
|
|
}
|
|
return content.markdown.length > 0 || content.hasImages ? 'lead' : 'tool'
|
|
}
|
|
|
|
/** One row of a transcript as the run boundaries read it. */
|
|
export type NativeChatWorkRunRow = {
|
|
/** What the row adds to a run; null ends one. */
|
|
member: NativeChatWorkRunMember | null
|
|
/** False for a row that draws nothing: it neither joins nor ends a run. */
|
|
draws: boolean
|
|
/** Runs never cross a scope (a turn, a subagent's section), drawn or not. */
|
|
scope: string | undefined
|
|
}
|
|
|
|
/** Each work run's member indexes, ascending. A run has at least two members and at least one
|
|
* call; a lead may head one but never join one. Derived per call, so nothing is stored. */
|
|
export function nativeChatWorkRunSpans(rows: readonly NativeChatWorkRunRow[]): number[][] {
|
|
const spans: number[][] = []
|
|
let open: number[] = []
|
|
let hasCall = false
|
|
let scope: string | undefined
|
|
const close = (): void => {
|
|
if (open.length > 1 && hasCall) {
|
|
spans.push(open)
|
|
}
|
|
open = []
|
|
hasCall = false
|
|
}
|
|
for (const [index, row] of rows.entries()) {
|
|
if (open.length > 0 && row.scope !== scope) {
|
|
close()
|
|
}
|
|
if (!row.draws) {
|
|
continue
|
|
}
|
|
if (row.member === null || row.member === 'lead') {
|
|
close()
|
|
}
|
|
if (row.member === null) {
|
|
continue
|
|
}
|
|
if (open.length === 0) {
|
|
scope = row.scope
|
|
}
|
|
open.push(index)
|
|
hasCall ||= row.member !== 'thought'
|
|
}
|
|
close()
|
|
return spans
|
|
}
|
|
|
|
export type NativeChatWorkRunEntries = {
|
|
/** Every member's tool blocks, in order: the run's one block list. */
|
|
blocks: NativeChatBlock[]
|
|
/** Thoughts drawn just before a block, keyed by that block. */
|
|
thoughtsBefore: Map<NativeChatBlock, NativeChatMessage[]>
|
|
/** Thoughts after the run's last block. */
|
|
thoughtsAfter: NativeChatMessage[]
|
|
}
|
|
|
|
export function nativeChatWorkRunEntries(
|
|
members: readonly NativeChatMessage[]
|
|
): NativeChatWorkRunEntries {
|
|
const blocks: NativeChatBlock[] = []
|
|
const thoughtsBefore = new Map<NativeChatBlock, NativeChatMessage[]>()
|
|
let waiting: NativeChatMessage[] = []
|
|
for (const message of members) {
|
|
if (message.role === 'reasoning') {
|
|
waiting.push(message)
|
|
continue
|
|
}
|
|
const { tools } = deriveNativeChatRowContent(message.blocks)
|
|
if (tools.length === 0) {
|
|
continue
|
|
}
|
|
if (waiting.length > 0) {
|
|
thoughtsBefore.set(tools[0]!, waiting)
|
|
waiting = []
|
|
}
|
|
blocks.push(...tools)
|
|
}
|
|
return { blocks, thoughtsBefore, thoughtsAfter: waiting }
|
|
}
|
|
|
|
/** A diff target names an edit by its index within one message; the run counts across
|
|
* all of them. Re-keys the target's edit to the run's own numbering. */
|
|
export function nativeChatWorkRunEditKey(
|
|
members: readonly NativeChatMessage[],
|
|
runBlocks: readonly NativeChatBlock[],
|
|
target: { messageId: string; editKey: string }
|
|
): string | null {
|
|
const member = members.find((message) => message.id === target.messageId)
|
|
if (!member) {
|
|
return null
|
|
}
|
|
// Numbered over the raw blocks, as the turn's diff rollup numbers them.
|
|
const call = pairToolBlocks(member.blocks).find(
|
|
({ call }, index) => call !== undefined && `${call.name}:${index}` === target.editKey
|
|
)?.call
|
|
if (!call) {
|
|
return null
|
|
}
|
|
const runIndex = pairToolBlocks(runBlocks).findIndex((pair) => pair.call === call)
|
|
return runIndex === -1 ? null : `${call.name}:${runIndex}`
|
|
}
|