diff --git a/src/messages.ts b/src/messages.ts index 180d801..34ae637 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -14,6 +14,7 @@ export interface OctoPart { input?: unknown output?: unknown error?: string + metadata?: Record title?: string } [key: string]: unknown @@ -43,6 +44,15 @@ function safeStringify(value: unknown): string { } } +function toolResultBody(state: OctoPart["state"]): string | undefined { + if (state?.status === "completed") { + return typeof state.output === "string" ? state.output : safeStringify(state.output) + } + if (state?.status !== "error") return undefined + const interruptedOutput = state.metadata?.interrupted === true ? state.metadata.output : undefined + return typeof interruptedOutput === "string" ? interruptedOutput : `Error: ${state.error ?? ""}` +} + export interface ConversionResult { cores: CoreMessage[] partIdToCoreIds: Map @@ -82,19 +92,13 @@ export function octoToCoreMessages(msgs: OctoMessage[]): ConversionResult { const ids = [callId] if (part.state?.status === "completed" || part.state?.status === "error") { const resultId = `${msg.info.id}#x${partIdx}` - const outText = - part.state.status === "completed" - ? typeof part.state.output === "string" - ? part.state.output - : safeStringify(part.state.output) - : `Error: ${part.state.error ?? ""}` cores.push({ id: resultId, role: "tool", contentType: "tool-result", toolName, toolCallId, - text: outText, + text: toolResultBody(part.state) ?? "", }) ids.push(resultId) } @@ -176,7 +180,10 @@ export function reassemble( const callAlive = outCoreById.has(ids[0]!) const resultAlive = outCoreById.has(ids[1]!) if (!(callAlive && resultAlive)) continue - parts.push(p) + // Honor kernel body mutations (emergency truncation of large + // tool-results): if the result core's text differs from the original + // output, replace it — otherwise truncation never reaches the model. + parts.push(applyToolBody(p, outCoreById.get(ids[1]!))) continue } const survived = ids.some((id) => outCoreById.has(id)) @@ -194,6 +201,30 @@ export function reassemble( return result } +function trimEnd(s: string): string { + return s.replace(/\s+$/, "") +} + +export function applyToolBody(part: OctoPart, resultCore: CoreMessage | undefined): OctoPart { + const coreBody = resultCore?.text ?? "" + if (!coreBody) return part + // Compare the original output against the result core's text — kernel + // truncation rewrites that text, and dropping the rewrite would leave the + // full output in the request. + const state = part.state + const originalText = toolResultBody(state) + if (!state || originalText === undefined) return part + if (trimEnd(coreBody) === trimEnd(originalText)) return part + if (state.status === "completed") { + return { ...part, state: { ...state, output: coreBody } } + } + if (state.metadata?.interrupted === true && typeof state.metadata.output === "string") { + return { ...part, state: { ...state, metadata: { ...state.metadata, output: coreBody } } } + } + const error = coreBody.startsWith("Error: ") ? coreBody.slice("Error: ".length) : coreBody + return { ...part, state: { ...state, error } } +} + export function makeNudgeMessage( id: string, sessionID: string, diff --git a/tests/messages.test.ts b/tests/messages.test.ts index f7fd2f8..43f6821 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -110,6 +110,57 @@ test("compress + reassembly replaces covered messages with synthetic user summar assert.ok(hasU2, "recent uncompressed message preserved") }) +test("reassemble: kernel-truncated tool body replaces part output", () => { + const msgs = [toolMsg("a2", "s1", "bash", "call_1", "ORIGINAL_LONG_OUTPUT")] + const { cores, partIdToCoreIds } = octoToCoreMessages(msgs) + const truncated = cores.map((c) => (c.id === "a2#x0" ? { ...c, text: "TRUNCATED_BODY" } : c)) + const out = reassemble(truncated, msgs, partIdToCoreIds, "s1") + assert.equal(out.length, 1) + assert.equal(out[0]!.parts[0]!.state!.output, "TRUNCATED_BODY", "truncated body applied to part output") +}) + +test("reassemble: trailing whitespace difference is not treated as a rewrite", () => { + const msgs = [toolMsg("a2", "s1", "bash", "call_1", "done")] + const { cores, partIdToCoreIds } = octoToCoreMessages(msgs) + const padded = cores.map((c) => (c.id === "a2#x0" ? { ...c, text: "done\n\n " } : c)) + const out = reassemble(padded, msgs, partIdToCoreIds, "s1") + assert.equal(out[0]!.parts[0]!.state!.output, "done", "original output kept") +}) + +test("reassemble: kernel-truncated tool error replaces error without changing status", () => { + const msg = toolMsg("a2", "s1", "bash", "call_1", "unused") + msg.parts[0]!.state = { status: "error", input: { a: 1 }, error: "ORIGINAL_LONG_ERROR", title: "bash" } + const { cores, partIdToCoreIds } = octoToCoreMessages([msg]) + assert.equal(cores.find((c) => c.id === "a2#x0")!.text, "Error: ORIGINAL_LONG_ERROR", "error projected for kernel") + const truncated = cores.map((c) => (c.id === "a2#x0" ? { ...c, text: "Error: TRUNCATED_ERROR" } : c)) + const out = reassemble(truncated, [msg], partIdToCoreIds, "s1") + const state = out[0]!.parts[0]!.state! + assert.equal(state.status, "error", "error status preserved") + assert.equal(state.error, "TRUNCATED_ERROR", "adapter prefix removed before writing error") + assert.equal(state.output, undefined, "completed output field not introduced") +}) + +test("reassemble: interrupted tool output is projected and rewritten in metadata", () => { + const msg = toolMsg("a2", "s1", "bash", "call_1", "unused") + msg.parts[0]!.state = { + status: "error", + input: { a: 1 }, + error: "Tool execution aborted", + metadata: { interrupted: true, output: "ORIGINAL_PARTIAL_OUTPUT", exitCode: 130 }, + title: "bash", + } + const { cores, partIdToCoreIds } = octoToCoreMessages([msg]) + assert.equal(cores.find((c) => c.id === "a2#x0")!.text, "ORIGINAL_PARTIAL_OUTPUT", "partial output projected for kernel") + const truncated = cores.map((c) => (c.id === "a2#x0" ? { ...c, text: "TRUNCATED_PARTIAL_OUTPUT" } : c)) + const out = reassemble(truncated, [msg], partIdToCoreIds, "s1") + const state = out[0]!.parts[0]!.state! + assert.equal(state.status, "error", "interrupted status preserved") + assert.equal(state.error, "Tool execution aborted", "original interruption error preserved") + assert.equal(state.metadata?.interrupted, true, "interrupted marker preserved") + assert.equal(state.metadata?.output, "TRUNCATED_PARTIAL_OUTPUT", "truncated partial output written where OpenCode reads it") + assert.equal(state.metadata?.exitCode, 130, "other metadata preserved") +}) + test("makeNudgeMessage produces a valid user message", () => { const msgs = [userMsg("u1", "s1", "hi")] const n = makeNudgeMessage("bili_nudge_0", "s1", "please compress", msgs)