diff --git a/changelog.d/fixes/8946-no-tool-output.md b/changelog.d/fixes/8946-no-tool-output.md new file mode 100644 index 0000000000..9dd6ed3369 --- /dev/null +++ b/changelog.d/fixes/8946-no-tool-output.md @@ -0,0 +1 @@ +- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946) diff --git a/open-sse/services/compression/bodyAdapter.ts b/open-sse/services/compression/bodyAdapter.ts index 64edaca84e..467575349f 100644 --- a/open-sse/services/compression/bodyAdapter.ts +++ b/open-sse/services/compression/bodyAdapter.ts @@ -400,13 +400,24 @@ export function adaptBodyForCompression( }); const cleanedInput = nextInput.filter((item) => { - if (!isRecord(item) || item.type !== "function_call") return true; + if (!isRecord(item)) return true; + const t = item.type; + if ( + t !== "function_call" && + t !== "custom_tool_call" && + t !== "local_shell_call" && + t !== "apply_patch_call" + ) { + return true; + } if (typeof item.call_id !== "string" || item.call_id.length === 0) return true; const hadMappedOutput = mappings.some((mapping) => { const original = mapping.item; return ( (original.type === "function_call_output" || - original.type === "custom_tool_call_output") && + original.type === "custom_tool_call_output" || + original.type === "local_shell_call_output" || + original.type === "apply_patch_call_output") && original.call_id === item.call_id ); }); diff --git a/tests/unit/compression/responses-orphan-tool-call.test.ts b/tests/unit/compression/responses-orphan-tool-call.test.ts new file mode 100644 index 0000000000..d0091f142a --- /dev/null +++ b/tests/unit/compression/responses-orphan-tool-call.test.ts @@ -0,0 +1,144 @@ +/** + * #8946 — "No tool output found" for custom tool calls (Codex desktop) + * + * Compaction Layer-3 purify_history drops oldest messages. The restore path's + * orphan-call cleanup only removed function_call items whose outputs vanished. + * custom_tool_call / local_shell_call / apply_patch_call were left orphaned, + * causing a 400 from the upstream Responses API. + * + * These tests pin the fix: the compaction restore co-drops any tool-call item + * whose output was removed, mirroring the existing function_call logic. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { adaptBodyForCompression } from "../../../open-sse/services/compression/bodyAdapter.ts"; +import { compressContext, estimateTokens } from "../../../open-sse/services/contextManager.ts"; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +const TOOL_CALL_TYPES = new Set([ + "function_call", + "custom_tool_call", + "local_shell_call", + "apply_patch_call", +]); + +const OUTPUT_TYPES = new Set([ + "function_call_output", + "custom_tool_call_output", + "local_shell_call_output", + "apply_patch_call_output", +]); + +/** + * Scan restored input for orphaned tool calls (a call item whose matching + * output is absent). Returns an array of descriptive strings, empty = clean. + */ +function findOrphanToolCalls(input: unknown[]): string[] { + const orphans: string[] = []; + for (const item of input) { + if (!isRecord(item)) continue; + if (!TOOL_CALL_TYPES.has(String(item.type))) continue; + const callId = typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : ""; + if (!callId) continue; + + const hasMatchingOutput = input.some( + (other) => isRecord(other) && OUTPUT_TYPES.has(String(other.type)) && other.call_id === callId + ); + if (!hasMatchingOutput) { + orphans.push(`${String(item.type)} ${callId}`); + } + } + return orphans.sort(); +} + +/** + * Build a Responses body with N tool-using turns. + * Each turn: tool_call + tool_call_output + assistant message + user message. + * The user messages carry substantial text to survive Layer-1 trim_tools and + * force Layer-3 purify_history to engage. + */ +function buildToolTurnBody( + numTurns: number, + outputText: string, + userText: string +): { input: Record[] } { + const input: Record[] = []; + for (let i = 0; i < numTurns; i++) { + // Each turn has one of each tool call type, cycling through them + const toolTypes: Array<{ + callType: string; + outputType: string; + name: string; + }> = [ + { callType: "custom_tool_call", outputType: "custom_tool_call_output", name: "my_tool" }, + { callType: "function_call", outputType: "function_call_output", name: "run_command" }, + { callType: "local_shell_call", outputType: "local_shell_call_output", name: "run_shell" }, + { callType: "apply_patch_call", outputType: "apply_patch_call_output", name: "apply_diff" }, + ]; + const t = toolTypes[i % toolTypes.length]; + const callId = `${t.callType}-${i}`; + + input.push({ type: t.callType, call_id: callId, name: t.name, arguments: "{}" }); + input.push({ type: t.outputType, call_id: callId, output: outputText }); + input.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `response ${i}` }], + }); + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: `${userText} turn ${i}` }], + }); + } + return { input }; +} + +test("#8946: compaction drops orphan custom_tool_call / local_shell_call / apply_patch_call with vanished outputs", () => { + // Build many turns: user messages carry enough text to survive Layer-1 + // trim_tools (which only trims role:"tool" content) so the aggregate + // token count still exceeds the compact target after trimming, forcing + // Layer-3 purify_history to engage. + const outputText = "result: ok"; + const userText = "x".repeat(3_000); // ~750 tokens each + const body = buildToolTurnBody(8, outputText, userText); + + const adapter = adaptBodyForCompression(body); + assert.equal(adapter.adapted, true); + + // Calculate before so we can set a target that forces Layer-3. + const before = estimateTokens(adapter.body.messages as Record[]); + const target = Math.max(5_000, Math.floor(before * 0.5)); + + const result = compressContext(adapter.body as Record, { + provider: "codex", + model: "gpt-5.6-terra", + maxTokens: target, + reserveTokens: 0, + }); + + assert.equal( + result.compressed, + true, + `compression should engage (before=${before}, target=${target}, stats=${JSON.stringify(result.stats)})` + ); + + const restored = adapter.restore(result.body as Record, { + dropMissingMappedItems: true, + }); + + const input = Array.isArray(restored.input) ? restored.input : []; + const orphans = findOrphanToolCalls(input); + + // Before the fix: custom_tool_call, local_shell_call, apply_patch_call + // orphans survive. After the fix: none survive. + assert.equal( + orphans.length, + 0, + `restored input contains orphaned tool calls whose outputs were dropped: ${JSON.stringify(orphans)} ` + + `(restored input length: ${input.length})` + ); +});