Compare commits

...

1 Commits

3 changed files with 158 additions and 2 deletions

View File

@@ -0,0 +1 @@
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)

View File

@@ -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
);
});

View File

@@ -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<string, unknown> {
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<string, unknown>[] } {
const input: Record<string, unknown>[] = [];
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<string, unknown>[]);
const target = Math.max(5_000, Math.floor(before * 0.5));
const result = compressContext(adapter.body as Record<string, unknown>, {
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<string, unknown>, {
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})`
);
});