@astrosheep/pi-context 0.19.0 → 0.21.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/dist/src/budget.js +65 -0
- package/dist/src/dream/cli.js +83 -0
- package/dist/src/dream/gates.js +22 -0
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/runner.js +115 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +215 -0
- package/dist/src/index.js +98 -0
- package/dist/src/notes/address.js +31 -0
- package/dist/src/notes/frontmatter.js +136 -0
- package/dist/src/notes/model.js +101 -0
- package/dist/src/notes/paths.js +58 -0
- package/dist/src/notes/store.js +270 -0
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +81 -0
- package/dist/src/protocol.js +56 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +75 -0
- package/dist/src/tool-output.js +175 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +214 -0
- package/dist/test/coherence.test.js +375 -0
- package/dist/test/dream.test.js +142 -0
- package/dist/test/history.test.js +26 -0
- package/dist/test/integration.test.js +1766 -0
- package/dist/test/notes.test.js +474 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +13 -7
- package/playbook.md +32 -0
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +20 -0
- package/src/dream/git.ts +27 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/runner.ts +111 -0
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +62 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +9 -5
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/src/memory/tools.ts +0 -166
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-based pagination tests for the four paginating pi-context tools:
|
|
3
|
+
* history_list, history_search, notes_search,
|
|
4
|
+
* notes_list.
|
|
5
|
+
*
|
|
6
|
+
* Each case is generated from a seed, so a failure names its seed and reproduces by
|
|
7
|
+
* re-running that one seed:
|
|
8
|
+
*
|
|
9
|
+
* PI_CONTEXT_PROPERTY_SEED=137 npm test
|
|
10
|
+
*
|
|
11
|
+
* Invariants asserted per tool across random session shapes x page caps x budgets:
|
|
12
|
+
* 1. enumeration complete: concatenated pages equal the expected ordered set
|
|
13
|
+
* 2. no duplicates across pages
|
|
14
|
+
* 3. cursors strictly advance: no cycle, no repeated page, no empty non-terminal page
|
|
15
|
+
* 4. next_cursor is null only at the true end
|
|
16
|
+
* 5. every serialized page stays within the 32 KiB tool-output budget
|
|
17
|
+
*
|
|
18
|
+
* Expected sets come from the underlying stores (historyFromSession / notesFromSession)
|
|
19
|
+
* combined with the tools' documented filters -- never from the pagination code under test.
|
|
20
|
+
*/
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
import { historyFromSession } from "../src/index.js";
|
|
24
|
+
import { listNotes, searchNotes } from "../src/notes/store.js";
|
|
25
|
+
import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
26
|
+
import { MAX_NOTE_PATH_BYTES } from "../src/protocol.js";
|
|
27
|
+
import { appendText, call, context, makeExtension, manager, resultJson } from "./integration.test.js";
|
|
28
|
+
const NEEDLE = "PAGE_NEEDLE";
|
|
29
|
+
/** Default seed corpus; PI_CONTEXT_PROPERTY_SEED=<n|n,n,...> re-runs exactly those seeds. */
|
|
30
|
+
const DEFAULT_SEEDS = [11, 23, 37, 51, 67, 89, 101, 137, 173, 211, 251, 307];
|
|
31
|
+
const OVERRIDE = process.env.PI_CONTEXT_PROPERTY_SEED
|
|
32
|
+
?.split(",")
|
|
33
|
+
.map((part) => Number(part.trim()))
|
|
34
|
+
.filter((value) => Number.isFinite(value));
|
|
35
|
+
const SEEDS = OVERRIDE && OVERRIDE.length > 0 ? OVERRIDE : DEFAULT_SEEDS;
|
|
36
|
+
/** Deterministic PRNG (mulberry32): the whole generated case is a pure function of the seed. */
|
|
37
|
+
class Rng {
|
|
38
|
+
state;
|
|
39
|
+
constructor(seed) {
|
|
40
|
+
this.state = seed >>> 0;
|
|
41
|
+
}
|
|
42
|
+
next() {
|
|
43
|
+
this.state = (this.state + 0x6d2b79f5) | 0;
|
|
44
|
+
let t = this.state;
|
|
45
|
+
t = Math.imul(t ^ (t >>> 15), 1 | t);
|
|
46
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
47
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
48
|
+
}
|
|
49
|
+
int(min, max) {
|
|
50
|
+
return min + Math.floor(this.next() * (max - min + 1));
|
|
51
|
+
}
|
|
52
|
+
bool(probability = 0.5) {
|
|
53
|
+
return this.next() < probability;
|
|
54
|
+
}
|
|
55
|
+
pick(values) {
|
|
56
|
+
return values[this.int(0, values.length - 1)];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const TOOL_NAMES = ["bash", "notes_read", "notes_write", "history_list", "history_search", "web_search", "mcp_tool_call", "read", "_odd"];
|
|
60
|
+
const CONTENT_LIMITS = [1, 5, 60, 1200, 24_000, 50_000];
|
|
61
|
+
const PAGE_LIMITS = [1, 2, 3, 5, 8, 13, 34, 200];
|
|
62
|
+
const ROLE_FILTERS = [null, "user", "assistant", "tool_call", "tool", "system", "developer"];
|
|
63
|
+
const NAME_FILTERS = [null, "bash", "notes_read", "history_list", "read", "_odd", "mcp_tool_call"];
|
|
64
|
+
function makeContent(rng, large) {
|
|
65
|
+
const shape = large ? rng.pick(["large", "large", "medium", "needle"]) : rng.pick(["empty", "tiny", "tiny", "needle", "medium"]);
|
|
66
|
+
switch (shape) {
|
|
67
|
+
case "empty": return "";
|
|
68
|
+
case "tiny": return rng.pick(["", "x", "hello world", "日本語のテキスト", "line one\nline two", NEEDLE, `${NEEDLE} ${NEEDLE}`]);
|
|
69
|
+
case "needle": return `${NEEDLE} item ${rng.int(0, 9999)}`;
|
|
70
|
+
case "medium": return rng.bool() ? `${NEEDLE}\n${"m".repeat(rng.int(400, 4000))}` : `${"m".repeat(rng.int(400, 4000))}\n${NEEDLE}`;
|
|
71
|
+
case "large": return `${NEEDLE}\n${"L".repeat(rng.int(24_000, 70_000))}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function historyPlan(seed) {
|
|
75
|
+
const rng = new Rng(seed * 2 + 1);
|
|
76
|
+
const count = rng.pick([0, 1, 1, 2, 3, 5, 8, 13, 21]);
|
|
77
|
+
const entries = [];
|
|
78
|
+
let compactions = 0;
|
|
79
|
+
for (let index = 0; index < count; index++) {
|
|
80
|
+
// A compaction entry splits the branch into a new window.
|
|
81
|
+
if (index > 0 && rng.bool(0.12)) {
|
|
82
|
+
compactions++;
|
|
83
|
+
entries.push({ kind: "compaction", summary: makeContent(rng, rng.bool(0.2)) });
|
|
84
|
+
}
|
|
85
|
+
const roll = rng.next();
|
|
86
|
+
if (roll < 0.12) {
|
|
87
|
+
entries.push({ kind: "custom_message", content: makeContent(rng, rng.bool(0.15)) });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const role = roll < 0.45 ? "user" : roll < 0.75 ? "assistant" : "toolResult";
|
|
91
|
+
entries.push({ kind: "message", role, toolName: role === "toolResult" ? rng.pick(TOOL_NAMES) : "bash", content: makeContent(rng, rng.bool(0.18)) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Rare pathological shape on a deterministic subset of seeds: a tool result whose 40 KB
|
|
95
|
+
// tool_name only fits once the metadata is middle-truncated. Inserted at the head so the
|
|
96
|
+
// oversized item is the first item of its page, and a fixed size keeps later rng draws
|
|
97
|
+
// (and therefore the filters) unchanged.
|
|
98
|
+
if (seed % 3 === 0)
|
|
99
|
+
entries.unshift({ kind: "message", role: "toolResult", toolName: `oversized_${"t".repeat(40_000)}`, content: "oversized tool_name probe" });
|
|
100
|
+
const windowCount = compactions + 1;
|
|
101
|
+
const variant = (label, query) => ({
|
|
102
|
+
label,
|
|
103
|
+
query,
|
|
104
|
+
windowIndex: rng.bool(0.3) ? rng.int(0, windowCount - 1) : null,
|
|
105
|
+
role: rng.pick(ROLE_FILTERS),
|
|
106
|
+
toolName: rng.pick(NAME_FILTERS),
|
|
107
|
+
recentFirst: rng.bool(),
|
|
108
|
+
limit: rng.pick(PAGE_LIMITS),
|
|
109
|
+
maxCharsPerItem: rng.pick(CONTENT_LIMITS),
|
|
110
|
+
});
|
|
111
|
+
// Variant 0 of each list is deliberately unfiltered: it must enumerate the whole set.
|
|
112
|
+
const list = [
|
|
113
|
+
{ label: "unfiltered", windowIndex: null, role: null, toolName: null, recentFirst: false, limit: 200, maxCharsPerItem: rng.pick(CONTENT_LIMITS) },
|
|
114
|
+
variant("list-1"),
|
|
115
|
+
variant("list-2"),
|
|
116
|
+
variant("list-3"),
|
|
117
|
+
];
|
|
118
|
+
const search = [
|
|
119
|
+
{ label: "needle-unfiltered", query: NEEDLE, windowIndex: null, role: null, toolName: null, recentFirst: false, limit: 200, maxCharsPerItem: rng.pick(CONTENT_LIMITS) },
|
|
120
|
+
variant("search-1", rng.pick([NEEDLE, "line", "no-such-token", "…"])),
|
|
121
|
+
variant("search-2", rng.pick([NEEDLE, "x", "item", "_odd"])),
|
|
122
|
+
variant("search-3", rng.pick([NEEDLE, "日本語", "m", "L"])),
|
|
123
|
+
];
|
|
124
|
+
return { seed, entries, windowCount, list, search };
|
|
125
|
+
}
|
|
126
|
+
function materializeHistory(session, plan) {
|
|
127
|
+
for (const entry of plan.entries) {
|
|
128
|
+
if (entry.kind === "message")
|
|
129
|
+
appendText(session, entry.role, entry.content, entry.toolName);
|
|
130
|
+
else if (entry.kind === "custom_message")
|
|
131
|
+
session.appendCustomMessageEntry("pi-context/property", entry.content, false);
|
|
132
|
+
else
|
|
133
|
+
session.appendCompaction(entry.summary, session.getLeafId() ?? "property-root", 1000);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function historyParams(ctx, variant) {
|
|
137
|
+
const windows = historyFromSession(ctx);
|
|
138
|
+
const windowId = variant.windowIndex === null ? null : windows[variant.windowIndex]?.windowId ?? null;
|
|
139
|
+
return {
|
|
140
|
+
limit: variant.limit,
|
|
141
|
+
recent_first: variant.recentFirst,
|
|
142
|
+
role: variant.role,
|
|
143
|
+
tool_name: variant.toolName,
|
|
144
|
+
window_id: windowId,
|
|
145
|
+
max_chars_per_item: variant.maxCharsPerItem,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** The documented filter order (filteredItems), reimplemented over the store, never over page(). */
|
|
149
|
+
function storeHistoryItems(ctx, params) {
|
|
150
|
+
let items = historyFromSession(ctx).flatMap((window) => window.items);
|
|
151
|
+
if (typeof params.window_id === "string")
|
|
152
|
+
items = items.filter((item) => item.windowId === params.window_id);
|
|
153
|
+
if (typeof params.role === "string")
|
|
154
|
+
items = items.filter((item) => item.role === params.role);
|
|
155
|
+
if (typeof params.tool_name === "string")
|
|
156
|
+
items = items.filter((item) => item.toolName === params.tool_name);
|
|
157
|
+
if (params.recent_first !== false)
|
|
158
|
+
items = [...items].reverse();
|
|
159
|
+
return items;
|
|
160
|
+
}
|
|
161
|
+
function makeNotePath(rng, index) {
|
|
162
|
+
const dir = rng.pick(["", "notes/", "deep/nested/dir/", "unicode-日本語/"]);
|
|
163
|
+
const name = rng.pick([`f${index}.md`, `long-${"x".repeat(rng.int(1, 80))}-${index}.md`, `note ${index}.md`, `ünïcode-${index}.md`, `checkpoint-${index}.md`]);
|
|
164
|
+
return `${dir}${name}`;
|
|
165
|
+
}
|
|
166
|
+
function makeNoteText(rng) {
|
|
167
|
+
const shape = rng.pick(["empty", "tiny", "needle", "lines", "huge-line", "huge-lines"]);
|
|
168
|
+
switch (shape) {
|
|
169
|
+
case "empty": return "";
|
|
170
|
+
case "tiny": return rng.pick(["small note", "needle", "one\ntwo"]);
|
|
171
|
+
case "needle": return `${NEEDLE} ${rng.int(0, 999)}\nsecond line`;
|
|
172
|
+
case "lines": return Array.from({ length: rng.int(2, 40) }, (_, line) => `${rng.bool(0.4) ? `${NEEDLE} ` : ""}line ${line} ${"z".repeat(rng.int(0, 80))}`).join("\n");
|
|
173
|
+
case "huge-line": return `${NEEDLE} ${"H".repeat(rng.int(24_000, 70_000))}`;
|
|
174
|
+
case "huge-lines": return Array.from({ length: rng.int(2, 5) }, (_, line) => `${NEEDLE} line ${line} ${"G".repeat(rng.int(8_000, 20_000))}`).join("\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function notesPlan(seed) {
|
|
178
|
+
const rng = new Rng(seed * 4 + 3);
|
|
179
|
+
const writes = [];
|
|
180
|
+
const fileCount = rng.pick([0, 1, 2, 3, 6, 11, 17]);
|
|
181
|
+
const used = new Set();
|
|
182
|
+
for (let index = 0; index < fileCount; index++) {
|
|
183
|
+
const path = makeNotePath(rng, index);
|
|
184
|
+
if (used.has(path))
|
|
185
|
+
continue;
|
|
186
|
+
used.add(path);
|
|
187
|
+
writes.push({ path, body: makeNoteText(rng) });
|
|
188
|
+
}
|
|
189
|
+
const patterns = [null, "", "**", "*.md", "**.md", "notes/*", "notes", "deep/**", "deep/nested", "unicode-日本語/*", "checkpoint-*", "absent*", "f?.md"];
|
|
190
|
+
const list = [
|
|
191
|
+
{ label: "all", pattern: null, maxResults: 200 },
|
|
192
|
+
{ label: "paged-1", pattern: rng.pick(patterns), maxResults: rng.pick([1, 2, 3, 5]) },
|
|
193
|
+
{ label: "paged-2", pattern: rng.pick(patterns), maxResults: rng.pick([1, 3, 7, 200]) },
|
|
194
|
+
{ label: "paged-3", pattern: rng.pick(patterns), maxResults: rng.pick([2, 4, 200]) },
|
|
195
|
+
];
|
|
196
|
+
const search = [
|
|
197
|
+
{ label: "needle-all", query: NEEDLE, pattern: null, maxFiles: 200, maxMatchesPerFile: 100 },
|
|
198
|
+
{ label: "needle-paged", query: NEEDLE, pattern: null, maxFiles: rng.pick([1, 2, 3]), maxMatchesPerFile: rng.pick([1, 2, 5, 100]) },
|
|
199
|
+
{ label: "rare-query", query: rng.pick(["line 3", "z", "日本語", "absent-token", "…"]), pattern: rng.pick(patterns), maxFiles: rng.pick([1, 5, 200]), maxMatchesPerFile: rng.pick([1, 100]) },
|
|
200
|
+
];
|
|
201
|
+
return { seed, writes, list, search };
|
|
202
|
+
}
|
|
203
|
+
async function materializeNotes(plan, captured, ctx) {
|
|
204
|
+
for (const write of plan.writes) {
|
|
205
|
+
const result = resultJson(await call(captured, "notes_write", { path: write.path, content: write.body }, ctx));
|
|
206
|
+
assert.equal(result.error, undefined, `seed=${plan.seed}: write ${write.path}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The store's own enumeration is the oracle for membership and order here; the pagination
|
|
211
|
+
* layer (page()) is what is under test, and walkPages asserts it enumerates exactly this set.
|
|
212
|
+
*/
|
|
213
|
+
function expectedListRows(ctx, variant) {
|
|
214
|
+
return listNotes(ctx, { pattern: variant.pattern ?? undefined });
|
|
215
|
+
}
|
|
216
|
+
function expectedSearchRows(ctx, variant) {
|
|
217
|
+
return searchNotes(ctx, [variant.query], { pattern: variant.pattern ?? undefined });
|
|
218
|
+
}
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Paging driver
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
function rawText(result) {
|
|
223
|
+
const part = result.content[0];
|
|
224
|
+
if (!part || part.type !== "text")
|
|
225
|
+
throw new Error("tool result carries text");
|
|
226
|
+
return part.text;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Follow next_cursor to the true end, asserting invariants 1-5 for every page.
|
|
230
|
+
* `expected` is the full ordered id sequence the store says the tool must enumerate.
|
|
231
|
+
* `idsOf` receives the page and the page's starting cursor (its index into the expected
|
|
232
|
+
* ordered set), so an identity-paginating tool can map a visibly truncated identity back
|
|
233
|
+
* to the expected one instead of pretending it was never returned.
|
|
234
|
+
*/
|
|
235
|
+
async function walkPages(options) {
|
|
236
|
+
const { captured, ctx, tool, params, idsOf, expected, label } = options;
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
const cursors = new Set();
|
|
239
|
+
const collected = [];
|
|
240
|
+
const pages = [];
|
|
241
|
+
let cursor = 0;
|
|
242
|
+
let next = 0;
|
|
243
|
+
const guard = expected.length + 4;
|
|
244
|
+
while (next !== null) {
|
|
245
|
+
assert.ok(pages.length < guard, `${label}: pagination exceeded ${guard} pages (cursor=${cursor}); cursor cycle or stall, collected ${collected.length}/${expected.length}`);
|
|
246
|
+
const result = await call(captured, tool, { ...params, cursor }, ctx);
|
|
247
|
+
const text = rawText(result);
|
|
248
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
249
|
+
assert.ok(bytes <= TOOL_OUTPUT_MAX_BYTES, `${label} cursor=${cursor}: serialized page is ${bytes} bytes, over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
|
|
250
|
+
const page = resultJson(result);
|
|
251
|
+
assert.ok(page.next_cursor === null || Number.isInteger(page.next_cursor), `${label} cursor=${cursor}: next_cursor is an integer or null`);
|
|
252
|
+
const ids = idsOf(page, cursor);
|
|
253
|
+
for (const id of ids) {
|
|
254
|
+
assert.equal(seen.has(id), false, `${label} cursor=${cursor}: duplicate id ${id} across pages`);
|
|
255
|
+
seen.add(id);
|
|
256
|
+
}
|
|
257
|
+
collected.push(...ids);
|
|
258
|
+
if (page.next_cursor === null) {
|
|
259
|
+
assert.equal(collected.length, expected.length, `${label} cursor=${cursor}: next_cursor is null but ${expected.length - collected.length} of ${expected.length} items are unenumerated`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
assert.ok(ids.length > 0, `${label} cursor=${cursor}: non-terminal page is empty (stall)`);
|
|
263
|
+
assert.ok(page.next_cursor > cursor, `${label}: cursor did not strictly advance (${cursor} -> ${page.next_cursor})`);
|
|
264
|
+
assert.equal(cursors.has(page.next_cursor), false, `${label}: cursor ${page.next_cursor} was revisited`);
|
|
265
|
+
cursors.add(page.next_cursor);
|
|
266
|
+
}
|
|
267
|
+
assert.ok(collected.length <= expected.length, `${label} cursor=${cursor}: enumerated ${collected.length} items, more than the expected ${expected.length}`);
|
|
268
|
+
pages.push(page);
|
|
269
|
+
next = page.next_cursor;
|
|
270
|
+
if (next !== null)
|
|
271
|
+
cursor = next;
|
|
272
|
+
}
|
|
273
|
+
assert.deepEqual(collected, [...expected], `${label}: concatenated pages differ from the expected enumeration`);
|
|
274
|
+
return pages;
|
|
275
|
+
}
|
|
276
|
+
const TRUNCATION_MARKER = /^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/;
|
|
277
|
+
/**
|
|
278
|
+
* A truncated identity is only legitimate when it is visibly a middle-truncation of the
|
|
279
|
+
* expected store path: same head, same tail, and strictly fewer characters. This is what
|
|
280
|
+
* keeps `path` from being silently mangled.
|
|
281
|
+
*/
|
|
282
|
+
function assertTruncatedIdentity(expectedPath, actual, label) {
|
|
283
|
+
const match = TRUNCATION_MARKER.exec(actual);
|
|
284
|
+
assert.ok(match, `${label}: truncated path carries the …[truncated N chars]… marker`);
|
|
285
|
+
const head = match[1];
|
|
286
|
+
const tail = match[2];
|
|
287
|
+
const expectedChars = Array.from(expectedPath);
|
|
288
|
+
const headChars = Array.from(head);
|
|
289
|
+
const tailChars = Array.from(tail);
|
|
290
|
+
assert.equal(expectedChars.slice(0, headChars.length).join(""), head, `${label}: truncated path keeps the original head`);
|
|
291
|
+
assert.equal(expectedChars.slice(expectedChars.length - tailChars.length).join(""), tail, `${label}: truncated path keeps the original tail`);
|
|
292
|
+
assert.ok(headChars.length + tailChars.length < expectedChars.length, `${label}: truncation actually removes characters`);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Map a notes page entry's path back to the expected store path. A non-truncated path must
|
|
296
|
+
* equal it; a flagged path must be a visible middle-truncation of a legacy path that the
|
|
297
|
+
* write cap could never have produced. The expected path is returned either way so the
|
|
298
|
+
* pagination invariants compare like with like.
|
|
299
|
+
*/
|
|
300
|
+
function notePathIdentity(expectedPaths, cursor, label, page, key) {
|
|
301
|
+
return page[key].map((file, index) => {
|
|
302
|
+
const expectedPath = expectedPaths[cursor + index];
|
|
303
|
+
assert.ok(expectedPath !== undefined, `${label} cursor=${cursor}: page returned more entries than the store holds`);
|
|
304
|
+
if (file.path_truncated) {
|
|
305
|
+
assert.ok(Buffer.byteLength(expectedPath, "utf8") > MAX_NOTE_PATH_BYTES, `${label} cursor=${cursor}: only a legacy path beyond the write cap may be truncated, got ${file.path}`);
|
|
306
|
+
assertTruncatedIdentity(expectedPath, file.path, `${label} cursor=${cursor}`);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
assert.equal(file.path, expectedPath, `${label} cursor=${cursor}: path is returned intact when its entry fits`);
|
|
310
|
+
}
|
|
311
|
+
return expectedPath;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Properties
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
/** Run one generated case per seed, naming the seed in any thrown failure so it reproduces alone. */
|
|
318
|
+
async function runSeeds(label, body) {
|
|
319
|
+
for (const seed of SEEDS) {
|
|
320
|
+
try {
|
|
321
|
+
await body(seed);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
325
|
+
throw new Error(`${label} failed at seed=${seed} (re-run with PI_CONTEXT_PROPERTY_SEED=${seed}): ${detail}`, { cause: error });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
test("generators are deterministic: the same seed replays the same shapes", () => {
|
|
330
|
+
for (const seed of SEEDS) {
|
|
331
|
+
assert.deepEqual(historyPlan(seed), historyPlan(seed), `historyPlan(seed=${seed}) replays identically`);
|
|
332
|
+
assert.deepEqual(notesPlan(seed), notesPlan(seed), `notesPlan(seed=${seed}) replays identically`);
|
|
333
|
+
}
|
|
334
|
+
assert.notDeepEqual(historyPlan(DEFAULT_SEEDS[0]), historyPlan(DEFAULT_SEEDS[1]), "different seeds generate different history shapes");
|
|
335
|
+
assert.notDeepEqual(notesPlan(DEFAULT_SEEDS[0]), notesPlan(DEFAULT_SEEDS[1]), "different seeds generate different notes shapes");
|
|
336
|
+
// The rare pathological history shape is reachable from the committed corpus.
|
|
337
|
+
const historyEntries = DEFAULT_SEEDS.flatMap((seed) => historyPlan(seed).entries);
|
|
338
|
+
assert.ok(historyEntries.some((entry) => entry.kind === "message" && Buffer.byteLength(entry.toolName, "utf8") > TOOL_OUTPUT_MAX_BYTES), "some committed seed generates an oversized tool_name");
|
|
339
|
+
const noteWrites = DEFAULT_SEEDS.flatMap((seed) => notesPlan(seed).writes);
|
|
340
|
+
assert.ok(noteWrites.some((write) => write.body.includes(NEEDLE)), "some committed seed generates a needle body");
|
|
341
|
+
});
|
|
342
|
+
test("history_list enumerates every item across seeded session shapes", async () => {
|
|
343
|
+
console.log(`pagination property seeds: ${SEEDS.join(", ")}`);
|
|
344
|
+
await runSeeds("history_list", async (seed) => {
|
|
345
|
+
const plan = historyPlan(seed);
|
|
346
|
+
const session = manager();
|
|
347
|
+
const captured = makeExtension(session);
|
|
348
|
+
const ctx = context(session);
|
|
349
|
+
materializeHistory(session, plan);
|
|
350
|
+
const windows = historyFromSession(ctx);
|
|
351
|
+
assert.equal(windows.length, plan.windowCount, `seed=${seed}: generated ${plan.windowCount} windows`);
|
|
352
|
+
for (const variant of plan.list) {
|
|
353
|
+
const params = historyParams(ctx, variant);
|
|
354
|
+
// A role×tool_name combination the taxonomy proves empty is a named error, not a page.
|
|
355
|
+
if (variant.toolName !== null && variant.role !== null && variant.role !== "tool_call" && variant.role !== "tool") {
|
|
356
|
+
const dead = resultJson(await call(captured, "history_list", params, ctx));
|
|
357
|
+
assert.ok(dead.error?.includes("only set on"), `history_list seed=${seed} ${variant.label} role=${variant.role} tool_name=${variant.toolName}: vacuous combo must be a named error`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const expected = storeHistoryItems(ctx, params).map((item) => item.itemId);
|
|
361
|
+
await walkPages({
|
|
362
|
+
captured, ctx, tool: "history_list", params,
|
|
363
|
+
idsOf: (page) => page.items.map((item) => item.item_id),
|
|
364
|
+
expected,
|
|
365
|
+
label: `history_list seed=${seed} ${variant.label} windowIndex=${variant.windowIndex} role=${variant.role} tool_name=${variant.toolName} recent_first=${variant.recentFirst} limit=${variant.limit} max_chars_per_item=${variant.maxCharsPerItem}`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
test("history_search enumerates every match across seeded session shapes", async () => {
|
|
371
|
+
await runSeeds("history_search", async (seed) => {
|
|
372
|
+
const plan = historyPlan(seed);
|
|
373
|
+
const session = manager();
|
|
374
|
+
const captured = makeExtension(session);
|
|
375
|
+
const ctx = context(session);
|
|
376
|
+
materializeHistory(session, plan);
|
|
377
|
+
for (const variant of plan.search) {
|
|
378
|
+
const params = { ...historyParams(ctx, variant), query: variant.query ?? "" };
|
|
379
|
+
if (variant.toolName !== null && variant.role !== null && variant.role !== "tool_call" && variant.role !== "tool") {
|
|
380
|
+
const dead = resultJson(await call(captured, "history_search", params, ctx));
|
|
381
|
+
assert.ok(dead.error?.includes("only set on"), `history_search seed=${seed} ${variant.label} role=${variant.role} tool_name=${variant.toolName}: vacuous combo must be a named error`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const expected = storeHistoryItems(ctx, params).filter((item) => item.content.includes(params.query)).map((item) => item.itemId);
|
|
385
|
+
await walkPages({
|
|
386
|
+
captured, ctx, tool: "history_search", params,
|
|
387
|
+
idsOf: (page) => page.items.map((item) => item.item_id),
|
|
388
|
+
expected,
|
|
389
|
+
label: `history_search seed=${seed} ${variant.label} query=${JSON.stringify(params.query)} windowIndex=${variant.windowIndex} role=${variant.role} tool_name=${variant.toolName} recent_first=${variant.recentFirst} limit=${variant.limit} max_chars_per_item=${variant.maxCharsPerItem}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
test("notes_list enumerates every note file across seeded mixes", async () => {
|
|
395
|
+
await runSeeds("notes_list", async (seed) => {
|
|
396
|
+
const plan = notesPlan(seed);
|
|
397
|
+
const session = manager();
|
|
398
|
+
const captured = makeExtension(session);
|
|
399
|
+
const ctx = context(session);
|
|
400
|
+
await materializeNotes(plan, captured, ctx);
|
|
401
|
+
const all = new Map(listNotes(ctx, {}).map((row) => [row.path, row]));
|
|
402
|
+
for (const variant of plan.list) {
|
|
403
|
+
const params = { pattern: variant.pattern, max_results: variant.maxResults };
|
|
404
|
+
const expected = expectedListRows(ctx, variant).map((row) => row.path);
|
|
405
|
+
const label = `notes_list seed=${seed} ${variant.label} pattern=${JSON.stringify(variant.pattern)} max_results=${variant.maxResults}`;
|
|
406
|
+
const pages = await walkPages({
|
|
407
|
+
captured, ctx, tool: "notes_list", params,
|
|
408
|
+
idsOf: (page, cursor) => notePathIdentity(expected, cursor, label, page, "files"),
|
|
409
|
+
expected,
|
|
410
|
+
label,
|
|
411
|
+
});
|
|
412
|
+
// Each listed file must describe the store's file exactly, not a stale or invented one.
|
|
413
|
+
let flat = 0;
|
|
414
|
+
for (const page of pages) {
|
|
415
|
+
for (const file of page.files) {
|
|
416
|
+
const storePath = expected[flat++];
|
|
417
|
+
const row = all.get(storePath);
|
|
418
|
+
assert.ok(row, `${label}: listed ${storePath} is not in the note store`);
|
|
419
|
+
assert.equal(file.size_bytes, row.sizeBytes, `${label}: size_bytes for ${storePath}`);
|
|
420
|
+
assert.equal(file.stale, row.meta.stale, `${label}: stale for ${storePath}`);
|
|
421
|
+
assert.equal(Date.parse(file.created_at), row.meta.created_at, `${label}: created_at for ${storePath}`);
|
|
422
|
+
assert.equal(Date.parse(file.updated_at), row.meta.updated_at, `${label}: updated_at for ${storePath}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
test("notes_search enumerates every matching file across seeded mixes", async () => {
|
|
429
|
+
await runSeeds("notes_search", async (seed) => {
|
|
430
|
+
const plan = notesPlan(seed);
|
|
431
|
+
const session = manager();
|
|
432
|
+
const captured = makeExtension(session);
|
|
433
|
+
const ctx = context(session);
|
|
434
|
+
await materializeNotes(plan, captured, ctx);
|
|
435
|
+
const bodies = new Map(plan.writes.map((write) => [write.path, write.body]));
|
|
436
|
+
for (const variant of plan.search) {
|
|
437
|
+
const params = { query: variant.query, pattern: variant.pattern, max_files: variant.maxFiles, max_matches_per_file: variant.maxMatchesPerFile };
|
|
438
|
+
const expected = expectedSearchRows(ctx, variant).map((row) => row.path);
|
|
439
|
+
const label = `notes_search seed=${seed} ${variant.label} query=${JSON.stringify(variant.query)} pattern=${JSON.stringify(variant.pattern)} max_files=${variant.maxFiles} max_matches_per_file=${variant.maxMatchesPerFile}`;
|
|
440
|
+
const pages = await walkPages({
|
|
441
|
+
captured, ctx, tool: "notes_search", params,
|
|
442
|
+
idsOf: (page, cursor) => notePathIdentity(expected, cursor, label, page, "files"),
|
|
443
|
+
expected,
|
|
444
|
+
label,
|
|
445
|
+
});
|
|
446
|
+
// Matches are a prefix of the file's real matching lines (never invented, never reordered).
|
|
447
|
+
let flat = 0;
|
|
448
|
+
for (const page of pages) {
|
|
449
|
+
for (const file of page.files) {
|
|
450
|
+
const storePath = expected[flat++];
|
|
451
|
+
const body = bodies.get(storePath);
|
|
452
|
+
assert.ok(body !== undefined, `${label}: reported ${storePath} was never written`);
|
|
453
|
+
const lines = body.split("\n");
|
|
454
|
+
const matchingLines = lines.flatMap((line, index) => line.includes(variant.query) ? [index + 1] : []);
|
|
455
|
+
assert.ok(file.matches.length >= 1, `${label}: ${storePath} reports no matches but appears in the result`);
|
|
456
|
+
assert.ok(file.matches.length <= Math.min(matchingLines.length, variant.maxMatchesPerFile), `${label}: ${storePath} reports ${file.matches.length} matches beyond its cap`);
|
|
457
|
+
assert.deepEqual(file.matches.map((match) => match.line), matchingLines.slice(0, file.matches.length), `${label}: ${storePath} match lines are not the first matching lines`);
|
|
458
|
+
const lineBase = [];
|
|
459
|
+
let lineOffset = 0;
|
|
460
|
+
for (const text of lines) {
|
|
461
|
+
lineBase.push(lineOffset);
|
|
462
|
+
lineOffset += Array.from(text).length + 1;
|
|
463
|
+
}
|
|
464
|
+
for (const match of file.matches) {
|
|
465
|
+
const line = lines[match.line - 1];
|
|
466
|
+
assert.ok(line.includes(variant.query), `${label}: ${storePath}:${match.line} does not contain the query`);
|
|
467
|
+
// The documented address: body-absolute code points up to the line, plus the query's
|
|
468
|
+
// earliest occurrence inside it.
|
|
469
|
+
const earliest = line.indexOf(variant.query);
|
|
470
|
+
assert.equal(match.offset_chars, lineBase[match.line - 1] + Array.from(line.slice(0, earliest)).length, `${label}: ${storePath}:${match.line} offset_chars does not address the query`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { registerResetLifecycle } from "../src/reset-lifecycle.js";
|
|
4
|
+
function harness() {
|
|
5
|
+
const handlers = new Map();
|
|
6
|
+
const messages = [];
|
|
7
|
+
const notices = [];
|
|
8
|
+
const requests = [];
|
|
9
|
+
let sessionId = "first", currentReset = "", enabled = true, idle = true, pending = false;
|
|
10
|
+
let signal;
|
|
11
|
+
let throwOnCompact = false;
|
|
12
|
+
const ctx = {
|
|
13
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
14
|
+
isIdle: () => idle,
|
|
15
|
+
hasPendingMessages: () => pending,
|
|
16
|
+
get signal() { return signal; },
|
|
17
|
+
compact: (options) => {
|
|
18
|
+
if (throwOnCompact)
|
|
19
|
+
throw new Error("synchronous failure");
|
|
20
|
+
requests.push(options);
|
|
21
|
+
},
|
|
22
|
+
ui: { notify: (message) => notices.push(message) },
|
|
23
|
+
};
|
|
24
|
+
const lifecycle = registerResetLifecycle({
|
|
25
|
+
on: (name, fn) => handlers.set(name, fn),
|
|
26
|
+
sendMessage: (message) => messages.push(message.customType),
|
|
27
|
+
}, {
|
|
28
|
+
isEnabled: () => enabled,
|
|
29
|
+
continuation: { customType: "continue", content: "resume", display: false },
|
|
30
|
+
buildReset: () => ({ compaction: { summary: "reset", firstKeptEntryId: "marker", tokensBefore: 100, details: {} } }),
|
|
31
|
+
isCurrentReset: (id) => id === currentReset,
|
|
32
|
+
onReset: () => { },
|
|
33
|
+
});
|
|
34
|
+
const emit = (name, event = {}) => handlers.get(name)?.(event, ctx);
|
|
35
|
+
return {
|
|
36
|
+
ctx, lifecycle, emit, messages, notices, requests,
|
|
37
|
+
setIdle: (value) => { idle = value; },
|
|
38
|
+
setPending: (value) => { pending = value; },
|
|
39
|
+
setSignal: (value) => { signal = value; },
|
|
40
|
+
setSession: (value) => { sessionId = value; },
|
|
41
|
+
setThrow: () => { throwOnCompact = true; },
|
|
42
|
+
disable: () => { enabled = false; lifecycle.clear(); },
|
|
43
|
+
enable: () => { enabled = true; },
|
|
44
|
+
before: (reason = "threshold") => emit("session_before_compact", { reason, signal: new AbortController().signal }),
|
|
45
|
+
settle: () => { emit("agent_end"); idle = true; emit("agent_settled"); },
|
|
46
|
+
success: (id = "reset", willRetry = false) => {
|
|
47
|
+
currentReset = id;
|
|
48
|
+
emit("session_compact", { compactionEntry: { id }, willRetry });
|
|
49
|
+
},
|
|
50
|
+
complete: (index = 0) => requests[index].onComplete({}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
test("reset completion, duplicate callbacks, and duplicate tools cannot launch duplicate runs", () => {
|
|
54
|
+
const h = harness();
|
|
55
|
+
assert.equal(h.lifecycle.request(), "rollover_requested");
|
|
56
|
+
assert.equal(h.lifecycle.request(), "rollover_already_pending");
|
|
57
|
+
h.settle();
|
|
58
|
+
h.emit("agent_settled");
|
|
59
|
+
assert.equal(h.requests.length, 1);
|
|
60
|
+
h.success();
|
|
61
|
+
h.success();
|
|
62
|
+
assert.deepEqual(h.messages, [], "nothing starts inside session_compact");
|
|
63
|
+
h.complete();
|
|
64
|
+
h.complete();
|
|
65
|
+
h.emit("agent_settled");
|
|
66
|
+
assert.deepEqual(h.messages, ["continue"]);
|
|
67
|
+
assert.equal(h.requests.length, 1);
|
|
68
|
+
});
|
|
69
|
+
test("automatic threshold compactions reset on the spot, with no steer and no model turn", () => {
|
|
70
|
+
const h = harness();
|
|
71
|
+
assert.ok(h.before().compaction, "the native attempt becomes our reset immediately");
|
|
72
|
+
assert.deepEqual(h.messages, [], "nothing is sent to the model");
|
|
73
|
+
});
|
|
74
|
+
test("a native compaction failure is not treated as failure of an explicit reset", () => {
|
|
75
|
+
const h = harness();
|
|
76
|
+
h.emit("session_compact_failed", { reason: "threshold", aborted: true });
|
|
77
|
+
h.lifecycle.request();
|
|
78
|
+
h.settle();
|
|
79
|
+
h.success();
|
|
80
|
+
h.complete();
|
|
81
|
+
assert.deepEqual(h.messages, ["continue"]);
|
|
82
|
+
});
|
|
83
|
+
test("failed resets release the request, retain history, and do not retry", () => {
|
|
84
|
+
const h = harness();
|
|
85
|
+
h.lifecycle.request();
|
|
86
|
+
h.settle();
|
|
87
|
+
h.emit("session_compact_failed", { reason: "manual", aborted: false });
|
|
88
|
+
h.requests[0].onError(new Error("Nothing to compact"));
|
|
89
|
+
h.requests[0].onError(new Error("duplicate callback"));
|
|
90
|
+
h.complete();
|
|
91
|
+
h.emit("agent_settled");
|
|
92
|
+
assert.equal(h.requests.length, 1);
|
|
93
|
+
assert.equal(h.notices.length, 1);
|
|
94
|
+
assert.deepEqual(h.messages, []);
|
|
95
|
+
h.setIdle(false);
|
|
96
|
+
assert.ok(h.before().compaction, "the next native attempt resets directly");
|
|
97
|
+
assert.equal(h.lifecycle.request(), "rollover_requested", "explicit retry is possible");
|
|
98
|
+
h.settle();
|
|
99
|
+
assert.equal(h.requests.length, 2);
|
|
100
|
+
h.success();
|
|
101
|
+
h.complete(1);
|
|
102
|
+
assert.deepEqual(h.messages, ["continue"]);
|
|
103
|
+
});
|
|
104
|
+
test("synchronous compact errors cannot leave a permanent in-flight request", () => {
|
|
105
|
+
const h = harness();
|
|
106
|
+
h.setThrow();
|
|
107
|
+
h.lifecycle.request();
|
|
108
|
+
h.settle();
|
|
109
|
+
h.emit("agent_settled");
|
|
110
|
+
assert.equal(h.notices.length, 1);
|
|
111
|
+
assert.equal(h.lifecycle.request(), "rollover_requested");
|
|
112
|
+
});
|
|
113
|
+
test("user abort ends explicit work without resurrecting the run", () => {
|
|
114
|
+
const h = harness();
|
|
115
|
+
h.lifecycle.request();
|
|
116
|
+
h.setSignal(AbortSignal.abort());
|
|
117
|
+
h.settle();
|
|
118
|
+
assert.equal(h.requests.length, 0);
|
|
119
|
+
assert.equal(h.messages.includes("continue"), false);
|
|
120
|
+
});
|
|
121
|
+
test("shutdown, restart, tree navigation and toggling off invalidate late callbacks", () => {
|
|
122
|
+
for (const boundary of ["session_shutdown", "session_start", "session_tree", "off"]) {
|
|
123
|
+
const h = harness();
|
|
124
|
+
h.lifecycle.request();
|
|
125
|
+
h.settle();
|
|
126
|
+
h.success();
|
|
127
|
+
if (boundary === "off") {
|
|
128
|
+
h.disable();
|
|
129
|
+
h.enable();
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
h.emit(boundary);
|
|
133
|
+
h.complete();
|
|
134
|
+
h.requests[0].onError(new Error("late error"));
|
|
135
|
+
assert.deepEqual(h.messages, [], boundary);
|
|
136
|
+
assert.deepEqual(h.notices, [], boundary);
|
|
137
|
+
if (boundary === "session_shutdown")
|
|
138
|
+
h.emit("session_start");
|
|
139
|
+
h.lifecycle.request();
|
|
140
|
+
h.settle();
|
|
141
|
+
assert.equal(h.requests.length, 2, `${boundary}: a fresh request still works`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test("callback identity keeps an earlier failure from cancelling a newer request", () => {
|
|
145
|
+
const h = harness();
|
|
146
|
+
h.lifecycle.request();
|
|
147
|
+
h.settle();
|
|
148
|
+
h.requests[0].onError(new Error("first failure"));
|
|
149
|
+
h.lifecycle.request();
|
|
150
|
+
h.settle();
|
|
151
|
+
h.requests[0].onError(new Error("late first failure"));
|
|
152
|
+
h.success();
|
|
153
|
+
h.complete(1);
|
|
154
|
+
assert.deepEqual(h.messages, ["continue"]);
|
|
155
|
+
assert.equal(h.notices.length, 1);
|
|
156
|
+
});
|
|
157
|
+
test("native compaction satisfies a pending request without duplicating Pi's continuation", () => {
|
|
158
|
+
for (const willRetry of [false, true]) {
|
|
159
|
+
const h = harness();
|
|
160
|
+
h.lifecycle.request();
|
|
161
|
+
h.success("native", willRetry);
|
|
162
|
+
h.settle();
|
|
163
|
+
assert.equal(h.requests.length, 0);
|
|
164
|
+
assert.deepEqual(h.messages, []);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test("do not interrupt another active run or duplicate a queued user prompt", () => {
|
|
168
|
+
const h = harness();
|
|
169
|
+
h.lifecycle.request();
|
|
170
|
+
h.setIdle(false);
|
|
171
|
+
h.emit("agent_settled");
|
|
172
|
+
assert.equal(h.requests.length, 0, "another extension already started work");
|
|
173
|
+
h.settle();
|
|
174
|
+
h.success();
|
|
175
|
+
h.setIdle(false);
|
|
176
|
+
h.complete();
|
|
177
|
+
assert.deepEqual(h.messages, [], "the active prompt owns continuation");
|
|
178
|
+
const queued = harness();
|
|
179
|
+
queued.lifecycle.request();
|
|
180
|
+
queued.settle();
|
|
181
|
+
queued.success();
|
|
182
|
+
queued.setPending(true);
|
|
183
|
+
queued.complete();
|
|
184
|
+
assert.deepEqual(queued.messages, [], "do not add a competing prompt");
|
|
185
|
+
});
|
|
186
|
+
test("foreign or unconfirmed reset events cannot trigger a successful continuation", () => {
|
|
187
|
+
const h = harness();
|
|
188
|
+
h.lifecycle.request();
|
|
189
|
+
h.settle();
|
|
190
|
+
// isCurrentReset stands in for the reset-v2/window-id check index.ts runs against the
|
|
191
|
+
// compaction entry's details. Emitting the foreign boundary twice proves it is never
|
|
192
|
+
// marked handled, and the request stays in flight rather than completing.
|
|
193
|
+
h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
|
|
194
|
+
h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
|
|
195
|
+
assert.equal(h.lifecycle.request(), "rollover_already_pending", "the ignored event did not complete or clear the attempt");
|
|
196
|
+
h.complete();
|
|
197
|
+
assert.deepEqual(h.messages, [], "an unconfirmed boundary never resumes the run");
|
|
198
|
+
assert.equal(h.lifecycle.request(), "rollover_requested", "the request is released after its own completion");
|
|
199
|
+
});
|