@monotykamary/pi-vcc 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +688 -0
- package/__tests__/helpers.ts +135 -0
- package/__tests__/root-causes.test.ts +858 -0
- package/demo.gif +0 -0
- package/index.ts +21 -0
- package/knip.json +4 -0
- package/npm-shrinkwrap.json +3001 -0
- package/package.json +36 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +36 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +497 -0
- package/src/core/build-sections.ts +431 -0
- package/src/core/content.ts +71 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +177 -0
- package/src/core/format.ts +98 -0
- package/src/core/invisible-continue.ts +172 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +40 -0
- package/src/core/normalize.ts +73 -0
- package/src/core/recall-scope.ts +49 -0
- package/src/core/render-entries.ts +58 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +375 -0
- package/src/core/settings.ts +190 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +179 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +19 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/shared-symbols.ts +357 -0
- package/src/hooks/before-compact.ts +564 -0
- package/src/hooks/proactive-threshold.ts +131 -0
- package/src/sections.ts +23 -0
- package/src/tools/recall.ts +204 -0
- package/src/types.ts +20 -0
- package/tests/australian-government.test.ts +479 -0
- package/tests/before-compact-hook.test.ts +185 -0
- package/tests/before-compact.test.ts +227 -0
- package/tests/brief.test.ts +215 -0
- package/tests/build-sections.test.ts +59 -0
- package/tests/compile.test.ts +89 -0
- package/tests/content.test.ts +66 -0
- package/tests/deep-extraction.test.ts +313 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +87 -0
- package/tests/format.test.ts +73 -0
- package/tests/government-domain.test.ts +542 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +49 -0
- package/tests/model-threshold-hook.test.ts +377 -0
- package/tests/model-threshold.test.ts +223 -0
- package/tests/normalize.test.ts +104 -0
- package/tests/proactive-threshold.test.ts +490 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +92 -0
- package/tests/report.test.ts +58 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +233 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tests/threshold-integration.test.ts +359 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { buildOwnCut } from "../src/hooks/before-compact";
|
|
3
|
+
|
|
4
|
+
const msg = (id: string, role: "user" | "assistant" | "toolResult", content = "x") => ({
|
|
5
|
+
id,
|
|
6
|
+
type: "message",
|
|
7
|
+
message: { role, content },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const comp = (id: string, firstKeptEntryId?: string) => ({
|
|
11
|
+
id,
|
|
12
|
+
type: "compaction",
|
|
13
|
+
firstKeptEntryId,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("buildOwnCut", () => {
|
|
17
|
+
test("no prior compaction: cuts at last user message", () => {
|
|
18
|
+
const r = buildOwnCut([
|
|
19
|
+
msg("m1", "user", "a"),
|
|
20
|
+
msg("m2", "assistant", "b"),
|
|
21
|
+
msg("m3", "user", "c"),
|
|
22
|
+
msg("m4", "assistant", "d"),
|
|
23
|
+
]);
|
|
24
|
+
expect(r.ok).toBe(true);
|
|
25
|
+
if (!r.ok) return;
|
|
26
|
+
expect(r.firstKeptEntryId).toBe("m3");
|
|
27
|
+
expect(r.messages).toHaveLength(2);
|
|
28
|
+
expect(r.compactAll).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("cancels with too_few_live_messages when liveMessages <= 2", () => {
|
|
32
|
+
const r = buildOwnCut([
|
|
33
|
+
comp("c1", "m1"),
|
|
34
|
+
msg("m1", "user", "x"),
|
|
35
|
+
msg("m2", "assistant", "y"),
|
|
36
|
+
]);
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
if (r.ok) return;
|
|
39
|
+
expect(r.reason).toBe("too_few_live_messages");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("orphan firstKeptEntryId triggers recovery (collect after compaction)", () => {
|
|
43
|
+
// Prev compaction set firstKeptEntryId to a non-existent id (e.g. "" sentinel
|
|
44
|
+
// from a previous compact-all). Recovery should collect msgs after compaction.
|
|
45
|
+
const r = buildOwnCut([
|
|
46
|
+
msg("old1", "user", "old"),
|
|
47
|
+
msg("old2", "assistant", "old"),
|
|
48
|
+
comp("c1", "ORPHAN_ID"),
|
|
49
|
+
msg("m1", "user", "a"),
|
|
50
|
+
msg("m2", "assistant", "b"),
|
|
51
|
+
msg("m3", "user", "c"),
|
|
52
|
+
msg("m4", "assistant", "d"),
|
|
53
|
+
]);
|
|
54
|
+
expect(r.ok).toBe(true);
|
|
55
|
+
if (!r.ok) return;
|
|
56
|
+
expect(r.firstKeptEntryId).toBe("m3");
|
|
57
|
+
expect(r.messages).toHaveLength(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("resumes from firstKeptEntryId after prior compaction", () => {
|
|
61
|
+
const r = buildOwnCut([
|
|
62
|
+
msg("old1", "user", "old"),
|
|
63
|
+
msg("old2", "assistant", "old"),
|
|
64
|
+
comp("c1", "m1"),
|
|
65
|
+
msg("m1", "user", "a"),
|
|
66
|
+
msg("m2", "assistant", "b"),
|
|
67
|
+
msg("m3", "user", "c"),
|
|
68
|
+
msg("m4", "assistant", "d"),
|
|
69
|
+
]);
|
|
70
|
+
expect(r.ok).toBe(true);
|
|
71
|
+
if (!r.ok) return;
|
|
72
|
+
expect(r.firstKeptEntryId).toBe("m3");
|
|
73
|
+
expect(r.messages).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("single user prompt + autonomous tail: cut at mid-cycle boundary", () => {
|
|
77
|
+
// The agentic scenario: user types 1 prompt, agent runs autonomously
|
|
78
|
+
// (assistant + toolResult interleaved). No user > idx 0.
|
|
79
|
+
// Instead of compact-all, find a completed tool-cycle boundary.
|
|
80
|
+
const r = buildOwnCut([
|
|
81
|
+
msg("m1", "user", "go"),
|
|
82
|
+
msg("m2", "assistant", "calling tool"),
|
|
83
|
+
msg("m3", "toolResult", "result"),
|
|
84
|
+
msg("m4", "assistant", "more"),
|
|
85
|
+
msg("m5", "toolResult", "result2"),
|
|
86
|
+
msg("m6", "assistant", "done"),
|
|
87
|
+
]);
|
|
88
|
+
expect(r.ok).toBe(true);
|
|
89
|
+
if (!r.ok) return;
|
|
90
|
+
expect(r.compactAll).toBe(false);
|
|
91
|
+
// Should cut after the first completed cycle (m2→m3) or the second (m4→m5)
|
|
92
|
+
// Mid-cycle logic picks nearest to midpoint of 6 = index 3
|
|
93
|
+
// Cycle 1 ends at m3 (idx 3), cycle 2 ends at m5 (idx 5)
|
|
94
|
+
// Distance: |3-3|=0 vs |5-3|=2, so picks m3
|
|
95
|
+
expect(r.firstKeptEntryId).toBe("m4");
|
|
96
|
+
expect(r.messages).toHaveLength(3); // m1, m2, m3
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("no user message: compact-all instead of cancelling", () => {
|
|
100
|
+
// When there are enough live messages but none are from the user
|
|
101
|
+
// (e.g., long assistant/tool chain), compact all rather than
|
|
102
|
+
// cancelling and leaving the session unrecoverable.
|
|
103
|
+
const r = buildOwnCut([
|
|
104
|
+
msg("m1", "assistant", "a"),
|
|
105
|
+
msg("m2", "assistant", "b"),
|
|
106
|
+
msg("m3", "assistant", "c"),
|
|
107
|
+
]);
|
|
108
|
+
expect(r.ok).toBe(true);
|
|
109
|
+
if (!r.ok) return;
|
|
110
|
+
expect(r.compactAll).toBe(true);
|
|
111
|
+
expect(r.firstKeptEntryId).toBe("");
|
|
112
|
+
expect(r.messages).toHaveLength(3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("compact-all then more chat: orphan recovery + normal cut", () => {
|
|
116
|
+
// After a compact-all (firstKeptEntryId=""), user chats more turns,
|
|
117
|
+
// next compaction should orphan-recover and find multiple users.
|
|
118
|
+
const r = buildOwnCut([
|
|
119
|
+
msg("o1", "user", "old"),
|
|
120
|
+
msg("o2", "assistant", "old"),
|
|
121
|
+
comp("c1", ""), // sentinel from prior compact-all
|
|
122
|
+
msg("u1", "user", "new1"),
|
|
123
|
+
msg("a1", "assistant", "reply1"),
|
|
124
|
+
msg("u2", "user", "new2"),
|
|
125
|
+
msg("a2", "assistant", "reply2"),
|
|
126
|
+
msg("u3", "user", "new3"),
|
|
127
|
+
msg("a3", "assistant", "reply3"),
|
|
128
|
+
]);
|
|
129
|
+
expect(r.ok).toBe(true);
|
|
130
|
+
if (!r.ok) return;
|
|
131
|
+
expect(r.compactAll).toBe(false);
|
|
132
|
+
expect(r.firstKeptEntryId).toBe("u3");
|
|
133
|
+
expect(r.messages).toHaveLength(4); // u1, a1, u2, a2
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("compact-all then single user msg + autonomous: cut at mid-cycle boundary", () => {
|
|
137
|
+
// After compact-all, only 1 user message in orphan range. Should find
|
|
138
|
+
// a completed tool-cycle boundary instead of compacting all again.
|
|
139
|
+
const r = buildOwnCut([
|
|
140
|
+
msg("o1", "user", "old"),
|
|
141
|
+
comp("c1", ""),
|
|
142
|
+
msg("u1", "user", "okay"),
|
|
143
|
+
msg("a1", "assistant", "x"),
|
|
144
|
+
msg("t1", "toolResult", "y"),
|
|
145
|
+
msg("a2", "assistant", "z"),
|
|
146
|
+
]);
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
if (!r.ok) return;
|
|
149
|
+
// Single user at idx 0 of live range → mid-cycle boundary
|
|
150
|
+
// Live messages: [u1, a1, t1, a2]. Midpoint=2. Completed cycle ends at t1 (idx 2).
|
|
151
|
+
expect(r.compactAll).toBe(false);
|
|
152
|
+
expect(r.firstKeptEntryId).toBe("a2");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("matched tool calls do NOT push cut back to previous user", () => {
|
|
156
|
+
// Regression: toolResult messages carry toolCallId at the message level,
|
|
157
|
+
// not as a content part. The old code looked for part.type==="toolResult"
|
|
158
|
+
// in content (which never matches), causing every toolCall to appear
|
|
159
|
+
// "unmatched" and pushing the cut back one user turn.
|
|
160
|
+
const r = buildOwnCut([
|
|
161
|
+
msg("u1", "user", "first prompt"),
|
|
162
|
+
msg("a1", "assistant", [{ type: "text", text: "let me check" }, { type: "toolCall", id: "tc_1", name: "read", arguments: { path: "foo.ts" } }]),
|
|
163
|
+
{ id: "t1", type: "message", message: { role: "toolResult", toolCallId: "tc_1", toolName: "read", content: [{ type: "text", text: "file contents" }], isError: false } },
|
|
164
|
+
msg("u2", "user", "second prompt"),
|
|
165
|
+
msg("a2", "assistant", [{ type: "text", text: "done" }]),
|
|
166
|
+
]);
|
|
167
|
+
expect(r.ok).toBe(true);
|
|
168
|
+
if (!r.ok) return;
|
|
169
|
+
// Cut should be at u2 (second user) since tc_1 has a matching toolResult
|
|
170
|
+
expect(r.compactAll).toBe(false);
|
|
171
|
+
expect(r.firstKeptEntryId).toBe("u2");
|
|
172
|
+
expect(r.messages).toHaveLength(3); // u1, a1, t1
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("unmatched tool call still pushes cut back", () => {
|
|
176
|
+
// When a toolCall genuinely has no toolResult, the cut should still
|
|
177
|
+
// push back to keep the in-progress turn in the tail.
|
|
178
|
+
const r = buildOwnCut([
|
|
179
|
+
msg("u1", "user", "first prompt"),
|
|
180
|
+
msg("a1", "assistant", "response 1"),
|
|
181
|
+
msg("u2", "user", "second prompt"),
|
|
182
|
+
msg("a2", "assistant", "response 2"),
|
|
183
|
+
msg("u3", "user", "third prompt"),
|
|
184
|
+
msg("a3", "assistant", [{ type: "text", text: "checking" }, { type: "toolCall", id: "tc_unmatched", name: "bash", arguments: { command: "ls" } }]),
|
|
185
|
+
]);
|
|
186
|
+
expect(r.ok).toBe(true);
|
|
187
|
+
if (!r.ok) return;
|
|
188
|
+
// tc_unmatched has no toolResult → push cut back from u3 to u2
|
|
189
|
+
expect(r.compactAll).toBe(false);
|
|
190
|
+
expect(r.firstKeptEntryId).toBe("u2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("Anthropic-style session: many matched toolCalls should not cause compact-all", () => {
|
|
194
|
+
// Simulates the real Anthropic pattern: 2 user messages with many matched
|
|
195
|
+
// toolCall/toolResult pairs between them. The bug caused all toolCalls to
|
|
196
|
+
// appear "unmatched" → cut pushed back to first user → compact-all.
|
|
197
|
+
const entries: any[] = [
|
|
198
|
+
msg("u1", "user", "help me find bugs"),
|
|
199
|
+
];
|
|
200
|
+
// Add 5 matched tool cycles (assistant with toolCall + toolResult)
|
|
201
|
+
for (let i = 1; i <= 5; i++) {
|
|
202
|
+
entries.push({ id: `a${i}`, type: "message", message: { role: "assistant", content: [
|
|
203
|
+
{ type: "thinking", thinking: `thinking ${i}` },
|
|
204
|
+
{ type: "toolCall", id: `tc_${i}`, name: "read", arguments: { path: `file${i}.ts` } },
|
|
205
|
+
] } });
|
|
206
|
+
entries.push({ id: `t${i}`, type: "message", message: { role: "toolResult", toolCallId: `tc_${i}`, toolName: "read", content: [{ type: "text", text: `file ${i} contents` }], isError: false } });
|
|
207
|
+
}
|
|
208
|
+
// Second user message
|
|
209
|
+
entries.push(msg("u2", "user", "now fix the bug"));
|
|
210
|
+
// More matched tool cycles after second user
|
|
211
|
+
for (let i = 6; i <= 8; i++) {
|
|
212
|
+
entries.push({ id: `a${i}`, type: "message", message: { role: "assistant", content: [
|
|
213
|
+
{ type: "toolCall", id: `tc_${i}`, name: "edit", arguments: { path: `file${i}.ts` } },
|
|
214
|
+
] } });
|
|
215
|
+
entries.push({ id: `t${i}`, type: "message", message: { role: "toolResult", toolCallId: `tc_${i}`, toolName: "edit", content: [{ type: "text", text: `edited file ${i}` }], isError: false } });
|
|
216
|
+
}
|
|
217
|
+
entries.push(msg("a9", "assistant", "all done"));
|
|
218
|
+
|
|
219
|
+
const r = buildOwnCut(entries);
|
|
220
|
+
expect(r.ok).toBe(true);
|
|
221
|
+
if (!r.ok) return;
|
|
222
|
+
// Cut should be at u2 — all toolCalls after u1 have matching toolResults
|
|
223
|
+
// and all toolCalls after u2 also have matching toolResults
|
|
224
|
+
expect(r.compactAll).toBe(false);
|
|
225
|
+
expect(r.firstKeptEntryId).toBe("u2");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { compileBrief } from "../src/core/brief";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("compileBrief", () => {
|
|
6
|
+
it("returns empty string for no blocks", () => {
|
|
7
|
+
expect(compileBrief([])).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("renders user and assistant text", () => {
|
|
11
|
+
const blocks: NormalizedBlock[] = [
|
|
12
|
+
{ kind: "user", text: "fix auth bug" },
|
|
13
|
+
{ kind: "assistant", text: "Let me look at the auth module." },
|
|
14
|
+
];
|
|
15
|
+
const r = compileBrief(blocks);
|
|
16
|
+
expect(r).toContain("[user]");
|
|
17
|
+
expect(r).toContain("fix auth bug");
|
|
18
|
+
expect(r).toContain("[assistant]");
|
|
19
|
+
expect(r).toContain("Let me look at the auth module.");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders bash commands as user actions", () => {
|
|
23
|
+
const blocks: NormalizedBlock[] = [
|
|
24
|
+
{ kind: "bash", command: "npm test", output: "FAIL noisy output", exitCode: 1, sourceIndex: 2 },
|
|
25
|
+
];
|
|
26
|
+
const r = compileBrief(blocks);
|
|
27
|
+
expect(r).toContain("[user]\n$ npm test (#2)");
|
|
28
|
+
expect(r).not.toContain("FAIL noisy output");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips filler prefixes but preserves meaningful lead-ins", () => {
|
|
32
|
+
const blocks: NormalizedBlock[] = [
|
|
33
|
+
{ kind: "assistant", text: "Okay, I found the root cause." },
|
|
34
|
+
{ kind: "assistant", text: "Actually, the issue is in middleware." },
|
|
35
|
+
{ kind: "assistant", text: "Let me check the logs." },
|
|
36
|
+
];
|
|
37
|
+
const r = compileBrief(blocks);
|
|
38
|
+
expect(r).toContain("I found the root cause.");
|
|
39
|
+
expect(r).toContain("the issue is in middleware.");
|
|
40
|
+
expect(r).toContain("Let me check the logs.");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("collapses tool calls to one-liners under [assistant]", () => {
|
|
44
|
+
const blocks: NormalizedBlock[] = [
|
|
45
|
+
{ kind: "assistant", text: "Let me check." },
|
|
46
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "auth.ts" } },
|
|
47
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts" } },
|
|
48
|
+
];
|
|
49
|
+
const r = compileBrief(blocks);
|
|
50
|
+
expect(r).toContain('* Read "auth.ts"');
|
|
51
|
+
expect(r).toContain('* Edit "auth.ts"');
|
|
52
|
+
// Should merge into single [assistant] section
|
|
53
|
+
const matches = r.match(/\[assistant\]/g);
|
|
54
|
+
expect(matches?.length).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("hides non-error tool results", () => {
|
|
58
|
+
const blocks: NormalizedBlock[] = [
|
|
59
|
+
{ kind: "tool_result", name: "Read", text: "const x = 1;\nconst y = 2;\n// lots of code", isError: false },
|
|
60
|
+
];
|
|
61
|
+
const r = compileBrief(blocks);
|
|
62
|
+
expect(r).toBe("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("shows tool errors with first line", () => {
|
|
66
|
+
const blocks: NormalizedBlock[] = [
|
|
67
|
+
{ kind: "tool_result", name: "bash", text: "FAIL auth.test.ts\nexpected 200 got 401", isError: true },
|
|
68
|
+
];
|
|
69
|
+
const r = compileBrief(blocks);
|
|
70
|
+
expect(r).toContain("[tool_error] bash");
|
|
71
|
+
expect(r).toContain("FAIL auth.test.ts");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("hides thinking blocks", () => {
|
|
75
|
+
const blocks: NormalizedBlock[] = [
|
|
76
|
+
{ kind: "thinking", text: "Let me think about this...", redacted: false },
|
|
77
|
+
{ kind: "assistant", text: "Here's what I found." },
|
|
78
|
+
];
|
|
79
|
+
const r = compileBrief(blocks);
|
|
80
|
+
expect(r).not.toContain("think");
|
|
81
|
+
expect(r).toContain("Here's what I found.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("merges adjacent assistant sections", () => {
|
|
85
|
+
const blocks: NormalizedBlock[] = [
|
|
86
|
+
{ kind: "assistant", text: "First part." },
|
|
87
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "a.ts" } },
|
|
88
|
+
// No user/tool_result between these — should merge
|
|
89
|
+
{ kind: "assistant", text: "Second part." },
|
|
90
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "b.ts" } },
|
|
91
|
+
];
|
|
92
|
+
const r = compileBrief(blocks);
|
|
93
|
+
const matches = r.match(/\[assistant\]/g);
|
|
94
|
+
expect(matches?.length).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does NOT merge assistant after user", () => {
|
|
98
|
+
const blocks: NormalizedBlock[] = [
|
|
99
|
+
{ kind: "assistant", text: "First." },
|
|
100
|
+
{ kind: "user", text: "Next task." },
|
|
101
|
+
{ kind: "assistant", text: "Second." },
|
|
102
|
+
];
|
|
103
|
+
const r = compileBrief(blocks);
|
|
104
|
+
const matches = r.match(/\[assistant\]/g);
|
|
105
|
+
expect(matches?.length).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("truncates long user text", () => {
|
|
109
|
+
const longText = Array.from({ length: 300 }, (_, i) => `word${i}`).join(" ");
|
|
110
|
+
const blocks: NormalizedBlock[] = [
|
|
111
|
+
{ kind: "user", text: longText },
|
|
112
|
+
];
|
|
113
|
+
const r = compileBrief(blocks);
|
|
114
|
+
expect(r).toContain("(truncated)");
|
|
115
|
+
expect(r).not.toContain("word299");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("truncates long assistant text", () => {
|
|
119
|
+
const longText = Array.from({ length: 300 }, (_, i) => `word${i}`).join(" ");
|
|
120
|
+
const blocks: NormalizedBlock[] = [
|
|
121
|
+
{ kind: "assistant", text: longText },
|
|
122
|
+
];
|
|
123
|
+
const r = compileBrief(blocks);
|
|
124
|
+
expect(r).toContain("(truncated)");
|
|
125
|
+
expect(r).not.toContain("word299");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renders a realistic conversation flow", () => {
|
|
129
|
+
const blocks: NormalizedBlock[] = [
|
|
130
|
+
{ kind: "user", text: "fix the login bug" },
|
|
131
|
+
{ kind: "thinking", text: "I need to check...", redacted: false },
|
|
132
|
+
{ kind: "assistant", text: "Let me investigate." },
|
|
133
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "login.ts" } },
|
|
134
|
+
{ kind: "tool_result", name: "Read", text: "export function login() { ... }", isError: false },
|
|
135
|
+
{ kind: "tool_call", name: "bash", args: { command: "npm test" } },
|
|
136
|
+
{ kind: "tool_result", name: "bash", text: "FAIL: login test\nExpected true, got false", isError: true },
|
|
137
|
+
{ kind: "assistant", text: "The test is failing because..." },
|
|
138
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "login.ts" } },
|
|
139
|
+
{ kind: "tool_result", name: "Edit", text: "File edited successfully", isError: false },
|
|
140
|
+
{ kind: "user", text: "test lại đi" },
|
|
141
|
+
{ kind: "assistant", text: "Running tests again." },
|
|
142
|
+
{ kind: "tool_call", name: "bash", args: { command: "npm test" } },
|
|
143
|
+
{ kind: "tool_result", name: "bash", text: "All tests passed", isError: false },
|
|
144
|
+
];
|
|
145
|
+
const r = compileBrief(blocks);
|
|
146
|
+
|
|
147
|
+
// Check structure
|
|
148
|
+
expect(r).toContain("[user]\nfix the login bug");
|
|
149
|
+
expect(r).toContain('[assistant]\nLet me investigate.\n* Read "login.ts"');
|
|
150
|
+
expect(r).toContain("[tool_error] bash\nFAIL: login test");
|
|
151
|
+
expect(r).toContain('[assistant]\nThe test is failing because...\n* Edit "login.ts"');
|
|
152
|
+
expect(r).toContain("[user]\ntest lại đi");
|
|
153
|
+
expect(r).toContain('[assistant]\nRunning tests again.\n* bash "npm test"');
|
|
154
|
+
|
|
155
|
+
// Hidden content
|
|
156
|
+
expect(r).not.toContain("think");
|
|
157
|
+
expect(r).not.toContain("export function login");
|
|
158
|
+
expect(r).not.toContain("File edited successfully");
|
|
159
|
+
expect(r).not.toContain("All tests passed");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── noise filtering tests (aligned with VCC) ──
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
it("suppresses blank lines between consecutive tool-only sections", () => {
|
|
173
|
+
const blocks: NormalizedBlock[] = [
|
|
174
|
+
{ kind: "assistant", text: "Checking files." },
|
|
175
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "a.ts" } },
|
|
176
|
+
{ kind: "tool_result", name: "Read", text: "...", isError: false },
|
|
177
|
+
// tool_result hidden → next tool_call starts new assistant section
|
|
178
|
+
// but since both are tool-only, no blank line between
|
|
179
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "b.ts" } },
|
|
180
|
+
{ kind: "tool_result", name: "Read", text: "...", isError: false },
|
|
181
|
+
];
|
|
182
|
+
const r = compileBrief(blocks);
|
|
183
|
+
// The first assistant section has text + tool, so it's NOT tool-only
|
|
184
|
+
// The second would be tool-only but merges into the first (adjacent assistant)
|
|
185
|
+
// So all under one [assistant]
|
|
186
|
+
expect(r.match(/\[assistant\]/g)?.length).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("caps tool calls per [assistant] turn at 8 (keep tail)", () => {
|
|
190
|
+
const blocks: NormalizedBlock[] = [
|
|
191
|
+
{ kind: "assistant", text: "Working." },
|
|
192
|
+
];
|
|
193
|
+
for (let i = 1; i <= 12; i++) {
|
|
194
|
+
blocks.push({ kind: "tool_call", name: "bash", args: { command: `echo ${i}` } });
|
|
195
|
+
}
|
|
196
|
+
const r = compileBrief(blocks);
|
|
197
|
+
expect(r).toContain("(4 earlier tool-call entries omitted)");
|
|
198
|
+
// Last 8 (5..12) kept; first 4 dropped
|
|
199
|
+
expect(r).not.toContain("echo 1\"");
|
|
200
|
+
expect(r).not.toContain("echo 4\"");
|
|
201
|
+
expect(r).toContain("echo 5");
|
|
202
|
+
expect(r).toContain("echo 12");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not cap when tool calls per turn <= 8", () => {
|
|
206
|
+
const blocks: NormalizedBlock[] = [{ kind: "assistant", text: "ok" }];
|
|
207
|
+
for (let i = 1; i <= 8; i++) {
|
|
208
|
+
blocks.push({ kind: "tool_call", name: "bash", args: { command: `c${i}` } });
|
|
209
|
+
}
|
|
210
|
+
const r = compileBrief(blocks);
|
|
211
|
+
expect(r).not.toContain("entries omitted");
|
|
212
|
+
expect(r).toContain("c1");
|
|
213
|
+
expect(r).toContain("c8");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { buildSections } from "../src/core/build-sections";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("buildSections", () => {
|
|
6
|
+
it("returns all-empty for no blocks", () => {
|
|
7
|
+
const r = buildSections({ blocks: [] });
|
|
8
|
+
expect(r.sessionGoal).toEqual([]);
|
|
9
|
+
expect(r.outstandingContext).toEqual([]);
|
|
10
|
+
expect(r.briefTranscript).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("populates sections from realistic blocks", () => {
|
|
14
|
+
const blocks: NormalizedBlock[] = [
|
|
15
|
+
{ kind: "user", text: "Fix the auth bug" },
|
|
16
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "auth.ts" } },
|
|
17
|
+
{ kind: "tool_result", name: "Read", text: "const x = 1;", isError: false },
|
|
18
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts" } },
|
|
19
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
20
|
+
{ kind: "assistant", text: "- run tests next" },
|
|
21
|
+
];
|
|
22
|
+
const r = buildSections({ blocks });
|
|
23
|
+
expect(r.sessionGoal).toContain("Fix the auth bug");
|
|
24
|
+
expect(r.briefTranscript).toContain('[user]');
|
|
25
|
+
expect(r.briefTranscript).toContain('* Read "auth.ts"');
|
|
26
|
+
expect(r.briefTranscript).toContain('* Edit "auth.ts"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("captures outstanding context from errors", () => {
|
|
30
|
+
const blocks: NormalizedBlock[] = [
|
|
31
|
+
{ kind: "tool_result", name: "bash", text: "FAIL: test broken\ndetails here", isError: true },
|
|
32
|
+
];
|
|
33
|
+
const r = buildSections({ blocks });
|
|
34
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
35
|
+
expect(r.outstandingContext[0]).toContain("FAIL");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("brief transcript hides tool results but shows errors", () => {
|
|
39
|
+
const blocks: NormalizedBlock[] = [
|
|
40
|
+
{ kind: "tool_result", name: "Read", text: "lots of code here ...", isError: false },
|
|
41
|
+
{ kind: "tool_result", name: "bash", text: "Command not found", isError: true },
|
|
42
|
+
];
|
|
43
|
+
const r = buildSections({ blocks });
|
|
44
|
+
expect(r.briefTranscript).not.toContain("lots of code");
|
|
45
|
+
expect(r.briefTranscript).toContain("[tool_error] bash");
|
|
46
|
+
expect(r.briefTranscript).toContain("Command not found");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("brief transcript merges adjacent assistant sections", () => {
|
|
50
|
+
const blocks: NormalizedBlock[] = [
|
|
51
|
+
{ kind: "assistant", text: "Part one." },
|
|
52
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "a.ts" } },
|
|
53
|
+
{ kind: "assistant", text: "Part two." },
|
|
54
|
+
];
|
|
55
|
+
const r = buildSections({ blocks });
|
|
56
|
+
const matches = r.briefTranscript.match(/\[assistant\]/g);
|
|
57
|
+
expect(matches?.length).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { compile } from "../src/core/summarize";
|
|
3
|
+
import {
|
|
4
|
+
userMsg,
|
|
5
|
+
assistantText,
|
|
6
|
+
assistantWithToolCall,
|
|
7
|
+
toolResult,
|
|
8
|
+
} from "./fixtures";
|
|
9
|
+
|
|
10
|
+
describe("compile", () => {
|
|
11
|
+
it("returns empty string for no messages", () => {
|
|
12
|
+
expect(compile({ messages: [] })).toBe("");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("produces hybrid output with header + brief transcript", () => {
|
|
16
|
+
const r = compile({
|
|
17
|
+
messages: [
|
|
18
|
+
userMsg("Fix login bug"),
|
|
19
|
+
assistantWithToolCall("Read", { path: "auth.ts" }),
|
|
20
|
+
assistantText("Found the issue.\n1. Fix validation"),
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
expect(r).toContain("[Session Goal]");
|
|
24
|
+
expect(r).toContain("Fix login bug");
|
|
25
|
+
expect(r).toContain("---");
|
|
26
|
+
expect(r).toContain("[user]\nFix login bug");
|
|
27
|
+
expect(r).toContain('* Read "auth.ts"');
|
|
28
|
+
expect(r).toContain("Found the issue.");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("merges previous summary goals", () => {
|
|
32
|
+
const r = compile({
|
|
33
|
+
messages: [userMsg("New task")],
|
|
34
|
+
previousSummary: "[Session Goal]\n- Original goal\n\n---\n\n[user]\nOriginal goal",
|
|
35
|
+
});
|
|
36
|
+
expect(r).toContain("- Original goal");
|
|
37
|
+
expect(r).toContain("- New task");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("appends brief transcript on merge", () => {
|
|
41
|
+
const previousSummary = [
|
|
42
|
+
"[Session Goal]\n- Original goal",
|
|
43
|
+
"---",
|
|
44
|
+
"[user]\nOriginal goal\n\n[assistant]\n* Read \"old.ts\"",
|
|
45
|
+
].join("\n\n");
|
|
46
|
+
const r = compile({
|
|
47
|
+
previousSummary,
|
|
48
|
+
messages: [
|
|
49
|
+
userMsg("Next step"),
|
|
50
|
+
assistantWithToolCall("Read", { path: "new.ts" }),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
expect(r).toContain('* Read "old.ts"');
|
|
54
|
+
expect(r).toContain('* Read "new.ts"');
|
|
55
|
+
expect(r).toContain("Next step");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("outstanding context is volatile (fresh only)", () => {
|
|
59
|
+
const previousSummary = "[Outstanding Context]\n- old blocker\n\n---\n\n[user]\nhi";
|
|
60
|
+
const r = compile({
|
|
61
|
+
previousSummary,
|
|
62
|
+
messages: [userMsg("continue")],
|
|
63
|
+
});
|
|
64
|
+
expect(r).not.toContain("old blocker");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("caps long brief transcript with rolling window", () => {
|
|
68
|
+
// Build a very long previous transcript
|
|
69
|
+
const longTranscript = Array.from({ length: 200 }, (_, i) =>
|
|
70
|
+
`[user]\nmessage ${i}`
|
|
71
|
+
).join("\n\n");
|
|
72
|
+
const previousSummary = `[Session Goal]\n- goal\n\n---\n\n${longTranscript}`;
|
|
73
|
+
const r = compile({
|
|
74
|
+
previousSummary,
|
|
75
|
+
messages: [userMsg("latest")],
|
|
76
|
+
});
|
|
77
|
+
expect(r).toContain("earlier lines omitted");
|
|
78
|
+
expect(r).toContain("latest");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("wraps final output including recall note", () => {
|
|
82
|
+
const r = compile({
|
|
83
|
+
messages: [userMsg("check final summary wrapping")],
|
|
84
|
+
});
|
|
85
|
+
const maxLineLength = Math.max(...r.split("\n").map((line) => line.length));
|
|
86
|
+
expect(r).toContain("vcc_recall");
|
|
87
|
+
expect(maxLineLength).toBeLessThanOrEqual(120);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { textOf, thinkingOf, clip, firstLine } from "../src/core/content";
|
|
3
|
+
|
|
4
|
+
describe("textOf", () => {
|
|
5
|
+
it("returns empty string for undefined content", () => {
|
|
6
|
+
expect(textOf(undefined as any)).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns empty string for null content", () => {
|
|
10
|
+
expect(textOf(null as any)).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns string content as-is", () => {
|
|
14
|
+
expect(textOf("hello")).toBe("hello");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts text parts from array content", () => {
|
|
18
|
+
const content = [
|
|
19
|
+
{ type: "text" as const, text: "first" },
|
|
20
|
+
{ type: "toolCall" as const, name: "x", id: "1", arguments: {} },
|
|
21
|
+
{ type: "text" as const, text: "second" },
|
|
22
|
+
];
|
|
23
|
+
expect(textOf(content)).toBe("first\nsecond");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("ignores thinking parts", () => {
|
|
27
|
+
const content = [
|
|
28
|
+
{ type: "thinking" as const, thinking: "let me think" },
|
|
29
|
+
{ type: "text" as const, text: "here is the answer" },
|
|
30
|
+
];
|
|
31
|
+
expect(textOf(content)).toBe("here is the answer");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("thinkingOf", () => {
|
|
36
|
+
it("returns empty string for undefined content", () => {
|
|
37
|
+
expect(thinkingOf(undefined as any)).toBe("");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty string for string content", () => {
|
|
41
|
+
expect(thinkingOf("hello")).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("extracts thinking parts from array content", () => {
|
|
45
|
+
const content = [
|
|
46
|
+
{ type: "thinking" as const, thinking: "let me think" },
|
|
47
|
+
{ type: "text" as const, text: "here is the answer" },
|
|
48
|
+
];
|
|
49
|
+
expect(thinkingOf(content)).toBe("let me think");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("joins multiple thinking parts", () => {
|
|
53
|
+
const content = [
|
|
54
|
+
{ type: "thinking" as const, thinking: "first thought" },
|
|
55
|
+
{ type: "text" as const, text: "answer" },
|
|
56
|
+
{ type: "thinking" as const, thinking: "second thought" },
|
|
57
|
+
];
|
|
58
|
+
expect(thinkingOf(content)).toBe("first thought\nsecond thought");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("textOf", () => {
|
|
63
|
+
it("returns empty string for undefined content", () => {
|
|
64
|
+
expect(textOf(undefined as any)).toBe("");
|
|
65
|
+
});
|
|
66
|
+
});
|