diff --git a/changelog.d/fixes/port-2132-headroom-developer-role.md b/changelog.d/fixes/port-2132-headroom-developer-role.md new file mode 100644 index 0000000000..c9064e43c5 --- /dev/null +++ b/changelog.d/fixes/port-2132-headroom-developer-role.md @@ -0,0 +1 @@ +- **fix(compression):** the Headroom SmartCrusher tabular-compaction guard now also skips `role: "developer"` messages, not just `role: "system"` — Codex CLI sends its instructions/tool-schema turn as `developer` (the Responses-API equivalent of `system`), so an embedded JSON array (e.g. an `update_plan` example) could get tabular-compacted, corrupting the model's tool-calling instructions and breaking Codex CLI plan mode. (thanks @SingCJ) diff --git a/open-sse/services/compression/engines/headroom/smartcrusher.ts b/open-sse/services/compression/engines/headroom/smartcrusher.ts index c40ea82928..a14c851b6b 100644 --- a/open-sse/services/compression/engines/headroom/smartcrusher.ts +++ b/open-sse/services/compression/engines/headroom/smartcrusher.ts @@ -160,7 +160,7 @@ export function collectCompactableArrays( while ((m = regex.exec(text)) !== null) pushIfCompactable(m[1].trim()); }; for (const msg of messages) { - if (msg.role === "system") continue; + if (msg.role === "system" || msg.role === "developer") continue; if (typeof msg.content === "string") scanText(msg.content); else if (Array.isArray(msg.content)) { for (const part of msg.content) { @@ -218,8 +218,12 @@ export function crushMessages( let changed = false; const result = messages.map((msg): MessageLike => { - // Guard: never touch system messages - if (msg.role === "system") return { ...msg }; + // Guard: never touch system messages. "developer" is the Responses-API equivalent of + // "system" used by newer models (e.g. Codex CLI, see open-sse/executors/codex.ts) and + // carries the same kind of instructions/tool-schema content — compacting a JSON array + // embedded there (e.g. an update_plan example) can corrupt the model's tool-calling + // instructions (9router#2132: broke Codex CLI plan mode). + if (msg.role === "system" || msg.role === "developer") return { ...msg }; if (typeof msg.content === "string") { const crushed = crushText(msg.content, minRows); diff --git a/tests/unit/compression/headroom-developer-role-2132.test.ts b/tests/unit/compression/headroom-developer-role-2132.test.ts new file mode 100644 index 0000000000..5f797d3f1a --- /dev/null +++ b/tests/unit/compression/headroom-developer-role-2132.test.ts @@ -0,0 +1,91 @@ +/** + * Regression test for upstream 9router#2132 (ported): "Token saver Headroom ruins plan mode + * in Codex CLI". + * + * Root cause: SmartCrusher's system-message guard only checked `role === "system"`. Codex CLI + * (open-sse/executors/codex.ts) sends its instructions/tool-schema turn with role "developer" + * (the Responses-API equivalent of "system" used by newer models). Every other guard in this + * codebase that excludes "system" also excludes "developer" (see roleNormalizer.ts, + * contextManager.ts, claudeUpstreamMessages.ts, etc.) — SmartCrusher was the exception, so it + * happily tabular-compacted JSON arrays (e.g. the update_plan tool schema/examples) embedded in + * the developer-role turn, corrupting the instructions the model needs to call the plan tool. + */ + +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let crushMessages: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").crushMessages; +let collectCompactableArrays: typeof import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts").collectCompactableArrays; +let headroomEngine: import("../../../open-sse/services/compression/engines/headroom/index.ts").headroomEngine; + +before(async () => { + const mod = await import("../../../open-sse/services/compression/engines/headroom/smartcrusher.ts"); + crushMessages = mod.crushMessages; + collectCompactableArrays = mod.collectCompactableArrays; + + const engineMod = await import("../../../open-sse/services/compression/engines/headroom/index.ts"); + headroomEngine = engineMod.headroomEngine; +}); + +/** A homogeneous array big enough (>= default minRows=8) to trigger compaction. */ +function makePlanSchemaExample(): Record[] { + return Array.from({ length: 10 }, (_, i) => ({ + step: `step-${i + 1}`, + status: i === 0 ? "in_progress" : "pending", + })); +} + +describe("headroom SmartCrusher — developer-role guard (9router#2132)", () => { + it("does NOT compact JSON arrays embedded in a developer-role message (crushMessages)", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { + role: "developer", + content: `Use the update_plan tool. Example plan:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Refactor the auth module." }, + ]; + + const { messages: result, changed } = crushMessages(messages, 8); + + assert.equal(changed, false, "developer-role content must not be touched"); + assert.equal(result[0].content, messages[0].content); + }); + + it("still compacts the same payload when placed under role: system (control case)", () => { + // Sanity check: this proves the array itself WOULD be compactable — the guard, not the + // shape of the payload, is what must change. + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [{ role: "user", content: `\`\`\`json\n${json}\n\`\`\`` }]; + + const { changed } = crushMessages(messages, 8); + assert.equal(changed, true, "control case: user-role content of the same shape IS compacted"); + }); + + it("collectCompactableArrays does not surface arrays from developer-role messages", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const messages = [ + { role: "developer", content: `\`\`\`json\n${json}\n\`\`\`` }, + ]; + const found = collectCompactableArrays(messages, 8); + assert.equal(found.length, 0); + }); + + it("headroomEngine.apply leaves a Codex-CLI-shaped developer turn untouched end-to-end", () => { + const json = JSON.stringify(makePlanSchemaExample()); + const body: Record = { + model: "gpt-5-codex", + messages: [ + { + role: "developer", + content: `Instructions with an embedded schema example:\n\`\`\`json\n${json}\n\`\`\``, + }, + { role: "user", content: "Implement the feature." }, + ], + }; + + const result = headroomEngine.apply(body); + assert.equal(result.compressed, false); + assert.deepEqual(result.body, body); + }); +});