Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
827
packages/agent/test/harness/compaction.test.ts
Normal file
827
packages/agent/test/harness/compaction.test.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
createModels,
|
||||
type FauxProviderHandle,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
type Message,
|
||||
type Model,
|
||||
type Models,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CompactionPreparation,
|
||||
type CompactionSettings,
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
generateSummary,
|
||||
generateSummaryWithUsage,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
|
||||
import { buildSessionContext } from "../../src/harness/session/context.ts";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
Entry,
|
||||
MessageEntry,
|
||||
} from "../../src/harness/session/types.ts";
|
||||
import { getOrThrow } from "../../src/harness/types.ts";
|
||||
import type { AgentMessage } from "../../src/types.ts";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
return `entry-${nextId++}`;
|
||||
}
|
||||
|
||||
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, usage = createMockUsage(100, 50)): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage,
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageEntry(message: AgentMessage, parentId: string | null = null): MessageEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id: createId(),
|
||||
parentId,
|
||||
seq: nextId,
|
||||
timestamp: Date.now(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactionEntry(
|
||||
summary: string,
|
||||
parentId: string | null = null,
|
||||
retainedTail?: AgentMessage[],
|
||||
): CompactionEntry {
|
||||
return {
|
||||
type: "compaction",
|
||||
id: createId(),
|
||||
parentId,
|
||||
seq: nextId,
|
||||
timestamp: Date.now(),
|
||||
summary,
|
||||
tokensBefore: 1234,
|
||||
retainedTail: retainedTail ?? [],
|
||||
fromHook: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomEntry(customType: string, parentId: string | null = null): CustomEntry {
|
||||
return {
|
||||
type: "custom",
|
||||
customType,
|
||||
id: createId(),
|
||||
parentId,
|
||||
seq: nextId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
|
||||
const models = createModels();
|
||||
let fauxCount = 0;
|
||||
|
||||
function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model<Api> } {
|
||||
const faux = fauxProvider({
|
||||
provider: `faux-${++fauxCount}`,
|
||||
models: [
|
||||
{
|
||||
id: reasoning ? "reasoning-model" : "non-reasoning-model",
|
||||
reasoning,
|
||||
contextWindow: 200000,
|
||||
maxTokens,
|
||||
},
|
||||
],
|
||||
});
|
||||
models.setProvider(faux.provider);
|
||||
return { faux, model: faux.getModel() };
|
||||
}
|
||||
|
||||
function createModelsWithSimpleResponses(responses: AssistantMessage[]): Models {
|
||||
const remaining = [...responses];
|
||||
const stub = Object.create(models) as Models;
|
||||
stub.completeSimple = async () => {
|
||||
const response = remaining.shift();
|
||||
if (!response) throw new Error("No faux completeSimple response queued");
|
||||
return response;
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe("harness compaction", () => {
|
||||
beforeEach(() => {
|
||||
nextId = 0;
|
||||
});
|
||||
|
||||
it("calculates total context tokens from usage", () => {
|
||||
expect(calculateContextTokens(createMockUsage(1000, 500, 200, 100))).toBe(1800);
|
||||
expect(calculateContextTokens(createMockUsage(0, 0, 0, 0))).toBe(0);
|
||||
});
|
||||
|
||||
it("checks compaction threshold", () => {
|
||||
const settings: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 10000,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
expect(shouldCompact(95000, 100000, settings)).toBe(true);
|
||||
expect(shouldCompact(89000, 100000, settings)).toBe(false);
|
||||
expect(shouldCompact(95000, 100000, { ...settings, enabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("finds a cut point based on token differences", () => {
|
||||
const entries: Entry[] = [];
|
||||
let parentId: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const user = createMessageEntry(createUserMessage(`User ${i}`), parentId);
|
||||
entries.push(user);
|
||||
const assistant = createMessageEntry(
|
||||
createAssistantMessage(`Assistant ${i}`, createMockUsage(0, 100, (i + 1) * 1000, 0)),
|
||||
user.id,
|
||||
);
|
||||
entries.push(assistant);
|
||||
parentId = assistant.id;
|
||||
}
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, 2500);
|
||||
expect(entries[result.firstKeptEntryIndex]?.type).toBe("message");
|
||||
});
|
||||
|
||||
it("covers cut-point and turn-start edge cases", () => {
|
||||
const firstCustom = createCustomEntry("first");
|
||||
const secondCustom = createCustomEntry("second", firstCustom.id);
|
||||
expect(findCutPoint([firstCustom, secondCustom], 0, 2, 1)).toEqual({
|
||||
firstKeptEntryIndex: 0,
|
||||
turnStartIndex: -1,
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const branchSummary: BranchSummaryEntry = {
|
||||
type: "branch_summary",
|
||||
id: createId(),
|
||||
parentId: secondCustom.id,
|
||||
seq: nextId,
|
||||
timestamp: Date.now(),
|
||||
fromId: "branch",
|
||||
summary: "branch summary",
|
||||
fromHook: false,
|
||||
};
|
||||
expect(findTurnStartIndex([firstCustom, branchSummary], 1, 0)).toBe(1);
|
||||
expect(findTurnStartIndex([firstCustom, secondCustom], 1, 0)).toBe(-1);
|
||||
|
||||
const result = findCutPoint([firstCustom, branchSummary], 0, 2, 1);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
|
||||
const toolResult = createMessageEntry({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({
|
||||
firstKeptEntryIndex: 0,
|
||||
turnStartIndex: -1,
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const user = createMessageEntry(createUserMessage("user"));
|
||||
const compaction = createCompactionEntry("summary", user.id);
|
||||
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
|
||||
expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("estimates tokens and context usage across supported message roles", () => {
|
||||
const usage = createMockUsage(10, 5, 3, 2);
|
||||
const assistant = createAssistantMessage("assistant", usage);
|
||||
const assistantWithThinkingAndTool: AssistantMessage = {
|
||||
...assistant,
|
||||
content: [
|
||||
{ type: "thinking", thinking: "thinking" },
|
||||
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } },
|
||||
],
|
||||
};
|
||||
const customString: AgentMessage = {
|
||||
role: "custom",
|
||||
customType: "note",
|
||||
content: "custom text",
|
||||
display: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const toolResultWithImage: AgentMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{ type: "text", text: "tool text" },
|
||||
{ type: "image", mimeType: "image/png", data: "abc" },
|
||||
],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const bashExecution: AgentMessage = {
|
||||
role: "bashExecution",
|
||||
command: "npm run check",
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const branchSummaryMessage: AgentMessage = {
|
||||
role: "branchSummary",
|
||||
summary: "branch",
|
||||
fromId: "x",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const compactionSummaryMessage: AgentMessage = {
|
||||
role: "compactionSummary",
|
||||
summary: "compact",
|
||||
tokensBefore: 123,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
|
||||
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(customString)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
|
||||
expect(estimateTokens(bashExecution)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0);
|
||||
expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0);
|
||||
expect(
|
||||
getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]),
|
||||
).toBe(usage);
|
||||
expect(
|
||||
getLastAssistantUsage([
|
||||
createMessageEntry({ ...assistant, stopReason: "aborted" }),
|
||||
createMessageEntry({ ...assistant, stopReason: "error" }),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getLastAssistantUsage([
|
||||
createMessageEntry(createUserMessage("user")),
|
||||
createMessageEntry(assistant),
|
||||
createMessageEntry(createAssistantMessage("partial", createMockUsage(0, 0))),
|
||||
]),
|
||||
).toBe(usage);
|
||||
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
|
||||
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
|
||||
usageTokens: 20,
|
||||
lastUsageIndex: 0,
|
||||
});
|
||||
const estimate = estimateContextTokens([
|
||||
createUserMessage("Hello"),
|
||||
assistant,
|
||||
createUserMessage("continue"),
|
||||
createAssistantMessage("Partial thinking", createMockUsage(0, 0)),
|
||||
]);
|
||||
expect(estimate.usageTokens).toBe(20);
|
||||
expect(estimate.lastUsageIndex).toBe(1);
|
||||
expect(estimate.trailingTokens).toBeGreaterThan(0);
|
||||
expect(estimate.tokens).toBe(20 + estimate.trailingTokens);
|
||||
});
|
||||
|
||||
it("builds session context with a compaction entry", async () => {
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
|
||||
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
|
||||
const compaction = createCompactionEntry("Summary of 1,a,2,b", a2.id, [
|
||||
createUserMessage("2"),
|
||||
createAssistantMessage("b"),
|
||||
]);
|
||||
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
|
||||
const a3 = createMessageEntry(createAssistantMessage("c"), u3.id);
|
||||
const loaded = await buildSessionContext([u1, a1, u2, a2, compaction, u3, a3], undefined, BACKGROUND_CONTEXT);
|
||||
expect(loaded).toHaveLength(5);
|
||||
expect(loaded[0]?.role).toBe("compactionSummary");
|
||||
expect(loaded.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepares compaction using the latest compaction summary as previousSummary", async () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"), u1.id);
|
||||
const u2 = createMessageEntry(createUserMessage("user msg 2"), a1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2", createMockUsage(5000, 1000)), u2.id);
|
||||
const compaction1 = createCompactionEntry("First summary", a2.id);
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id);
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id);
|
||||
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3];
|
||||
const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS));
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation?.previousSummary).toBe("First summary");
|
||||
expect(preparation?.retainedTail.length).toBeGreaterThan(0);
|
||||
expect(preparation?.tokensBefore).toBe(
|
||||
estimateContextTokens(await buildSessionContext(pathEntries, undefined, BACKGROUND_CONTEXT)).tokens,
|
||||
);
|
||||
});
|
||||
|
||||
it("carries a previous compaction's retained tail into the next preparation", () => {
|
||||
const retainedUser = createUserMessage("retained user");
|
||||
const retainedAssistant = createAssistantMessage("retained assistant");
|
||||
const compaction = createCompactionEntry("previous summary", null, [retainedUser, retainedAssistant]);
|
||||
const user = createMessageEntry(createUserMessage("new user"), compaction.id);
|
||||
const assistant = createMessageEntry(createAssistantMessage("new assistant"), user.id);
|
||||
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([compaction, user, assistant], {
|
||||
enabled: true,
|
||||
reserveTokens: 100,
|
||||
keepRecentTokens: 1,
|
||||
}),
|
||||
);
|
||||
expect(preparation?.previousSummary).toBe("previous summary");
|
||||
expect([
|
||||
...(preparation?.messagesToSummarize ?? []),
|
||||
...(preparation?.turnPrefixMessages ?? []),
|
||||
...(preparation?.retainedTail ?? []),
|
||||
]).toEqual([retainedUser, retainedAssistant, user.message, assistant.message]);
|
||||
});
|
||||
|
||||
it("prepares split-turn compaction with prior file-operation details", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1"));
|
||||
const assistantMessage: AssistantMessage = {
|
||||
...createAssistantMessage("assistant msg 1"),
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }],
|
||||
};
|
||||
const a1 = createMessageEntry(assistantMessage, u1.id);
|
||||
const compaction1: CompactionEntry = {
|
||||
...createCompactionEntry("First summary", a1.id),
|
||||
details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts", "written.ts"] },
|
||||
};
|
||||
const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id);
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([u1, a1, compaction1, u2, a2], {
|
||||
enabled: true,
|
||||
reserveTokens: 100,
|
||||
keepRecentTokens: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true });
|
||||
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect([...preparation!.fileOps.read]).toContain("old-read.ts");
|
||||
expect([...preparation!.fileOps.edited]).toContain("old-edit.ts");
|
||||
expect([...preparation!.fileOps.edited]).toContain("written.ts");
|
||||
});
|
||||
|
||||
it("does not prepare compaction when there is nothing valid to compact", () => {
|
||||
const compaction = createCompactionEntry("already compacted");
|
||||
expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
|
||||
expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes conversation with truncated tool results", () => {
|
||||
const longContent = "x".repeat(5000);
|
||||
const messages = convertMessages([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tc1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: longContent }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const result = serializeConversation(messages);
|
||||
expect(result).toContain("[Tool result]:");
|
||||
expect(result).toContain("[... 3000 more characters truncated]");
|
||||
});
|
||||
|
||||
it("passes reasoning through generateSummary only for reasoning models with thinking enabled", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
const { faux: fauxReasoning, model: reasoningModel } = createFauxModel(true);
|
||||
fauxReasoning.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
reasoningModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "medium" });
|
||||
|
||||
const { faux: fauxOff, model: offModel } = createFauxModel(true);
|
||||
fauxOff.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
offModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"off",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[1]).not.toHaveProperty("reasoning");
|
||||
|
||||
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
|
||||
fauxNonReasoning.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
nonReasoningModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
expect(seenOptions[2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("includes previous summaries and custom instructions in generateSummary prompts", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
let promptText = "";
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([
|
||||
(context) => {
|
||||
const message = context.messages[0];
|
||||
const content = message?.role === "user" ? message.content : [];
|
||||
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = getOrThrow(
|
||||
await generateSummaryWithUsage(
|
||||
messages,
|
||||
models,
|
||||
model,
|
||||
2000,
|
||||
"focus",
|
||||
"old summary",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
expect(summary.text).toContain("Test summary");
|
||||
expect(summary.usage.input).toBeGreaterThan(0);
|
||||
expect(summary.usage.output).toBeGreaterThan(0);
|
||||
expect(summary.usage.totalTokens).toBe(
|
||||
summary.usage.input + summary.usage.output + summary.usage.cacheRead + summary.usage.cacheWrite,
|
||||
);
|
||||
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
|
||||
expect(promptText).toContain("Additional focus: focus");
|
||||
});
|
||||
|
||||
it("preserves the string result from generateSummary", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
|
||||
expect(
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
model,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
),
|
||||
).toBe("## Goal\nTest summary");
|
||||
});
|
||||
|
||||
it("returns error results for failed or aborted summary generations", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
|
||||
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
|
||||
const errorResult = await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
errorModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(errorResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: boom" },
|
||||
});
|
||||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
|
||||
const abortedResult = await generateSummary(
|
||||
messages,
|
||||
models,
|
||||
abortedModel,
|
||||
2000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
|
||||
});
|
||||
|
||||
it("clamps compaction summary maxTokens to the model output cap", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
const { faux, model } = createFauxModel(false, 128000);
|
||||
faux.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: messages,
|
||||
retainedTail: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 600000,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
|
||||
};
|
||||
|
||||
getOrThrow(
|
||||
await compact(preparation, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
|
||||
expect(seenOptions.map((options) => options?.cacheRetention)).toEqual(["none", "none"]);
|
||||
const sessionIds = seenOptions.map((options) => options?.sessionId);
|
||||
expect(sessionIds[0]).not.toBe(sessionIds[1]);
|
||||
});
|
||||
|
||||
it("retains per-request retries for non-harness compaction callers", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: [],
|
||||
retainedTail: messages,
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2_000, keepRecentTokens: 20 },
|
||||
};
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "rate limit exceeded" }),
|
||||
fauxAssistantMessage("recovered summary"),
|
||||
]);
|
||||
|
||||
expect(
|
||||
getOrThrow(
|
||||
await compact(
|
||||
preparation,
|
||||
models,
|
||||
model,
|
||||
undefined,
|
||||
undefined,
|
||||
{ enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).summary,
|
||||
).toContain("recovered summary");
|
||||
});
|
||||
|
||||
it("returns compaction error results without throwing", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: [],
|
||||
retainedTail: messages,
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
|
||||
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
|
||||
expect(
|
||||
await compact(
|
||||
preparation,
|
||||
models,
|
||||
historyModel,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("combines usage for split-turn compaction summaries", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { model } = createFauxModel(false);
|
||||
const historyUsage = createMockUsage(1, 2, 3, 4);
|
||||
const turnPrefixUsage = createMockUsage(5, 6, 7, 8);
|
||||
const usageModels = createModelsWithSimpleResponses([
|
||||
{ ...fauxAssistantMessage("history summary"), usage: historyUsage },
|
||||
{ ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage },
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
retainedTail: messages,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
const result = getOrThrow(
|
||||
await compact(preparation, usageModels, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(result.usage).toEqual(createMockUsage(6, 8, 10, 12));
|
||||
});
|
||||
|
||||
it("passes reasoning through turn-prefix summaries when enabled", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
const { faux, model } = createFauxModel(true);
|
||||
faux.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Original Request\nTest summary");
|
||||
},
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
retainedTail: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
getOrThrow(
|
||||
await compact(preparation, models, model, undefined, "high", undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
||||
it("returns turn-prefix compaction errors without throwing", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const preparation: CompactionPreparation = {
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
retainedTail: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
|
||||
|
||||
expect(
|
||||
await compact(preparation, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
|
||||
});
|
||||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
|
||||
expect(
|
||||
await compact(
|
||||
preparation,
|
||||
models,
|
||||
abortedModel,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "aborted", message: "prefix stopped" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a compaction result with file details", async () => {
|
||||
const u1 = createMessageEntry(createUserMessage("read a file"));
|
||||
const assistantMessage: AssistantMessage = {
|
||||
...createAssistantMessage("calling tool", createMockUsage(1000, 200)),
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "src/index.ts" } }],
|
||||
};
|
||||
const a1 = createMessageEntry(assistantMessage, u1.id);
|
||||
const u2 = createMessageEntry(createUserMessage("continue"), a1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id);
|
||||
const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS));
|
||||
expect(preparation).toBeDefined();
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
const result = getOrThrow(
|
||||
await compact(preparation!, models, model, undefined, undefined, undefined, undefined, BACKGROUND_CONTEXT),
|
||||
);
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.usage?.totalTokens).toBeGreaterThan(0);
|
||||
expect(result.retainedTail?.length).toBeGreaterThan(0);
|
||||
expect(result.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
function convertMessages(messages: Message[]): Message[] {
|
||||
return messages;
|
||||
}
|
||||
Reference in New Issue
Block a user