feat(tui): implement message compaction and summarization logic for efficient conversation handling
This commit is contained in:
@@ -8,6 +8,9 @@ import {
|
||||
isToolFailure,
|
||||
deriveVerifyCommand,
|
||||
AgentTurnServiceImpl,
|
||||
takeRecentTail,
|
||||
reduceMessagesToDigest,
|
||||
compactMessagesWithAi,
|
||||
} from "./turn_service.ts";
|
||||
import {
|
||||
type ChatMessage,
|
||||
@@ -264,3 +267,118 @@ describe("runTurn self-review", () => {
|
||||
expect(events.some((e) => e.kind === "review_usage")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Compaction: efficient + accurate ─────────────────────────────── */
|
||||
|
||||
describe("takeRecentTail", () => {
|
||||
it("keeps the most recent messages and evicts an oversized older blob", () => {
|
||||
const messages = [
|
||||
toolResultMessage("t0", "y".repeat(5000)), // huge old tool output
|
||||
userMessage("old request"),
|
||||
assistantMessage("recent reply A"),
|
||||
userMessage("recent reply B"),
|
||||
];
|
||||
const { tail, evicted } = takeRecentTail(messages, 25, 2);
|
||||
expect(evicted.length).toBe(2); // the blob + the old request
|
||||
expect(tail.map((m) => m.content)).toEqual(["recent reply A", "recent reply B"]);
|
||||
});
|
||||
|
||||
it("gives the whole history as tail when it is small enough", () => {
|
||||
const messages = [userMessage("a"), assistantMessage("b")];
|
||||
const { tail, evicted } = takeRecentTail(messages, 1000, 6);
|
||||
expect(evicted).toEqual([]);
|
||||
expect(tail.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not let a huge tool output blow the tail past the min count", () => {
|
||||
// minTail 2: newest two kept regardless; the huge tool body stays evicted.
|
||||
const messages = [
|
||||
toolResultMessage("t0", "x".repeat(50000)),
|
||||
userMessage("keep1"),
|
||||
assistantMessage("keep2"),
|
||||
];
|
||||
const { tail, evicted } = takeRecentTail(messages, 8000, 2);
|
||||
expect(tail.map((m) => m.content)).toEqual(["keep1", "keep2"]);
|
||||
expect(evicted.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceMessagesToDigest", () => {
|
||||
it("stubs tool bodies entirely and previews text", () => {
|
||||
const digest = reduceMessagesToDigest([
|
||||
toolResultMessage("t0", "y".repeat(5000)),
|
||||
userMessage("short request"),
|
||||
]);
|
||||
expect(digest).toContain("- tool");
|
||||
expect(digest).not.toContain("yyyy");
|
||||
expect(digest).toContain("short request");
|
||||
});
|
||||
|
||||
it("keeps user previews well above the shorter assistant cap", () => {
|
||||
const long = "w".repeat(2000);
|
||||
// User budget is 1500 chars — larger than the 400-char assistant cap, so
|
||||
// a long user request survives far more of its body than a long assistant
|
||||
// reply would (requests matter most for summary accuracy).
|
||||
const digestUser = reduceMessagesToDigest([userMessage(long)]);
|
||||
const digestAssistant = reduceMessagesToDigest([assistantMessage(long)]);
|
||||
expect(digestUser.length).toBeGreaterThan(800);
|
||||
expect(digestAssistant.length).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("respects the hard input cap", () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 200; i++) many.push(userMessage("r".repeat(300)));
|
||||
const digest = reduceMessagesToDigest(many);
|
||||
expect(digest.length).toBeLessThanOrEqual(20_000 + 40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactMessagesWithAi", () => {
|
||||
it("keeps the recent tail and prepends an AI summary", async () => {
|
||||
// Long enough to bypass the min-size guard, with an oversized OLD tool blob
|
||||
// (should be evicted + stubbed, not kept verbatim).
|
||||
const messages: ChatMessage[] = [
|
||||
userMessage("old request one"),
|
||||
assistantMessage("old response"),
|
||||
toolResultMessage("t0", "big".repeat(4000)),
|
||||
];
|
||||
for (let i = 0; i < 7; i++) messages.push(userMessage(`mid ${i}`));
|
||||
messages.push(toolResultMessage("t1", "tail-result"));
|
||||
messages.push(userMessage("newest request"));
|
||||
messages.push(assistantMessage("newest response"));
|
||||
|
||||
const provider: ProviderService = {
|
||||
async chat() {
|
||||
return { message: { role: "assistant", content: "## Requests\n- old request one" }, usage: null };
|
||||
},
|
||||
async chatStream() {
|
||||
return { message: { role: "assistant", content: null }, usage: null };
|
||||
},
|
||||
};
|
||||
await compactMessagesWithAi(messages, provider);
|
||||
// Summary first, recent tail preserved verbatim, tool blobs gone.
|
||||
expect(messages[0]?.content).toContain("[AI Summary of Previous Conversation]");
|
||||
expect(messages.some((m) => m.content === "newest request")).toBe(true);
|
||||
expect(messages.some((m) => m.content === "newest response")).toBe(true);
|
||||
expect(messages.some((m) => (m.content ?? "").includes("big".repeat(10)))).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades gracefully on summarizer failure, preserving the tail", async () => {
|
||||
const messages = [
|
||||
userMessage("old"),
|
||||
toolResultMessage("t0", "x".repeat(100)),
|
||||
userMessage("recent request"),
|
||||
];
|
||||
const failing: ProviderService = {
|
||||
async chat() {
|
||||
throw new Error("network down");
|
||||
},
|
||||
async chatStream() {
|
||||
return { message: { role: "assistant", content: null }, usage: null };
|
||||
},
|
||||
};
|
||||
await compactMessagesWithAi(messages, failing);
|
||||
// The recent message survives even when the LLM call fails.
|
||||
expect(messages.some((m) => m.content === "recent request")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user