@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.
@@ -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
+ }