ultimate-pi 0.4.0 → 0.4.1
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/.pi/PACKAGING.md +3 -2
- package/.pi/extensions/lib/harness-vcc-settings.ts +50 -0
- package/.pi/extensions/ultimate-pi-vcc.ts +17 -0
- package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +40 -0
- package/.pi/harness/docs/adrs/README.md +1 -0
- package/.pi/prompts/harness-setup.md +2 -2
- package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +8 -0
- package/.pi/scripts/vendor-sync-pi-vcc.sh +40 -0
- package/.pi/settings.example.json +1 -6
- package/CHANGELOG.md +9 -7
- package/THIRD_PARTY_NOTICES.md +8 -22
- package/package.json +7 -6
- package/vendor/pi-vcc/README.md +215 -0
- package/vendor/pi-vcc/UPSTREAM_PIN.md +12 -0
- package/vendor/pi-vcc/demo.gif +0 -0
- package/vendor/pi-vcc/index.ts +12 -0
- package/vendor/pi-vcc/package.json +26 -0
- package/vendor/pi-vcc/scripts/audit-sessions.ts +88 -0
- package/vendor/pi-vcc/scripts/benchmark-real-sessions.ts +25 -0
- package/vendor/pi-vcc/scripts/compare-before-after.ts +36 -0
- package/vendor/pi-vcc/scripts/dump-branch-output.ts +20 -0
- package/vendor/pi-vcc/src/commands/pi-vcc.ts +36 -0
- package/vendor/pi-vcc/src/commands/vcc-recall.ts +65 -0
- package/vendor/pi-vcc/src/core/brief.ts +381 -0
- package/vendor/pi-vcc/src/core/build-sections.ts +79 -0
- package/vendor/pi-vcc/src/core/content.ts +60 -0
- package/vendor/pi-vcc/src/core/filter-noise.ts +42 -0
- package/vendor/pi-vcc/src/core/format-recall.ts +27 -0
- package/vendor/pi-vcc/src/core/format.ts +49 -0
- package/vendor/pi-vcc/src/core/lineage.ts +26 -0
- package/vendor/pi-vcc/src/core/load-messages.ts +41 -0
- package/vendor/pi-vcc/src/core/normalize.ts +66 -0
- package/vendor/pi-vcc/src/core/recall-scope.ts +14 -0
- package/vendor/pi-vcc/src/core/render-entries.ts +55 -0
- package/vendor/pi-vcc/src/core/report.ts +237 -0
- package/vendor/pi-vcc/src/core/sanitize.ts +5 -0
- package/vendor/pi-vcc/src/core/search-entries.ts +221 -0
- package/vendor/pi-vcc/src/core/settings.ts +8 -0
- package/vendor/pi-vcc/src/core/skill-collapse.ts +35 -0
- package/vendor/pi-vcc/src/core/summarize.ts +157 -0
- package/vendor/pi-vcc/src/core/tool-args.ts +14 -0
- package/vendor/pi-vcc/src/details.ts +7 -0
- package/vendor/pi-vcc/src/extract/commits.ts +69 -0
- package/vendor/pi-vcc/src/extract/files.ts +80 -0
- package/vendor/pi-vcc/src/extract/goals.ts +79 -0
- package/vendor/pi-vcc/src/extract/preferences.ts +55 -0
- package/vendor/pi-vcc/src/hooks/before-compact.ts +314 -0
- package/vendor/pi-vcc/src/sections.ts +12 -0
- package/vendor/pi-vcc/src/tools/recall.ts +109 -0
- package/vendor/pi-vcc/src/types.ts +14 -0
- package/vendor/pi-vcc/tests/before-compact-hook.test.ts +204 -0
- package/vendor/pi-vcc/tests/before-compact.test.ts +145 -0
- package/vendor/pi-vcc/tests/brief.test.ts +206 -0
- package/vendor/pi-vcc/tests/build-sections.test.ts +59 -0
- package/vendor/pi-vcc/tests/compile.test.ts +80 -0
- package/vendor/pi-vcc/tests/content.test.ts +31 -0
- package/vendor/pi-vcc/tests/extract-goals.test.ts +86 -0
- package/vendor/pi-vcc/tests/extract-preferences.test.ts +30 -0
- package/vendor/pi-vcc/tests/filter-noise.test.ts +61 -0
- package/vendor/pi-vcc/tests/fixtures.ts +61 -0
- package/vendor/pi-vcc/tests/format-recall.test.ts +30 -0
- package/vendor/pi-vcc/tests/format.test.ts +62 -0
- package/vendor/pi-vcc/tests/lineage.test.ts +33 -0
- package/vendor/pi-vcc/tests/load-messages.test.ts +51 -0
- package/vendor/pi-vcc/tests/normalize.test.ts +97 -0
- package/vendor/pi-vcc/tests/real-sessions.test.ts +38 -0
- package/vendor/pi-vcc/tests/recall-expand.test.ts +15 -0
- package/vendor/pi-vcc/tests/recall-scope.test.ts +32 -0
- package/vendor/pi-vcc/tests/recall-tool-scope.test.ts +67 -0
- package/vendor/pi-vcc/tests/render-entries.test.ts +62 -0
- package/vendor/pi-vcc/tests/report.test.ts +44 -0
- package/vendor/pi-vcc/tests/sanitize.test.ts +24 -0
- package/vendor/pi-vcc/tests/search-entries.test.ts +144 -0
- package/vendor/pi-vcc/tests/support/load-session.ts +23 -0
- package/vendor/pi-vcc/tests/support/real-sessions.ts +51 -0
- package/.pi/pi-vcc-config.json +0 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { loadAllMessages } from "../core/load-messages";
|
|
4
|
+
import { searchEntries } from "../core/search-entries";
|
|
5
|
+
import { formatRecallOutput } from "../core/format-recall";
|
|
6
|
+
import { getActiveLineageEntryIds } from "../core/lineage";
|
|
7
|
+
import { normalizeRecallScope } from "../core/recall-scope";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RECENT = 25;
|
|
10
|
+
const PAGE_SIZE = 5;
|
|
11
|
+
|
|
12
|
+
export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
|
|
13
|
+
requested.filter((i) => !Number.isInteger(i) || !available.has(i));
|
|
14
|
+
|
|
15
|
+
export const registerRecallTool = (pi: ExtensionAPI) => {
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: "vcc_recall",
|
|
18
|
+
label: "VCC Recall",
|
|
19
|
+
description:
|
|
20
|
+
"Search session history. Defaults to active lineage; use scope:'all' to include off-lineage branches." +
|
|
21
|
+
" Supports regex queries, paging, and expand indices.",
|
|
22
|
+
promptSnippet:
|
|
23
|
+
"vcc_recall: Search history; default scope is active lineage. Use scope:'all' for off-lineage branches.",
|
|
24
|
+
parameters: Type.Object({
|
|
25
|
+
query: Type.Optional(
|
|
26
|
+
Type.String({ description: "Search terms or regex pattern (e.g. 'hook|inject', 'fail.*build'). Multi-word = OR ranked by relevance." }),
|
|
27
|
+
),
|
|
28
|
+
expand: Type.Optional(
|
|
29
|
+
Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for" }),
|
|
30
|
+
),
|
|
31
|
+
page: Type.Optional(
|
|
32
|
+
Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1." }),
|
|
33
|
+
),
|
|
34
|
+
scope: Type.Optional(
|
|
35
|
+
Type.Union([
|
|
36
|
+
Type.Literal("lineage"),
|
|
37
|
+
Type.Literal("all"),
|
|
38
|
+
], { description: "Search scope. Default: lineage; all includes off-lineage branches." }),
|
|
39
|
+
),
|
|
40
|
+
}),
|
|
41
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
42
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
43
|
+
if (!sessionFile) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: "No session file available." }],
|
|
46
|
+
details: undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scope = normalizeRecallScope(params.scope);
|
|
51
|
+
const lineageEntryIds = scope === "lineage"
|
|
52
|
+
? getActiveLineageEntryIds(ctx.sessionManager)
|
|
53
|
+
: undefined;
|
|
54
|
+
const expandSet = new Set(params.expand ?? []);
|
|
55
|
+
const hasExpand = expandSet.size > 0;
|
|
56
|
+
|
|
57
|
+
if (hasExpand && !params.query) {
|
|
58
|
+
const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
|
|
59
|
+
const requested = [...expandSet];
|
|
60
|
+
const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
|
|
61
|
+
const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
|
|
62
|
+
if (invalid.length > 0) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
|
|
65
|
+
details: undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const expanded = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
|
|
70
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(expanded);
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: "text", text: output }],
|
|
73
|
+
details: undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { rendered: msgs, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
78
|
+
const allResults = params.query?.trim()
|
|
79
|
+
? searchEntries(msgs, rawMessages, params.query)
|
|
80
|
+
: msgs.slice(-DEFAULT_RECENT);
|
|
81
|
+
|
|
82
|
+
if (params.query?.trim()) {
|
|
83
|
+
const page = Math.max(1, params.page ?? 1);
|
|
84
|
+
const start = (page - 1) * PAGE_SIZE;
|
|
85
|
+
const pageResults = allResults.slice(start, start + PAGE_SIZE);
|
|
86
|
+
const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
|
|
87
|
+
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
88
|
+
const header = totalPages > 1
|
|
89
|
+
? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
|
|
90
|
+
: `${allResults.length} matches${scopeSuffix}`;
|
|
91
|
+
const footer = page < totalPages
|
|
92
|
+
? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
|
|
93
|
+
: "";
|
|
94
|
+
const output = formatRecallOutput(pageResults, params.query, header) + footer;
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: output }],
|
|
97
|
+
details: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, params.query);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: output }],
|
|
104
|
+
details: undefined,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
export interface FileOps {
|
|
4
|
+
readFiles?: string[];
|
|
5
|
+
modifiedFiles?: string[];
|
|
6
|
+
createdFiles?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type NormalizedBlock =
|
|
10
|
+
| { kind: "user"; text: string; sourceIndex?: number }
|
|
11
|
+
| { kind: "assistant"; text: string; sourceIndex?: number }
|
|
12
|
+
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
13
|
+
| { kind: "tool_result"; name: string; text: string; isError: boolean; sourceIndex?: number }
|
|
14
|
+
| { kind: "thinking"; text: string; redacted: boolean; sourceIndex?: number };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { existsSync, unlinkSync, readFileSync } from "fs";
|
|
3
|
+
import { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION } from "../src/hooks/before-compact";
|
|
4
|
+
|
|
5
|
+
const DEBUG_PATH = "/tmp/pi-vcc-debug.json";
|
|
6
|
+
|
|
7
|
+
let compactionEnvBackup: string | undefined;
|
|
8
|
+
let debugEnvBackup: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
compactionEnvBackup = process.env.HARNESS_VCC_COMPACTION;
|
|
12
|
+
debugEnvBackup = process.env.HARNESS_VCC_DEBUG;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
if (compactionEnvBackup === undefined) {
|
|
17
|
+
delete process.env.HARNESS_VCC_COMPACTION;
|
|
18
|
+
} else {
|
|
19
|
+
process.env.HARNESS_VCC_COMPACTION = compactionEnvBackup;
|
|
20
|
+
}
|
|
21
|
+
if (debugEnvBackup === undefined) {
|
|
22
|
+
delete process.env.HARNESS_VCC_DEBUG;
|
|
23
|
+
} else {
|
|
24
|
+
process.env.HARNESS_VCC_DEBUG = debugEnvBackup;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function setHarnessEnv(opts: {
|
|
29
|
+
overrideDefaultCompaction: boolean;
|
|
30
|
+
debug: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
process.env.HARNESS_VCC_COMPACTION = opts.overrideDefaultCompaction
|
|
33
|
+
? "true"
|
|
34
|
+
: "false";
|
|
35
|
+
process.env.HARNESS_VCC_DEBUG = opts.debug ? "true" : "false";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Minimal ExtensionAPI stub: capture handler + provide ctx with mocked ui.notify
|
|
39
|
+
function createMockPi() {
|
|
40
|
+
let handler: ((event: any, ctx: any) => any) | undefined;
|
|
41
|
+
const notifyCalls: Array<{ msg: string; level: string }> = [];
|
|
42
|
+
const ctx = {
|
|
43
|
+
hasUI: true,
|
|
44
|
+
ui: {
|
|
45
|
+
notify: (msg: string, level: string) => {
|
|
46
|
+
notifyCalls.push({ msg, level });
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
pi: {
|
|
52
|
+
on: (eventName: string, h: (e: any, c: any) => any) => {
|
|
53
|
+
if (eventName === "session_before_compact") handler = h;
|
|
54
|
+
},
|
|
55
|
+
} as any,
|
|
56
|
+
invoke: (event: any) => handler!(event, ctx),
|
|
57
|
+
notifyCalls,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeEvent(branchEntries: any[], customInstructions?: string) {
|
|
62
|
+
return {
|
|
63
|
+
type: "session_before_compact",
|
|
64
|
+
customInstructions,
|
|
65
|
+
branchEntries,
|
|
66
|
+
preparation: {
|
|
67
|
+
previousSummary: undefined,
|
|
68
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
69
|
+
tokensBefore: 1000,
|
|
70
|
+
},
|
|
71
|
+
signal: new AbortController().signal,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const msg = (id: string, role: "user" | "assistant" | "toolResult", content = "x") => ({
|
|
76
|
+
id,
|
|
77
|
+
type: "message",
|
|
78
|
+
message: { role, content },
|
|
79
|
+
});
|
|
80
|
+
const comp = (id: string, firstKeptEntryId?: string) => ({
|
|
81
|
+
id,
|
|
82
|
+
type: "compaction",
|
|
83
|
+
firstKeptEntryId,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("registerBeforeCompactHook: cancel paths", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("/pi-vcc with too few live messages cancels and notifies warning", () => {
|
|
95
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
96
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
97
|
+
registerBeforeCompactHook(pi);
|
|
98
|
+
|
|
99
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
100
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
101
|
+
cancel: true,
|
|
102
|
+
});
|
|
103
|
+
expect(notifyCalls).toHaveLength(1);
|
|
104
|
+
expect(notifyCalls[0].level).toBe("warning");
|
|
105
|
+
expect(notifyCalls[0].msg).toContain("Too few messages");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("/pi-vcc with no user message compacts all instead of cancelling", () => {
|
|
109
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
110
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
111
|
+
registerBeforeCompactHook(pi);
|
|
112
|
+
|
|
113
|
+
const entries = [
|
|
114
|
+
msg("m1", "assistant"),
|
|
115
|
+
msg("m2", "assistant"),
|
|
116
|
+
msg("m3", "assistant"),
|
|
117
|
+
];
|
|
118
|
+
const result = invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION));
|
|
119
|
+
expect(result.cancel).toBeUndefined();
|
|
120
|
+
expect(result.compaction).toBeDefined();
|
|
121
|
+
expect(result.compaction.firstKeptEntryId).toBe("");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("/compact with override=true cancels and notifies (NEW: was silent before)", () => {
|
|
125
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: true });
|
|
126
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
127
|
+
registerBeforeCompactHook(pi);
|
|
128
|
+
|
|
129
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
130
|
+
expect(invoke(makeEvent(entries, undefined))).toEqual({ cancel: true });
|
|
131
|
+
expect(notifyCalls).toHaveLength(1);
|
|
132
|
+
expect(notifyCalls[0].level).toBe("warning");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("/compact with override=false short-circuits (no notify, returns undefined)", () => {
|
|
136
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
137
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
138
|
+
registerBeforeCompactHook(pi);
|
|
139
|
+
|
|
140
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
141
|
+
expect(invoke(makeEvent(entries, undefined))).toBeUndefined();
|
|
142
|
+
expect(notifyCalls).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("debug:true writes metrics-only snapshot on cancel with no content leakage", () => {
|
|
146
|
+
setHarnessEnv({ debug: true, overrideDefaultCompaction: false });
|
|
147
|
+
const { pi, invoke } = createMockPi();
|
|
148
|
+
registerBeforeCompactHook(pi);
|
|
149
|
+
|
|
150
|
+
const entries = [
|
|
151
|
+
msg("m1", "user", "SECRET_TOKEN_abc123"),
|
|
152
|
+
msg("m2", "assistant", "sensitive response"),
|
|
153
|
+
];
|
|
154
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
155
|
+
cancel: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(existsSync(DEBUG_PATH)).toBe(true);
|
|
159
|
+
const snapshot = JSON.parse(readFileSync(DEBUG_PATH, "utf-8"));
|
|
160
|
+
expect(snapshot.cancelled).toBe(true);
|
|
161
|
+
expect(snapshot.reason).toBe("too_few_live_messages");
|
|
162
|
+
|
|
163
|
+
const serialized = JSON.stringify(snapshot);
|
|
164
|
+
expect(serialized).not.toContain("SECRET_TOKEN_abc123");
|
|
165
|
+
expect(serialized).not.toContain("sensitive response");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("debug:false does NOT write snapshot", () => {
|
|
169
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
170
|
+
const { pi, invoke } = createMockPi();
|
|
171
|
+
registerBeforeCompactHook(pi);
|
|
172
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
173
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
174
|
+
cancel: true,
|
|
175
|
+
});
|
|
176
|
+
expect(existsSync(DEBUG_PATH)).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("registerBeforeCompactHook: compact-all path", () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
183
|
+
});
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("single-user + autonomous tail → returns compaction with empty firstKeptEntryId", () => {
|
|
189
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
190
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
191
|
+
registerBeforeCompactHook(pi);
|
|
192
|
+
|
|
193
|
+
const entries = [
|
|
194
|
+
msg("m1", "user", "go"),
|
|
195
|
+
msg("m2", "assistant", "calling tool"),
|
|
196
|
+
msg("m3", "toolResult", "result"),
|
|
197
|
+
msg("m4", "assistant", "done"),
|
|
198
|
+
];
|
|
199
|
+
const result = invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION));
|
|
200
|
+
expect(result.compaction).toBeDefined();
|
|
201
|
+
expect(result.compaction.firstKeptEntryId).toBe("");
|
|
202
|
+
expect(notifyCalls).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
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: compact all", () => {
|
|
77
|
+
// The Discord scenario: user types 1 prompt, agent runs autonomously
|
|
78
|
+
// (assistant + toolResult interleaved). No user > idx 0.
|
|
79
|
+
const r = buildOwnCut([
|
|
80
|
+
msg("m1", "user", "go"),
|
|
81
|
+
msg("m2", "assistant", "calling tool"),
|
|
82
|
+
msg("m3", "toolResult", "result"),
|
|
83
|
+
msg("m4", "assistant", "more"),
|
|
84
|
+
msg("m5", "toolResult", "result2"),
|
|
85
|
+
msg("m6", "assistant", "done"),
|
|
86
|
+
]);
|
|
87
|
+
expect(r.ok).toBe(true);
|
|
88
|
+
if (!r.ok) return;
|
|
89
|
+
expect(r.compactAll).toBe(true);
|
|
90
|
+
expect(r.firstKeptEntryId).toBe("");
|
|
91
|
+
expect(r.messages).toHaveLength(6);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("no user message: compact-all instead of cancelling", () => {
|
|
95
|
+
// When there are enough live messages but none are from the user
|
|
96
|
+
// (e.g., long assistant/tool chain), compact all rather than
|
|
97
|
+
// cancelling and leaving the session unrecoverable.
|
|
98
|
+
const r = buildOwnCut([
|
|
99
|
+
msg("m1", "assistant", "a"),
|
|
100
|
+
msg("m2", "assistant", "b"),
|
|
101
|
+
msg("m3", "assistant", "c"),
|
|
102
|
+
]);
|
|
103
|
+
expect(r.ok).toBe(true);
|
|
104
|
+
if (!r.ok) return;
|
|
105
|
+
expect(r.compactAll).toBe(true);
|
|
106
|
+
expect(r.firstKeptEntryId).toBe("");
|
|
107
|
+
expect(r.messages).toHaveLength(3);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("compact-all then more chat: orphan recovery + normal cut", () => {
|
|
111
|
+
// After a compact-all (firstKeptEntryId=""), user chats more turns,
|
|
112
|
+
// next compaction should orphan-recover and find multiple users.
|
|
113
|
+
const r = buildOwnCut([
|
|
114
|
+
msg("o1", "user", "old"),
|
|
115
|
+
msg("o2", "assistant", "old"),
|
|
116
|
+
comp("c1", ""), // sentinel from prior compact-all
|
|
117
|
+
msg("u1", "user", "new1"),
|
|
118
|
+
msg("a1", "assistant", "reply1"),
|
|
119
|
+
msg("u2", "user", "new2"),
|
|
120
|
+
msg("a2", "assistant", "reply2"),
|
|
121
|
+
msg("u3", "user", "new3"),
|
|
122
|
+
msg("a3", "assistant", "reply3"),
|
|
123
|
+
]);
|
|
124
|
+
expect(r.ok).toBe(true);
|
|
125
|
+
if (!r.ok) return;
|
|
126
|
+
expect(r.compactAll).toBe(false);
|
|
127
|
+
expect(r.firstKeptEntryId).toBe("u3");
|
|
128
|
+
expect(r.messages).toHaveLength(4); // u1, a1, u2, a2
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("compact-all then single user msg + autonomous: compact all again", () => {
|
|
132
|
+
const r = buildOwnCut([
|
|
133
|
+
msg("o1", "user", "old"),
|
|
134
|
+
comp("c1", ""),
|
|
135
|
+
msg("u1", "user", "okay"),
|
|
136
|
+
msg("a1", "assistant", "x"),
|
|
137
|
+
msg("t1", "toolResult", "y"),
|
|
138
|
+
msg("a2", "assistant", "z"),
|
|
139
|
+
]);
|
|
140
|
+
expect(r.ok).toBe(true);
|
|
141
|
+
if (!r.ok) return;
|
|
142
|
+
expect(r.compactAll).toBe(true);
|
|
143
|
+
expect(r.firstKeptEntryId).toBe("");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
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("strips filler prefixes but preserves meaningful lead-ins", () => {
|
|
23
|
+
const blocks: NormalizedBlock[] = [
|
|
24
|
+
{ kind: "assistant", text: "Okay, I found the root cause." },
|
|
25
|
+
{ kind: "assistant", text: "Actually, the issue is in middleware." },
|
|
26
|
+
{ kind: "assistant", text: "Let me check the logs." },
|
|
27
|
+
];
|
|
28
|
+
const r = compileBrief(blocks);
|
|
29
|
+
expect(r).toContain("I found the root cause.");
|
|
30
|
+
expect(r).toContain("the issue is in middleware.");
|
|
31
|
+
expect(r).toContain("Let me check the logs.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("collapses tool calls to one-liners under [assistant]", () => {
|
|
35
|
+
const blocks: NormalizedBlock[] = [
|
|
36
|
+
{ kind: "assistant", text: "Let me check." },
|
|
37
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "auth.ts" } },
|
|
38
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts" } },
|
|
39
|
+
];
|
|
40
|
+
const r = compileBrief(blocks);
|
|
41
|
+
expect(r).toContain('* Read "auth.ts"');
|
|
42
|
+
expect(r).toContain('* Edit "auth.ts"');
|
|
43
|
+
// Should merge into single [assistant] section
|
|
44
|
+
const matches = r.match(/\[assistant\]/g);
|
|
45
|
+
expect(matches?.length).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("hides non-error tool results", () => {
|
|
49
|
+
const blocks: NormalizedBlock[] = [
|
|
50
|
+
{ kind: "tool_result", name: "Read", text: "const x = 1;\nconst y = 2;\n// lots of code", isError: false },
|
|
51
|
+
];
|
|
52
|
+
const r = compileBrief(blocks);
|
|
53
|
+
expect(r).toBe("");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("shows tool errors with first line", () => {
|
|
57
|
+
const blocks: NormalizedBlock[] = [
|
|
58
|
+
{ kind: "tool_result", name: "bash", text: "FAIL auth.test.ts\nexpected 200 got 401", isError: true },
|
|
59
|
+
];
|
|
60
|
+
const r = compileBrief(blocks);
|
|
61
|
+
expect(r).toContain("[tool_error] bash");
|
|
62
|
+
expect(r).toContain("FAIL auth.test.ts");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("hides thinking blocks", () => {
|
|
66
|
+
const blocks: NormalizedBlock[] = [
|
|
67
|
+
{ kind: "thinking", text: "Let me think about this...", redacted: false },
|
|
68
|
+
{ kind: "assistant", text: "Here's what I found." },
|
|
69
|
+
];
|
|
70
|
+
const r = compileBrief(blocks);
|
|
71
|
+
expect(r).not.toContain("think");
|
|
72
|
+
expect(r).toContain("Here's what I found.");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("merges adjacent assistant sections", () => {
|
|
76
|
+
const blocks: NormalizedBlock[] = [
|
|
77
|
+
{ kind: "assistant", text: "First part." },
|
|
78
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "a.ts" } },
|
|
79
|
+
// No user/tool_result between these — should merge
|
|
80
|
+
{ kind: "assistant", text: "Second part." },
|
|
81
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "b.ts" } },
|
|
82
|
+
];
|
|
83
|
+
const r = compileBrief(blocks);
|
|
84
|
+
const matches = r.match(/\[assistant\]/g);
|
|
85
|
+
expect(matches?.length).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does NOT merge assistant after user", () => {
|
|
89
|
+
const blocks: NormalizedBlock[] = [
|
|
90
|
+
{ kind: "assistant", text: "First." },
|
|
91
|
+
{ kind: "user", text: "Next task." },
|
|
92
|
+
{ kind: "assistant", text: "Second." },
|
|
93
|
+
];
|
|
94
|
+
const r = compileBrief(blocks);
|
|
95
|
+
const matches = r.match(/\[assistant\]/g);
|
|
96
|
+
expect(matches?.length).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("truncates long user text", () => {
|
|
100
|
+
const longText = Array.from({ length: 300 }, (_, i) => `word${i}`).join(" ");
|
|
101
|
+
const blocks: NormalizedBlock[] = [
|
|
102
|
+
{ kind: "user", text: longText },
|
|
103
|
+
];
|
|
104
|
+
const r = compileBrief(blocks);
|
|
105
|
+
expect(r).toContain("(truncated)");
|
|
106
|
+
expect(r).not.toContain("word299");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("truncates long assistant text", () => {
|
|
110
|
+
const longText = Array.from({ length: 300 }, (_, i) => `word${i}`).join(" ");
|
|
111
|
+
const blocks: NormalizedBlock[] = [
|
|
112
|
+
{ kind: "assistant", text: longText },
|
|
113
|
+
];
|
|
114
|
+
const r = compileBrief(blocks);
|
|
115
|
+
expect(r).toContain("(truncated)");
|
|
116
|
+
expect(r).not.toContain("word299");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("renders a realistic conversation flow", () => {
|
|
120
|
+
const blocks: NormalizedBlock[] = [
|
|
121
|
+
{ kind: "user", text: "fix the login bug" },
|
|
122
|
+
{ kind: "thinking", text: "I need to check...", redacted: false },
|
|
123
|
+
{ kind: "assistant", text: "Let me investigate." },
|
|
124
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "login.ts" } },
|
|
125
|
+
{ kind: "tool_result", name: "Read", text: "export function login() { ... }", isError: false },
|
|
126
|
+
{ kind: "tool_call", name: "bash", args: { command: "npm test" } },
|
|
127
|
+
{ kind: "tool_result", name: "bash", text: "FAIL: login test\nExpected true, got false", isError: true },
|
|
128
|
+
{ kind: "assistant", text: "The test is failing because..." },
|
|
129
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "login.ts" } },
|
|
130
|
+
{ kind: "tool_result", name: "Edit", text: "File edited successfully", isError: false },
|
|
131
|
+
{ kind: "user", text: "test lại đi" },
|
|
132
|
+
{ kind: "assistant", text: "Running tests again." },
|
|
133
|
+
{ kind: "tool_call", name: "bash", args: { command: "npm test" } },
|
|
134
|
+
{ kind: "tool_result", name: "bash", text: "All tests passed", isError: false },
|
|
135
|
+
];
|
|
136
|
+
const r = compileBrief(blocks);
|
|
137
|
+
|
|
138
|
+
// Check structure
|
|
139
|
+
expect(r).toContain("[user]\nfix the login bug");
|
|
140
|
+
expect(r).toContain('[assistant]\nLet me investigate.\n* Read "login.ts"');
|
|
141
|
+
expect(r).toContain("[tool_error] bash\nFAIL: login test");
|
|
142
|
+
expect(r).toContain('[assistant]\nThe test is failing because...\n* Edit "login.ts"');
|
|
143
|
+
expect(r).toContain("[user]\ntest lại đi");
|
|
144
|
+
expect(r).toContain('[assistant]\nRunning tests again.\n* bash "npm test"');
|
|
145
|
+
|
|
146
|
+
// Hidden content
|
|
147
|
+
expect(r).not.toContain("think");
|
|
148
|
+
expect(r).not.toContain("export function login");
|
|
149
|
+
expect(r).not.toContain("File edited successfully");
|
|
150
|
+
expect(r).not.toContain("All tests passed");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── noise filtering tests (aligned with VCC) ──
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
it("suppresses blank lines between consecutive tool-only sections", () => {
|
|
164
|
+
const blocks: NormalizedBlock[] = [
|
|
165
|
+
{ kind: "assistant", text: "Checking files." },
|
|
166
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "a.ts" } },
|
|
167
|
+
{ kind: "tool_result", name: "Read", text: "...", isError: false },
|
|
168
|
+
// tool_result hidden → next tool_call starts new assistant section
|
|
169
|
+
// but since both are tool-only, no blank line between
|
|
170
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "b.ts" } },
|
|
171
|
+
{ kind: "tool_result", name: "Read", text: "...", isError: false },
|
|
172
|
+
];
|
|
173
|
+
const r = compileBrief(blocks);
|
|
174
|
+
// The first assistant section has text + tool, so it's NOT tool-only
|
|
175
|
+
// The second would be tool-only but merges into the first (adjacent assistant)
|
|
176
|
+
// So all under one [assistant]
|
|
177
|
+
expect(r.match(/\[assistant\]/g)?.length).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("caps tool calls per [assistant] turn at 8 (keep tail)", () => {
|
|
181
|
+
const blocks: NormalizedBlock[] = [
|
|
182
|
+
{ kind: "assistant", text: "Working." },
|
|
183
|
+
];
|
|
184
|
+
for (let i = 1; i <= 12; i++) {
|
|
185
|
+
blocks.push({ kind: "tool_call", name: "bash", args: { command: `echo ${i}` } });
|
|
186
|
+
}
|
|
187
|
+
const r = compileBrief(blocks);
|
|
188
|
+
expect(r).toContain("(4 earlier tool-call entries omitted)");
|
|
189
|
+
// Last 8 (5..12) kept; first 4 dropped
|
|
190
|
+
expect(r).not.toContain("echo 1\"");
|
|
191
|
+
expect(r).not.toContain("echo 4\"");
|
|
192
|
+
expect(r).toContain("echo 5");
|
|
193
|
+
expect(r).toContain("echo 12");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not cap when tool calls per turn <= 8", () => {
|
|
197
|
+
const blocks: NormalizedBlock[] = [{ kind: "assistant", text: "ok" }];
|
|
198
|
+
for (let i = 1; i <= 8; i++) {
|
|
199
|
+
blocks.push({ kind: "tool_call", name: "bash", args: { command: `c${i}` } });
|
|
200
|
+
}
|
|
201
|
+
const r = compileBrief(blocks);
|
|
202
|
+
expect(r).not.toContain("entries omitted");
|
|
203
|
+
expect(r).toContain("c1");
|
|
204
|
+
expect(r).toContain("c8");
|
|
205
|
+
});
|
|
206
|
+
});
|