mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* fix(native-chat): stop rendering a tool result whose call is outside the window A tool result carries no call id, so it can only be attributed to a tool call loaded alongside it. Both chat views read a windowed transcript tail (mobile 40 messages, desktop 300), and the window regularly opens between an assistant's `tool_use` record and the user-role record that answers it. Claude also re-emits already-answered `tool_result` records at a `/compact` boundary, long after their call scrolled out of the window. `foldToolMessages` had no rule for those: with no assistant predecessor in the output they were pushed through as standalone messages and rendered as a bare, unowned block of raw tool output with no tool name — reading as a message from nowhere mid-conversation. Sampling real Claude transcripts, 176 of 400 sessions (44%) produced one in a mobile-sized first page. Drop a result no loaded call can own, before folding. It is not lost: it comes back attached to its call as soon as the owning turn pages in. * fix(native-chat): scope tool result attribution to folded turns * fix(native-chat): preserve harness-attributed tool results * fix(native-chat): keep interruption boundaries
178 lines
5.2 KiB
TypeScript
178 lines
5.2 KiB
TypeScript
import {
|
|
isToolCallBlock,
|
|
isToolResultBlock,
|
|
type NativeChatBlock,
|
|
type NativeChatMessage,
|
|
type NativeChatToolCallBlock,
|
|
type NativeChatToolResultBlock
|
|
} from './native-chat-types'
|
|
import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
|
|
import { isNoiseMessage } from './native-chat-noise'
|
|
|
|
function isToolOnlyMessage(message: NativeChatMessage): boolean {
|
|
return (
|
|
message.blocks.length > 0 &&
|
|
message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block))
|
|
)
|
|
}
|
|
|
|
function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean {
|
|
if (
|
|
message.role !== 'user' ||
|
|
isInterruptionBoundary(message) ||
|
|
!message.blocks.some(isToolResultBlock)
|
|
) {
|
|
return false
|
|
}
|
|
const textBlocks = message.blocks.filter((block) => block.type === 'text')
|
|
return (
|
|
textBlocks.length > 0 &&
|
|
message.blocks.every(
|
|
(block) =>
|
|
isToolResultBlock(block) ||
|
|
(block.type === 'text' && isKnownHarnessInjectedUserTurnText(block.text))
|
|
)
|
|
)
|
|
}
|
|
|
|
function isInterruptionBoundary(message: NativeChatMessage): boolean {
|
|
return message.blocks.some(
|
|
(block) =>
|
|
block.type === 'text' && block.text.trim().toLowerCase().startsWith('[request interrupted')
|
|
)
|
|
}
|
|
|
|
/** Drop tool results the renderer cannot pair within their folded message. */
|
|
function dropUnattributableToolResults(message: NativeChatMessage): NativeChatMessage | null {
|
|
const blocks: NativeChatBlock[] = []
|
|
let unansweredCalls = 0
|
|
for (const block of message.blocks) {
|
|
if (isToolCallBlock(block)) {
|
|
unansweredCalls += 1
|
|
} else if (isToolResultBlock(block)) {
|
|
if (unansweredCalls === 0) {
|
|
continue
|
|
}
|
|
unansweredCalls -= 1
|
|
}
|
|
blocks.push(block)
|
|
}
|
|
if (blocks.length === message.blocks.length) {
|
|
return message
|
|
}
|
|
return blocks.length > 0 ? { ...message, blocks } : null
|
|
}
|
|
|
|
/** Fold consecutive tool-only messages into their preceding assistant turn. */
|
|
export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
|
|
const output: NativeChatMessage[] = []
|
|
let mutableAssistantIndex = -1
|
|
let clonedAssistantIndex = -1
|
|
for (const message of messages) {
|
|
if (isHarnessSidecarToolMessage(message) && mutableAssistantIndex >= 0) {
|
|
const index = mutableAssistantIndex
|
|
const assistant = output[index]
|
|
if (assistant?.role === 'assistant') {
|
|
if (clonedAssistantIndex !== index) {
|
|
output[index] = { ...assistant, blocks: [...assistant.blocks] }
|
|
clonedAssistantIndex = index
|
|
}
|
|
output[index].blocks.push(...message.blocks.filter(isToolResultBlock))
|
|
output.push({
|
|
...message,
|
|
blocks: message.blocks.filter((block) => !isToolResultBlock(block))
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
if (isToolOnlyMessage(message) && mutableAssistantIndex >= 0) {
|
|
const index = mutableAssistantIndex
|
|
const assistant = output[index]
|
|
if (assistant?.role !== 'assistant') {
|
|
output.push(message)
|
|
mutableAssistantIndex = -1
|
|
continue
|
|
}
|
|
if (clonedAssistantIndex !== index) {
|
|
output[index] = { ...assistant, blocks: [...assistant.blocks] }
|
|
clonedAssistantIndex = index
|
|
}
|
|
output[index]!.blocks.push(...message.blocks)
|
|
continue
|
|
}
|
|
output.push(message)
|
|
if (message.role === 'assistant') {
|
|
mutableAssistantIndex = output.length - 1
|
|
clonedAssistantIndex = -1
|
|
} else if (!isNoiseMessage(message) || isInterruptionBoundary(message)) {
|
|
mutableAssistantIndex = -1
|
|
clonedAssistantIndex = -1
|
|
}
|
|
}
|
|
const attributedOutput: NativeChatMessage[] = []
|
|
for (const message of output) {
|
|
const attributed = dropUnattributableToolResults(message)
|
|
if (attributed) {
|
|
attributedOutput.push(attributed)
|
|
}
|
|
}
|
|
return attributedOutput
|
|
}
|
|
|
|
export type NativeChatToolPair = {
|
|
call?: NativeChatToolCallBlock
|
|
result?: NativeChatToolResultBlock
|
|
}
|
|
|
|
/** Pair calls and results by FIFO ordinal because transcript blocks carry no tool ids. */
|
|
export function pairToolBlocks(
|
|
blocks: readonly NativeChatBlock[],
|
|
limit = Infinity
|
|
): NativeChatToolPair[] {
|
|
const pairs: NativeChatToolPair[] = []
|
|
const callSlots: (number | null)[] = []
|
|
let resultOrdinal = 0
|
|
for (const block of blocks) {
|
|
if (block.type === 'tool-call') {
|
|
if (pairs.length < limit) {
|
|
callSlots.push(pairs.length)
|
|
pairs.push({ call: block })
|
|
} else {
|
|
callSlots.push(null)
|
|
}
|
|
continue
|
|
}
|
|
if (block.type !== 'tool-result') {
|
|
continue
|
|
}
|
|
const slot = callSlots[resultOrdinal]
|
|
if (slot === undefined) {
|
|
if (pairs.length < limit) {
|
|
pairs.push({ result: block })
|
|
}
|
|
} else {
|
|
resultOrdinal += 1
|
|
if (slot !== null) {
|
|
pairs[slot]!.result = block
|
|
}
|
|
}
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): {
|
|
prose: NativeChatBlock[]
|
|
tools: NativeChatBlock[]
|
|
} {
|
|
const prose: NativeChatBlock[] = []
|
|
const tools: NativeChatBlock[] = []
|
|
for (const block of blocks) {
|
|
if (isToolCallBlock(block) || isToolResultBlock(block)) {
|
|
tools.push(block)
|
|
} else {
|
|
prose.push(block)
|
|
}
|
|
}
|
|
return { prose, tools }
|
|
}
|