fix(compression): Headroom SmartCrusher skips developer-role messages (port from 9router#2132)

Root cause: SmartCrusher's system-message guard only excluded role === "system", but 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 system-exclusion guard in this codebase also covers developer (roleNormalizer.ts, contextManager.ts, claudeUpstreamMessages.ts, etc.) except this one, so Headroom happily tabular-compacted JSON arrays embedded in the developer turn (e.g. an update_plan tool schema example), corrupting the instructions the model needs to call the plan tool and breaking Codex CLI plan mode.

Fix: extend the guard in crushMessages()/collectCompactableArrays() (smartcrusher.ts) to skip role === "developer" alongside role === "system".

Reported-by: SingCJ (https://github.com/decolua/9router/issues/2132)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-13 23:07:56 -03:00
parent 2c62333b0b
commit 0b03b22c23
3 changed files with 99 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>[] {
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<string, unknown> = {
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);
});
});