-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontext.test.ts
More file actions
208 lines (187 loc) · 7.26 KB
/
Copy pathcontext.test.ts
File metadata and controls
208 lines (187 loc) · 7.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
import { describe, expect, it, beforeEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AfterToolCallContext } from "@earendil-works/pi-agent-core";
import { ContextManager } from "../src/context/manager.js";
import { truncateMiddle, saveArtifact } from "../src/context/tool-results.js";
import { splitForCompact, estimateTokens, buildTranscriptText } from "../src/context/compact.js";
function userMessage(text: string) {
return { role: "user" as const, content: text, timestamp: Date.now() };
}
function assistantMessage(text: string) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "mock",
provider: "mock",
model: "m",
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop" as const,
timestamp: Date.now(),
};
}
function toolResultMessage(text: string) {
return {
role: "toolResult" as const,
toolCallId: "tc1",
toolName: "bash",
content: [{ type: "text" as const, text }],
isError: false,
timestamp: Date.now(),
};
}
describe("tool-result truncation", () => {
let artifactsDir: string;
beforeEach(() => {
artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-artifacts-"));
});
it("keeps short text untouched", () => {
expect(truncateMiddle("short", 100)).toEqual({ text: "short", droppedChars: 0 });
});
it("keeps head and tail with an explicit marker", () => {
const long = "A".repeat(600) + "MIDDLE" + "B".repeat(600);
const result = truncateMiddle(long, 400);
expect(result.droppedChars).toBeGreaterThan(0);
expect(result.text).toContain("[… ");
expect(result.text).toContain("characters truncated …]");
expect(result.text.startsWith("AAAA")).toBe(true);
expect(result.text.endsWith("BBBB")).toBe(true);
expect(result.text).not.toContain("MIDDLE");
});
it("saves full outputs as artifacts", () => {
const file = saveArtifact(artifactsDir, "npm install", "x".repeat(50));
expect(fs.existsSync(file)).toBe(true);
expect(fs.readFileSync(file, "utf8")).toHaveLength(50);
});
});
describe("ContextManager afterToolCall hook", () => {
function makeContext(outputText: string): AfterToolCallContext {
return {
assistantMessage: assistantMessage("go"),
toolCall: { type: "toolCall", id: "tc1", name: "bash", arguments: {} },
args: {},
result: { content: [{ type: "text", text: outputText }], details: { exitCode: 0 } },
isError: false,
context: { systemPrompt: "", messages: [] },
};
}
it("passes small results through unchanged", () => {
const manager = new ContextManager({ maxToolResultChars: 1000, compactAboveTokens: 0, keepRecentMessages: 10 });
expect(manager.handleAfterToolCall(makeContext("tiny"))).toBeUndefined();
});
it("overrides oversized results and preserves details", () => {
const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-artifacts-"));
const manager = new ContextManager({
maxToolResultChars: 500,
compactAboveTokens: 0,
keepRecentMessages: 10,
artifactsDir,
});
const override = manager.handleAfterToolCall(makeContext("y".repeat(5000)));
expect(override).toBeDefined();
const textPart = override!.content![0] as { type: string; text: string };
expect(textPart.text.length).toBeLessThan(1200);
expect(textPart.text).toContain("[full output saved to");
expect(override!.details).toEqual({ exitCode: 0 });
});
});
const conversation = [
userMessage("first task"),
assistantMessage("working"),
toolResultMessage("result one"),
userMessage("second task"),
assistantMessage("still working"),
];
describe("compaction helpers", () => {
it("cuts only on user-message boundaries", () => {
const long = [
...conversation,
userMessage("third task"),
assistantMessage("on it"),
toolResultMessage("result two"),
userMessage("final task"),
assistantMessage("finishing"),
]; // users at index 0 and 5
const split = splitForCompact(long, 3);
// Latest valid user boundary ≤ len-keepRecent wins.
expect(split.cutIndex).toBe(5);
expect(long[split.cutIndex]!.role).toBe("user");
expect(split.recent).toHaveLength(5);
expect(split.old).toHaveLength(5);
});
it("refuses to compact when everything is protected", () => {
const split = splitForCompact(conversation, conversation.length);
expect(split.old).toHaveLength(0);
expect(split.recent).toBe(conversation);
// No internal user boundary within reach → nothing can be cut.
const headless = [conversation[1]!, conversation[2]!];
const single = splitForCompact(headless, 2);
expect(single.old).toHaveLength(0);
});
it("renders a readable transcript", () => {
const transcript = buildTranscriptText([conversation[0]!, conversation[1]!, conversation[2]!]);
expect(transcript).toContain("[USER]");
expect(transcript).toContain("[ASSISTANT]");
expect(transcript).toContain("[TOOL RESULT for bash]");
});
it("estimates tokens deterministically", () => {
const single = estimateTokens([userMessage("abcd")]);
expect(single).toBeGreaterThan(0);
expect(estimateTokens([userMessage("abcd"), userMessage("abcd")])).toBeGreaterThan(single);
});
});
describe("ContextManager.compact", () => {
it("replaces old turns with a summary message", async () => {
const manager = new ContextManager({
maxToolResultChars: 1000,
compactAboveTokens: 0,
keepRecentMessages: 3,
});
let summarizeCalls = 0;
const summarize = async (transcript: string) => {
summarizeCalls += 1;
return `SUMMARY OF ${transcript.length} chars`;
};
const long = [
...conversation,
userMessage("third task"),
assistantMessage("on it"),
toolResultMessage("result two"),
userMessage("final task"),
assistantMessage("finishing"),
];
const compacted = await manager.compact(long, summarize);
expect(summarizeCalls).toBe(1);
expect(compacted[0]!.role).toBe("user");
expect(JSON.stringify(compacted[0])).toContain("<conversation-summary>");
expect(compacted).toHaveLength(1 + 5); // summary + recent from cut index 5
});
it("auto-compacts only above the token threshold via transformContext", async () => {
const hot = new ContextManager({
maxToolResultChars: 1000,
compactAboveTokens: 10,
keepRecentMessages: 2,
});
const long = [
userMessage("task one"),
assistantMessage("reply one"),
toolResultMessage("result one"),
userMessage("task two"),
assistantMessage("reply two"),
toolResultMessage("result two"),
userMessage("task three"),
assistantMessage("reply three"),
];
const transformed = await hot.makeTransformContext(async () => "summary")(long);
expect(transformed[0]!.role).toBe("user");
expect(transformed.length).toBeLessThan(long.length);
const quietManager = new ContextManager({
maxToolResultChars: 1000,
compactAboveTokens: 0,
keepRecentMessages: 2,
});
const passthrough = await quietManager.makeTransformContext(async () => "should not run")(long);
expect(passthrough).toBe(long);
});
});