Files
2026-07-13 13:39:12 +08:00

210 lines
9.8 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
extractTextContent,
replaceTextContent,
type ChatMessageLike,
} from "../../../open-sse/services/compression/messageContent.ts";
import { applyAging } from "../../../open-sse/services/compression/progressiveAging.ts";
import { compressAggressive } from "../../../open-sse/services/compression/aggressive.ts";
import type { AgingThresholds } from "../../../open-sse/services/compression/types.ts";
// ─── ISSUE 1 — B-AGG-TEXTDROP ────────────────────────────────────────────────
// `replaceTextContent` previously dropped every text block after the first via
// flatMap → []. With the standard call pattern (newText = compressed JOIN of all
// text blocks), the joined original content must remain recoverable: nothing the
// model can no longer see may be silently lost.
describe("replaceTextContent — multi-text-block fidelity (B-AGG-TEXTDROP)", () => {
it("does not silently drop trailing text-block content absent from newText", () => {
const msg: ChatMessageLike = {
role: "user",
content: [
{ type: "text", text: "FIRST block alpha" },
{ type: "image", source: { foo: 1 } },
{ type: "text", text: "SECOND block bravo" },
{ type: "text", text: "THIRD block charlie" },
],
};
// newText does NOT subsume the trailing blocks (worst case): a caller that
// only summarized the first block. The trailing blocks' content must not
// silently vanish.
const replaced = replaceTextContent(msg, "NEWTEXT-only-first");
const out = extractTextContent(replaced.content);
assert.ok(out.includes("NEWTEXT-only-first"), "replacement text missing");
assert.ok(out.includes("SECOND block bravo"), "second block silently dropped");
assert.ok(out.includes("THIRD block charlie"), "third block silently dropped");
// Non-text blocks (image) must survive unchanged.
const blocks = replaced.content as Array<{ type?: string }>;
assert.ok(
blocks.some((b) => b.type === "image"),
"non-text block must be preserved"
);
});
it("collapses trailing blocks when newText already subsumes them (no duplication)", () => {
const msg: ChatMessageLike = {
role: "user",
content: [
{ type: "text", text: "alpha" },
{ type: "text", text: "bravo" },
],
};
// Standard call pattern: newText = compressed JOIN of all text blocks.
const joined = extractTextContent(msg.content); // "alpha\nbravo"
const replaced = replaceTextContent(msg, joined);
const blocks = replaced.content as Array<{ type?: string; text?: string }>;
const textBlocks = blocks.filter((b) => b.type === "text" || b.text !== undefined);
// Should collapse to a single text block — no duplicated "alpha"/"bravo".
assert.equal(textBlocks.length, 1, "subsumed trailing blocks should be collapsed");
assert.equal(extractTextContent(replaced.content), joined);
});
it("aging a multi-text-block message keeps all original text represented", () => {
// Build a long conversation so the first (multi-block) message ages out.
const first: ChatMessageLike = {
role: "user",
content: [
{ type: "text", text: "alpha-marker request: fix login" },
{ type: "text", text: "bravo-marker error: TypeError: boom" },
],
};
const msgs: ChatMessageLike[] = [first];
for (let i = 1; i < 8; i++) {
msgs.push({ role: i % 2 ? "assistant" : "user", content: `filler ${i} ${"z".repeat(60)}` });
}
const result = applyAging(msgs, { fullSummary: 10, moderate: 10, light: 3, verbatim: 1 });
const out = extractTextContent(result.messages[0].content as ChatMessageLike["content"]);
// Light tier keeps content; both blocks' text must still be present (joined).
assert.ok(out.includes("alpha-marker"), "first text block lost during aging");
assert.ok(out.includes("bravo-marker"), "second text block silently dropped during aging");
});
});
// ─── ISSUE 2 — B-AGG-ANTHROPIC-TR ────────────────────────────────────────────
// Anthropic-shape tool_result blocks (a {type:"tool_result"} content block inside
// a user message) must be compressed too, preserving tool_use_id + block type.
describe("aggressive — Anthropic tool_result compression (B-AGG-ANTHROPIC-TR)", () => {
it("compresses the text inside an Anthropic tool_result block", () => {
const bigJsonArray = JSON.stringify(
Array.from({ length: 200 }, (_, i) => ({ id: i, name: `item${i}`, data: "x".repeat(40) }))
);
const userMsg: ChatMessageLike = {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_ABC123",
content: [{ type: "text", text: bigJsonArray }],
},
],
};
const result = compressAggressive([userMsg]);
const outMsg = result.messages[0];
const blocks = outMsg.content as Array<Record<string, unknown>>;
const tr = blocks.find((b) => b.type === "tool_result");
assert.ok(tr, "tool_result block must survive");
assert.equal(tr!.type, "tool_result", "block type must be unchanged");
assert.equal(tr!.tool_use_id, "toolu_ABC123", "tool_use_id must be preserved");
// The inner text must be smaller than the original (it was compressed).
const innerText =
typeof tr!.content === "string"
? (tr!.content as string)
: ((tr!.content as Array<{ type?: string; text?: string }>) ?? [])
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("\n");
assert.ok(
innerText.length < bigJsonArray.length,
`tool_result inner text was not compressed (orig ${bigJsonArray.length}, got ${innerText.length})`
);
assert.ok(result.stats.aggressive!.toolResultSavings > 0, "toolResultSavings must be > 0");
});
it("compresses a string-form Anthropic tool_result block", () => {
const errorOutput =
"TypeError: Cannot read property 'x' of undefined\n" +
Array.from({ length: 40 }, (_, i) => ` at fn${i} (file${i}.ts:${i + 1}:${i + 5})`).join(
"\n"
);
const userMsg: ChatMessageLike = {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ERR", content: errorOutput }],
};
const result = compressAggressive([userMsg]);
const tr = (result.messages[0].content as Array<Record<string, unknown>>).find(
(b) => b.type === "tool_result"
);
assert.ok(tr, "tool_result block must survive");
assert.equal(tr!.tool_use_id, "toolu_ERR");
assert.ok((tr!.content as string).length < errorOutput.length, "string tool_result not compressed");
});
});
// ─── ISSUE 3 — B-AGG-JSONTAG ─────────────────────────────────────────────────
// Aging must not corrupt structured content (JSON / fenced code block) with a
// literal [COMPRESSED:...] inline prefix.
describe("progressiveAging — structured-content tag safety (B-AGG-JSONTAG)", () => {
const thresholds: AgingThresholds = { fullSummary: 10, moderate: 10, light: 3, verbatim: 1 };
function agedFirstContent(content: string, t: AgingThresholds): string {
const msgs: ChatMessageLike[] = [
{ role: "user", content },
{ role: "assistant", content: "a " + "z".repeat(60) },
{ role: "user", content: "b " + "z".repeat(60) },
{ role: "assistant", content: "c" },
];
const result = applyAging(msgs, t);
const c = result.messages[0].content;
return typeof c === "string" ? c : extractTextContent(c as ChatMessageLike["content"]);
}
it("keeps pure-JSON content JSON.parse-able after aging", () => {
const json = JSON.stringify({
status: "ok",
items: Array.from({ length: 5 }, (_, i) => ({ id: i, name: `n${i}` })),
meta: { a: 1, b: 2 },
});
const aged = agedFirstContent(json, thresholds);
// Must remain valid JSON (no inline tag corruption).
assert.doesNotThrow(() => JSON.parse(aged), `aged JSON not parseable: ${aged.slice(0, 80)}`);
});
it("keeps a fenced code block valid after aging (tag outside the fence)", () => {
const fenced = "```json\n{\n \"a\": 1,\n \"b\": [1, 2, 3]\n}\n```";
const aged = agedFirstContent(fenced, thresholds);
// The fenced block must still be present and intact.
assert.ok(aged.includes("```json"), "opening fence lost");
assert.ok(aged.trimEnd().endsWith("```"), "closing fence lost");
assert.ok(aged.includes('"a": 1'), "fenced payload corrupted");
});
it("does not re-compress structured content on a second aging pass (recursion guard)", () => {
const json = JSON.stringify({ status: "ok", items: [{ id: 0 }, { id: 1 }], meta: { a: 1 } });
const msgs: ChatMessageLike[] = [
{ role: "user", content: json },
{ role: "assistant", content: "a " + "z".repeat(60) },
{ role: "user", content: "b " + "z".repeat(60) },
{ role: "assistant", content: "c" },
];
const first = applyAging(msgs, thresholds);
const second = applyAging(first.messages as ChatMessageLike[], thresholds);
const firstContent = JSON.stringify(
first.messages.map((m) => (m as ChatMessageLike).content)
);
const secondContent = JSON.stringify(
second.messages.map((m) => (m as ChatMessageLike).content)
);
assert.equal(secondContent, firstContent, "second aging pass changed structured content");
// And it must still be parseable.
const c0 = (second.messages[0] as ChatMessageLike).content;
const text0 = typeof c0 === "string" ? c0 : extractTextContent(c0 as ChatMessageLike["content"]);
assert.doesNotThrow(() => JSON.parse(text0), "JSON corrupted after two aging passes");
});
});