@gswangg/pi-duncan 0.1.0
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 +92 -0
- package/extensions/duncan.ts +931 -0
- package/package.json +26 -0
- package/skills/duncan/SKILL.md +35 -0
- package/tests/compaction-windows.test.mjs +285 -0
- package/tests/lineage.test.mjs +315 -0
- package/tests/resolve-targets.test.mjs +291 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: target resolution and pagination.
|
|
3
|
+
*
|
|
4
|
+
* Tests resolveTargets() — the pure function that maps
|
|
5
|
+
* (mode, limit, offset, sessionFile) → paginated DuncanTargets.
|
|
6
|
+
*
|
|
7
|
+
* Run: tsx tests/resolve-targets.test.mjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveTargets } from "../extensions/duncan.ts";
|
|
11
|
+
|
|
12
|
+
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Helpers
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const TEST_ROOT = join("/tmp", "duncan-resolve-test");
|
|
22
|
+
|
|
23
|
+
let passed = 0;
|
|
24
|
+
let failed = 0;
|
|
25
|
+
|
|
26
|
+
function assert(condition, msg) {
|
|
27
|
+
if (!condition) { console.error(` ✗ ${msg}`); failed++; }
|
|
28
|
+
else { console.log(` ✓ ${msg}`); passed++; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setup() {
|
|
32
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
33
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function teardown() {
|
|
37
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeUser(t) {
|
|
41
|
+
return { role: "user", content: [{ type: "text", text: t }], timestamp: Date.now() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeAssistant(t) {
|
|
45
|
+
return {
|
|
46
|
+
role: "assistant", content: [{ type: "text", text: t }],
|
|
47
|
+
provider: "test", model: "test-model", stopReason: "endTurn",
|
|
48
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150 },
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createSession(sessionDir, messages, parentSessionFile) {
|
|
54
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
55
|
+
const sm = new SessionManager("/workspace", sessionDir, undefined, true);
|
|
56
|
+
if (parentSessionFile) {
|
|
57
|
+
for (const msg of messages) sm.appendMessage(msg);
|
|
58
|
+
const file = sm.getSessionFile();
|
|
59
|
+
const content = readFileSync(file, "utf-8");
|
|
60
|
+
const lines = content.split("\n");
|
|
61
|
+
const header = JSON.parse(lines[0]);
|
|
62
|
+
header.parentSession = parentSessionFile;
|
|
63
|
+
lines[0] = JSON.stringify(header);
|
|
64
|
+
writeFileSync(file, lines.join("\n"));
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
for (const msg of messages) sm.appendMessage(msg);
|
|
68
|
+
return sm.getSessionFile();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findEntryByText(sm, targetText) {
|
|
72
|
+
return sm.getEntries().find(e =>
|
|
73
|
+
e.type === "message" && e.message?.content?.[0]?.text === targetText
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createCompactedSession(sessionDir) {
|
|
78
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
79
|
+
const sm = new SessionManager("/workspace", sessionDir, undefined, true);
|
|
80
|
+
sm.appendMessage(makeUser("w0-a"));
|
|
81
|
+
sm.appendMessage(makeAssistant("w0-a-r"));
|
|
82
|
+
sm.appendMessage(makeUser("w0-b"));
|
|
83
|
+
sm.appendMessage(makeAssistant("w0-b-r"));
|
|
84
|
+
const kept = findEntryByText(sm, "w0-b");
|
|
85
|
+
sm.appendCompaction("summary of w0", kept.id, 30000);
|
|
86
|
+
sm.appendMessage(makeUser("w1-a"));
|
|
87
|
+
sm.appendMessage(makeAssistant("w1-a-r"));
|
|
88
|
+
return sm.getSessionFile(); // 2 windows
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Tests
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
function testDefaultLimits() {
|
|
96
|
+
console.log("\n--- Default limits per mode ---");
|
|
97
|
+
const dir = join(TEST_ROOT, "limits");
|
|
98
|
+
|
|
99
|
+
// Create enough sessions for limits to matter
|
|
100
|
+
const sessions = [];
|
|
101
|
+
for (let i = 0; i < 5; i++) {
|
|
102
|
+
sessions.push(createSession(dir, [makeUser(`msg-${i}`), makeAssistant(`resp-${i}`)]));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sessionFile = sessions[sessions.length - 1];
|
|
106
|
+
|
|
107
|
+
// ancestors — limited mode, default 50
|
|
108
|
+
const r1 = resolveTargets({ sessions: "project" }, sessionFile, dir);
|
|
109
|
+
assert(r1.limit === 50, `project default limit is 50 (got ${r1.limit})`);
|
|
110
|
+
|
|
111
|
+
// parent — unlimited
|
|
112
|
+
// Need a parent for this
|
|
113
|
+
const parent = createSession(dir, [makeUser("parent"), makeAssistant("parent-r")]);
|
|
114
|
+
const child = createSession(dir, [makeUser("child"), makeAssistant("child-r")], parent);
|
|
115
|
+
const r2 = resolveTargets({ sessions: "parent" }, child, dir);
|
|
116
|
+
assert(r2.limit === Infinity, `parent default limit is Infinity`);
|
|
117
|
+
|
|
118
|
+
// explicit filename — unlimited
|
|
119
|
+
const r3 = resolveTargets({ sessions: basename(parent) }, child, dir);
|
|
120
|
+
assert(r3.limit === Infinity, `explicit filename default limit is Infinity`);
|
|
121
|
+
|
|
122
|
+
// ancestors — limited
|
|
123
|
+
const r4 = resolveTargets({ sessions: "ancestors" }, child, dir);
|
|
124
|
+
assert(r4.limit === 50, `ancestors default limit is 50 (got ${r4.limit})`);
|
|
125
|
+
|
|
126
|
+
// descendants — limited
|
|
127
|
+
const r5 = resolveTargets({ sessions: "descendants" }, parent, dir);
|
|
128
|
+
assert(r5.limit === 50, `descendants default limit is 50 (got ${r5.limit})`);
|
|
129
|
+
|
|
130
|
+
// user override
|
|
131
|
+
const r6 = resolveTargets({ sessions: "project", limit: 3 }, sessionFile, dir);
|
|
132
|
+
assert(r6.limit === 3, `user override limit is 3 (got ${r6.limit})`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function testPaginationBasic() {
|
|
136
|
+
console.log("\n--- Pagination: basic offset/limit ---");
|
|
137
|
+
const dir = join(TEST_ROOT, "pagination");
|
|
138
|
+
|
|
139
|
+
// Create 5 sessions = 5 windows
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
createSession(dir, [makeUser(`s${i}`), makeAssistant(`r${i}`)]);
|
|
142
|
+
}
|
|
143
|
+
const sessionFile = createSession(dir, [makeUser("current"), makeAssistant("current-r")]);
|
|
144
|
+
|
|
145
|
+
// Get all (should be 6 sessions, but current's window gets dropped = 5 windows from others + 0 from self)
|
|
146
|
+
const all = resolveTargets({ sessions: "project", limit: 100 }, sessionFile, dir);
|
|
147
|
+
const total = all.totalWindows;
|
|
148
|
+
assert(total === 5, `total windows is 5 (got ${total})`);
|
|
149
|
+
|
|
150
|
+
// First page of 2
|
|
151
|
+
const p1 = resolveTargets({ sessions: "project", limit: 2, offset: 0 }, sessionFile, dir);
|
|
152
|
+
assert(p1.targets.length === 2, `page 1: 2 targets (got ${p1.targets.length})`);
|
|
153
|
+
assert(p1.hasMore === true, `page 1: hasMore`);
|
|
154
|
+
assert(p1.totalWindows === total, `page 1: totalWindows matches`);
|
|
155
|
+
|
|
156
|
+
// Second page
|
|
157
|
+
const p2 = resolveTargets({ sessions: "project", limit: 2, offset: 2 }, sessionFile, dir);
|
|
158
|
+
assert(p2.targets.length === 2, `page 2: 2 targets (got ${p2.targets.length})`);
|
|
159
|
+
assert(p2.hasMore === true, `page 2: hasMore`);
|
|
160
|
+
|
|
161
|
+
// Third page (last)
|
|
162
|
+
const p3 = resolveTargets({ sessions: "project", limit: 2, offset: 4 }, sessionFile, dir);
|
|
163
|
+
assert(p3.targets.length === 1, `page 3: 1 target (got ${p3.targets.length})`);
|
|
164
|
+
assert(p3.hasMore === false, `page 3: no more`);
|
|
165
|
+
|
|
166
|
+
// All pages cover all windows, no overlap
|
|
167
|
+
const allIds = [...p1.targets, ...p2.targets, ...p3.targets].map(t => `${basename(t.sessionFile)}:${t.windowIndex}`);
|
|
168
|
+
const unique = new Set(allIds);
|
|
169
|
+
assert(unique.size === total, `pages cover all ${total} windows with no overlap`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function testPaginationWithCompaction() {
|
|
173
|
+
console.log("\n--- Pagination: compacted sessions expand to multiple windows ---");
|
|
174
|
+
const dir = join(TEST_ROOT, "pagination-compact");
|
|
175
|
+
|
|
176
|
+
// 1 compacted session (2 windows) + 2 normal sessions (1 window each) = 4 windows
|
|
177
|
+
createCompactedSession(dir);
|
|
178
|
+
createSession(dir, [makeUser("normal1"), makeAssistant("normal1-r")]);
|
|
179
|
+
const sessionFile = createSession(dir, [makeUser("current"), makeAssistant("current-r")]);
|
|
180
|
+
|
|
181
|
+
const all = resolveTargets({ sessions: "project", limit: 100 }, sessionFile, dir);
|
|
182
|
+
assert(all.totalWindows === 3, `3 windows total (2 from compacted + 1 from normal, current dropped) (got ${all.totalWindows})`);
|
|
183
|
+
|
|
184
|
+
// Page size 2: should split across compaction windows
|
|
185
|
+
const p1 = resolveTargets({ sessions: "project", limit: 2, offset: 0 }, sessionFile, dir);
|
|
186
|
+
assert(p1.targets.length === 2, `page 1: 2 targets`);
|
|
187
|
+
assert(p1.hasMore === true, `page 1: hasMore`);
|
|
188
|
+
|
|
189
|
+
const p2 = resolveTargets({ sessions: "project", limit: 2, offset: 2 }, sessionFile, dir);
|
|
190
|
+
assert(p2.targets.length === 1, `page 2: 1 target`);
|
|
191
|
+
assert(p2.hasMore === false, `page 2: no more`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function testOffsetBeyondRange() {
|
|
195
|
+
console.log("\n--- Pagination: offset beyond range ---");
|
|
196
|
+
const dir = join(TEST_ROOT, "offset-beyond");
|
|
197
|
+
|
|
198
|
+
createSession(dir, [makeUser("only"), makeAssistant("one")]);
|
|
199
|
+
const sessionFile = createSession(dir, [makeUser("current"), makeAssistant("current-r")]);
|
|
200
|
+
|
|
201
|
+
const r = resolveTargets({ sessions: "project", limit: 10, offset: 100 }, sessionFile, dir);
|
|
202
|
+
assert(r.error !== undefined, `error when offset beyond range`);
|
|
203
|
+
assert(r.targets.length === 0, `no targets`);
|
|
204
|
+
assert(r.error.includes("No windows in range"), `error message mentions range: "${r.error}"`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function testGlobalMode() {
|
|
208
|
+
console.log("\n--- Global mode ---");
|
|
209
|
+
const sessionsRoot = join(TEST_ROOT, "sessions");
|
|
210
|
+
const projA = join(sessionsRoot, "--project-a--");
|
|
211
|
+
const projB = join(sessionsRoot, "--project-b--");
|
|
212
|
+
|
|
213
|
+
createSession(projA, [makeUser("a1"), makeAssistant("a1-r")]);
|
|
214
|
+
createSession(projA, [makeUser("a2"), makeAssistant("a2-r")]);
|
|
215
|
+
createSession(projB, [makeUser("b1"), makeAssistant("b1-r")]);
|
|
216
|
+
const sessionFile = createSession(projA, [makeUser("current"), makeAssistant("current-r")]);
|
|
217
|
+
|
|
218
|
+
const r = resolveTargets({ sessions: "global" }, sessionFile, projA);
|
|
219
|
+
assert(!r.error, `no error`);
|
|
220
|
+
// 4 sessions total, current's window dropped = 3 windows
|
|
221
|
+
assert(r.totalWindows === 3, `3 windows (got ${r.totalWindows})`);
|
|
222
|
+
assert(r.limit === 50, `global default limit is 50`);
|
|
223
|
+
|
|
224
|
+
// Sessions from both projects present
|
|
225
|
+
const files = new Set(r.targets.map(t => t.sessionFile));
|
|
226
|
+
const dirs = new Set([...files].map(f => basename(join(f, ".."))));
|
|
227
|
+
assert(dirs.size === 2, `targets from 2 project dirs (got ${dirs.size})`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function testSelfFiltering() {
|
|
231
|
+
console.log("\n--- Self-filtering: current session's last window dropped ---");
|
|
232
|
+
const dir = join(TEST_ROOT, "self-filter");
|
|
233
|
+
|
|
234
|
+
// Compacted current session: 2 windows, but last (active) should be dropped
|
|
235
|
+
const sessionFile = createCompactedSession(dir);
|
|
236
|
+
|
|
237
|
+
// ancestors includes self
|
|
238
|
+
const r = resolveTargets({ sessions: "ancestors" }, sessionFile, dir);
|
|
239
|
+
assert(!r.error, `no error`);
|
|
240
|
+
// 2 windows in session, last dropped = 1 queryable window
|
|
241
|
+
assert(r.totalWindows === 1, `1 queryable window from self (got ${r.totalWindows})`);
|
|
242
|
+
assert(r.targets[0].windowIndex === 0, `window 0 (pre-compaction) kept`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function testErrorCases() {
|
|
246
|
+
console.log("\n--- Error cases ---");
|
|
247
|
+
const dir = join(TEST_ROOT, "errors");
|
|
248
|
+
mkdirSync(dir, { recursive: true });
|
|
249
|
+
|
|
250
|
+
const sessionFile = createSession(dir, [makeUser("solo"), makeAssistant("solo-r")]);
|
|
251
|
+
|
|
252
|
+
// No parent
|
|
253
|
+
const r1 = resolveTargets({ sessions: "parent" }, sessionFile, dir);
|
|
254
|
+
assert(r1.error === "No parent session found.", `parent error: "${r1.error}"`);
|
|
255
|
+
|
|
256
|
+
// No descendants
|
|
257
|
+
const r2 = resolveTargets({ sessions: "descendants" }, sessionFile, dir);
|
|
258
|
+
assert(r2.error === "No descendant sessions found.", `descendants error: "${r2.error}"`);
|
|
259
|
+
|
|
260
|
+
// Missing file
|
|
261
|
+
const r3 = resolveTargets({ sessions: "nonexistent.jsonl" }, sessionFile, dir);
|
|
262
|
+
assert(r3.error?.includes("Session not found"), `missing file error: "${r3.error}"`);
|
|
263
|
+
|
|
264
|
+
// Empty global
|
|
265
|
+
const emptyRoot = join(TEST_ROOT, "empty-sessions", "--empty--");
|
|
266
|
+
mkdirSync(emptyRoot, { recursive: true });
|
|
267
|
+
const emptyFile = createSession(emptyRoot, [makeUser("x"), makeAssistant("y")]);
|
|
268
|
+
// Only self exists, self gets filtered, so 0 queryable windows
|
|
269
|
+
const r4 = resolveTargets({ sessions: "project", limit: 100 }, emptyFile, emptyRoot);
|
|
270
|
+
assert(r4.error?.includes("No queryable context"), `self-only project error: "${r4.error}"`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Run
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
setup();
|
|
278
|
+
try {
|
|
279
|
+
testDefaultLimits();
|
|
280
|
+
testPaginationBasic();
|
|
281
|
+
testPaginationWithCompaction();
|
|
282
|
+
testOffsetBeyondRange();
|
|
283
|
+
testGlobalMode();
|
|
284
|
+
testSelfFiltering();
|
|
285
|
+
testErrorCases();
|
|
286
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
287
|
+
if (failed > 0) process.exit(1);
|
|
288
|
+
console.log("✅ All tests passed\n");
|
|
289
|
+
} finally {
|
|
290
|
+
teardown();
|
|
291
|
+
}
|