@astrosheep/pi-context 0.18.0 → 0.20.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 +10 -10
- package/dist/src/budget.js +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/docs/reset-lifecycle.md +1 -1
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/history-tools.ts +6 -6
- package/src/history.ts +1 -1
- package/src/index.ts +2 -2
- package/src/memory/frontmatter.ts +153 -0
- package/src/memory/paths.ts +60 -0
- package/src/memory/store.ts +310 -0
- package/src/memory/tools.ts +175 -0
- package/src/prompts.ts +28 -17
- package/src/protocol.ts +7 -7
- package/src/note-tools.ts +0 -171
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { AgentSession, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import piContext, { historyFromSession, internal } from "../src/index.js";
|
|
8
|
+
import { localIso } from "../src/notes.js";
|
|
9
|
+
import { physicalPath } from "../src/memory/paths.js";
|
|
10
|
+
import { listNotes } from "../src/memory/store.js";
|
|
11
|
+
import { middleTruncate, page, TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
|
|
12
|
+
// Settings fixtures live in temp directories. PI_CODING_AGENT_DIR is redirected for the
|
|
13
|
+
// whole test process so the extension's SettingsManager.create(ctx.cwd, undefined, ...)
|
|
14
|
+
// never reads the user's real ~/.pi. beforeEach points it back at an empty fixture.
|
|
15
|
+
const DEFAULT_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-context-agent-"));
|
|
16
|
+
const DEFAULT_CWD = mkdtempSync(join(tmpdir(), "pi-context-cwd-"));
|
|
17
|
+
process.env.PI_CODING_AGENT_DIR = DEFAULT_AGENT_DIR;
|
|
18
|
+
// Notes are real files now: every test process points the store at a throwaway root so no
|
|
19
|
+
// test can read or write the user's ~/.agents/notes/pi.
|
|
20
|
+
const DEFAULT_NOTES_ROOT = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
21
|
+
process.env.PI_NOTES_HOME = DEFAULT_NOTES_ROOT;
|
|
22
|
+
function writeJson(path, value) {
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, JSON.stringify(value, null, 2));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Materialize global (agentDir/settings.json) and project (cwd/.pi/settings.json)
|
|
28
|
+
* settings in temp directories, then read them back through the same public
|
|
29
|
+
* SettingsManager.create the extension uses. Never touches the real ~/.pi.
|
|
30
|
+
*/
|
|
31
|
+
function settingsFixture(options = {}) {
|
|
32
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-context-cwd-"));
|
|
33
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-context-agent-"));
|
|
34
|
+
const global = { ...(options.global ?? {}) };
|
|
35
|
+
if (options.reserveTokens !== undefined)
|
|
36
|
+
global.compaction = { reserveTokens: options.reserveTokens };
|
|
37
|
+
writeJson(join(agentDir, "settings.json"), global);
|
|
38
|
+
if (options.project)
|
|
39
|
+
writeJson(join(cwd, ".pi", "settings.json"), options.project);
|
|
40
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
41
|
+
// The fixture itself must parse through SettingsManager.create with these temp dirs.
|
|
42
|
+
const fixtureManager = SettingsManager.create(cwd, agentDir, { projectTrusted: true });
|
|
43
|
+
const projectReserve = options.project?.compaction?.reserveTokens;
|
|
44
|
+
assert.equal(fixtureManager.getCompactionSettings().reserveTokens, projectReserve ?? options.reserveTokens ?? 16_384, "fixture reserve reads back");
|
|
45
|
+
return { cwd, agentDir };
|
|
46
|
+
}
|
|
47
|
+
test.beforeEach(() => {
|
|
48
|
+
process.env.PI_CODING_AGENT_DIR = DEFAULT_AGENT_DIR;
|
|
49
|
+
process.env.PI_NOTES_HOME = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
|
|
50
|
+
});
|
|
51
|
+
/** TypeBox's TSchema does not expose `type`/`required` statically; read them structurally. */
|
|
52
|
+
function objectSchema(tool) {
|
|
53
|
+
return tool?.parameters;
|
|
54
|
+
}
|
|
55
|
+
export function manager(persisted = false) {
|
|
56
|
+
if (!persisted)
|
|
57
|
+
return SessionManager.inMemory("/private/tmp/pi-context-test");
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-context-session-"));
|
|
59
|
+
return SessionManager.create("/private/tmp/pi-context-test", dir);
|
|
60
|
+
}
|
|
61
|
+
export function makeExtension(sessionManager) {
|
|
62
|
+
const captured = { tools: new Map(), handlers: new Map(), commands: new Map(), sent: [], flags: [] };
|
|
63
|
+
const api = {
|
|
64
|
+
registerFlag(name) {
|
|
65
|
+
captured.flags.push(name);
|
|
66
|
+
},
|
|
67
|
+
registerTool(tool) {
|
|
68
|
+
captured.tools.set(tool.name, tool);
|
|
69
|
+
},
|
|
70
|
+
registerCommand(name, options) {
|
|
71
|
+
captured.commands.set(name, options);
|
|
72
|
+
},
|
|
73
|
+
on(name, handler) {
|
|
74
|
+
const handlers = captured.handlers.get(name) ?? [];
|
|
75
|
+
handlers.push(handler);
|
|
76
|
+
captured.handlers.set(name, handlers);
|
|
77
|
+
},
|
|
78
|
+
appendEntry(customType, data) {
|
|
79
|
+
sessionManager.appendCustomEntry(customType, data);
|
|
80
|
+
},
|
|
81
|
+
sendMessage(message, options) {
|
|
82
|
+
captured.sent.push({ message, options });
|
|
83
|
+
sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// The harness implements only the ExtensionAPI members this extension uses.
|
|
87
|
+
piContext(api);
|
|
88
|
+
return captured;
|
|
89
|
+
}
|
|
90
|
+
export function context(sessionManager, compact, usage, idle = true, cwd = DEFAULT_CWD, projectTrusted = true) {
|
|
91
|
+
const notices = [];
|
|
92
|
+
const compactionRequests = [];
|
|
93
|
+
const fake = {
|
|
94
|
+
sessionManager,
|
|
95
|
+
getContextUsage: () => usage,
|
|
96
|
+
compact: (options) => { compactionRequests.push(options); compact?.(options); },
|
|
97
|
+
isIdle: () => idle,
|
|
98
|
+
hasPendingMessages: () => false,
|
|
99
|
+
cwd,
|
|
100
|
+
isProjectTrusted: () => projectTrusted,
|
|
101
|
+
ui: { notify: (message, type) => notices.push({ message, type }) },
|
|
102
|
+
};
|
|
103
|
+
// Only the members the extension reads; the rest of the ExtensionContext surface is unused.
|
|
104
|
+
return Object.assign(fake, { notices, compactionRequests });
|
|
105
|
+
}
|
|
106
|
+
function noticesOf(ctx) {
|
|
107
|
+
return ctx.notices;
|
|
108
|
+
}
|
|
109
|
+
export async function call(captured, name, params, ctx) {
|
|
110
|
+
const tool = captured.tools.get(name);
|
|
111
|
+
assert.ok(tool, `registered ${name}`);
|
|
112
|
+
return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
|
|
113
|
+
}
|
|
114
|
+
export function resultJson(result) {
|
|
115
|
+
const text = result.content[0];
|
|
116
|
+
assert.ok(text && text.type === "text", "tool result carries text");
|
|
117
|
+
return JSON.parse(text.text);
|
|
118
|
+
}
|
|
119
|
+
/** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
|
|
120
|
+
export function assertWithinBudget(result, message) {
|
|
121
|
+
const text = result.content[0];
|
|
122
|
+
const bytes = text && text.type === "text" ? Buffer.byteLength(text.text, "utf8") : 0;
|
|
123
|
+
assert.ok(bytes <= TOOL_OUTPUT_MAX_BYTES, `${message}: ${bytes} bytes over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Decode a raw read (notes_read / history_read): a one-line bracketed header, then
|
|
127
|
+
* the payload verbatim (which may itself contain newlines), so split on the first newline only.
|
|
128
|
+
*/
|
|
129
|
+
export function resultRead(result) {
|
|
130
|
+
const text = result.content[0];
|
|
131
|
+
assert.ok(text && text.type === "text", "read result carries text");
|
|
132
|
+
const newline = text.text.indexOf("\n");
|
|
133
|
+
assert.ok(newline !== -1, "raw read carries a header line and a payload");
|
|
134
|
+
const header = text.text.slice(0, newline);
|
|
135
|
+
const content = text.text.slice(newline + 1);
|
|
136
|
+
assert.match(header, /^\[/, "the header is bracketed");
|
|
137
|
+
assert.match(header, /\]$/, "the header closes its bracket");
|
|
138
|
+
const match = header.match(/ · chars (\d+)-(\d+) of (\d+) · (end|continue at offset_chars=(\d+))/);
|
|
139
|
+
assert.ok(match, `read header names the char range and resume cursor: ${header}`);
|
|
140
|
+
const offset_chars = Number(match[1]);
|
|
141
|
+
const end = Number(match[2]);
|
|
142
|
+
const total_chars = Number(match[3]);
|
|
143
|
+
const next_offset_chars = match[4] === "end" ? null : Number(match[5]);
|
|
144
|
+
assert.equal(Array.from(content).length, end - offset_chars, "the header range matches the delivered payload");
|
|
145
|
+
return { header, content, offset_chars, total_chars, next_offset_chars, details: (result.details ?? {}) };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
|
|
149
|
+
* and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
|
|
150
|
+
*/
|
|
151
|
+
function assertLocalIso(value, epochMs, message) {
|
|
152
|
+
assert.equal(typeof value, "string", message);
|
|
153
|
+
assert.match(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/, message);
|
|
154
|
+
assert.equal(Date.parse(value), epochMs, `${message}: Date.parse restores the stored epoch ms`);
|
|
155
|
+
}
|
|
156
|
+
/** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
|
|
157
|
+
function assertIsoTimestamp(text, message) {
|
|
158
|
+
const match = text.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}/);
|
|
159
|
+
assert.ok(match, message);
|
|
160
|
+
assert.equal(Number.isNaN(Date.parse(match[0])), false, `${message}: timestamp parses`);
|
|
161
|
+
return match[0];
|
|
162
|
+
}
|
|
163
|
+
/** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
|
|
164
|
+
function assertTruncationOf(original, actual) {
|
|
165
|
+
const match = actual.match(/^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/);
|
|
166
|
+
assert.ok(match, "truncated value carries the middle-truncation marker");
|
|
167
|
+
const head = match[1];
|
|
168
|
+
const tail = match[2];
|
|
169
|
+
assert.ok(original.startsWith(head), "truncation keeps the original head");
|
|
170
|
+
assert.ok(original.endsWith(tail), "truncation keeps the original tail");
|
|
171
|
+
assert.ok(head.length + tail.length < original.length, "truncation actually removes characters");
|
|
172
|
+
}
|
|
173
|
+
async function runBeforeCompact(captured, ctx, tokensBefore, reason = "manual") {
|
|
174
|
+
const handler = captured.handlers.get("session_before_compact")?.[0];
|
|
175
|
+
assert.ok(handler, "session_before_compact handler registered");
|
|
176
|
+
const event = { reason, willRetry: reason === "overflow", signal: new AbortController().signal, preparation: { tokensBefore } };
|
|
177
|
+
return (await handler(event, ctx));
|
|
178
|
+
}
|
|
179
|
+
export function runHandlers(captured, name, event, ctx) {
|
|
180
|
+
const isIdle = ctx.isIdle;
|
|
181
|
+
if (name === "agent_settled")
|
|
182
|
+
ctx.isIdle = () => true;
|
|
183
|
+
try {
|
|
184
|
+
for (const handler of captured.handlers.get(name) ?? [])
|
|
185
|
+
handler(event, ctx);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
ctx.isIdle = isIdle;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function completeRequestedCompaction(ctx) {
|
|
192
|
+
const requests = ctx.compactionRequests;
|
|
193
|
+
const options = requests.shift();
|
|
194
|
+
assert.ok(options?.onComplete, "a reset request has a completion callback");
|
|
195
|
+
const isIdle = ctx.isIdle;
|
|
196
|
+
ctx.isIdle = () => true;
|
|
197
|
+
try {
|
|
198
|
+
options.onComplete({});
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
ctx.isIdle = isIdle;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function runCommand(captured, name, args, ctx) {
|
|
205
|
+
const command = captured.commands.get(name);
|
|
206
|
+
assert.ok(command, `${name} command registered`);
|
|
207
|
+
const notices = [];
|
|
208
|
+
const cmdCtx = Object.assign({}, ctx, {
|
|
209
|
+
ui: { notify: (message, type) => notices.push({ message, type }) },
|
|
210
|
+
});
|
|
211
|
+
await command.handler(args, cmdCtx);
|
|
212
|
+
return notices;
|
|
213
|
+
}
|
|
214
|
+
async function runContextHook(captured, ctx, eventOverride = {}) {
|
|
215
|
+
const handlers = captured.handlers.get("context") ?? [];
|
|
216
|
+
assert.ok(handlers.length > 0, "context handler registered");
|
|
217
|
+
// Pi invokes every registered context handler in order; budget and warning each own one.
|
|
218
|
+
let result;
|
|
219
|
+
for (const handler of handlers) {
|
|
220
|
+
const returned = (await handler({ type: "context", messages: [], ...eventOverride }, ctx));
|
|
221
|
+
if (returned !== undefined)
|
|
222
|
+
result = result ? { messages: [...result.messages, ...returned.messages] } : returned;
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
export function appendText(sessionManager, role, text, toolName = "bash") {
|
|
227
|
+
const base = {
|
|
228
|
+
role,
|
|
229
|
+
content: [{ type: "text", text }],
|
|
230
|
+
timestamp: Date.now(),
|
|
231
|
+
...(role === "assistant" ? { stopReason: "stop" } : {}),
|
|
232
|
+
...(role === "toolResult" ? { toolCallId: "call-1", toolName, isError: false } : {}),
|
|
233
|
+
};
|
|
234
|
+
return sessionManager.appendMessage(base);
|
|
235
|
+
}
|
|
236
|
+
test("schemas cover the History/Notes actions plus reset controls", () => {
|
|
237
|
+
const captured = makeExtension(manager());
|
|
238
|
+
for (const name of [
|
|
239
|
+
"history_windows", "history_list", "history_read", "history_search",
|
|
240
|
+
"notes_list", "notes_read", "notes_search", "notes_edit", "notes_write",
|
|
241
|
+
"new_context", "get_context_remaining",
|
|
242
|
+
]) {
|
|
243
|
+
const tool = captured.tools.get(name);
|
|
244
|
+
assert.equal(objectSchema(tool)?.type, "object", name);
|
|
245
|
+
}
|
|
246
|
+
assert.equal(objectSchema(captured.tools.get("history_read"))?.required?.includes("item_id"), true);
|
|
247
|
+
// The write surface requires its body; the edit surface requires its anchors.
|
|
248
|
+
const writeSchema = captured.tools.get("notes_write")?.parameters;
|
|
249
|
+
assert.ok(writeSchema?.properties?.content, "notes_write exposes content");
|
|
250
|
+
assert.ok(writeSchema?.properties?.path, "notes_write exposes path");
|
|
251
|
+
assert.deepEqual([...(writeSchema?.required ?? [])].sort(), ["content", "path"], "notes_write requires path and content");
|
|
252
|
+
const editSchema = captured.tools.get("notes_edit")?.parameters;
|
|
253
|
+
assert.ok(editSchema?.properties?.edits, "notes_edit exposes edits");
|
|
254
|
+
assert.deepEqual([...(editSchema?.required ?? [])].sort(), ["path"], "notes_edit requires only path; edits are optional for metadata-only updates");
|
|
255
|
+
// The history ordering switch is documented as newest-first by default.
|
|
256
|
+
for (const name of ["history_windows", "history_list", "history_search"]) {
|
|
257
|
+
const schema = captured.tools.get(name)?.parameters;
|
|
258
|
+
assert.equal(schema?.properties?.recent_first?.description?.includes("Defaults to true."), true, `${name} documents the recent_first default`);
|
|
259
|
+
}
|
|
260
|
+
// The notes list surface is usage-shaped: its default order in one sentence, no ordering algebra.
|
|
261
|
+
const listDescription = captured.tools.get("notes_list")?.description ?? "";
|
|
262
|
+
assert.match(listDescription, /most recently updated first/, "notes_list states its default order in one sentence");
|
|
263
|
+
assert.equal(/natural direction|Ties break|reshuffle between pages/.test(listDescription), false, "notes_list prose carries no ordering algebra");
|
|
264
|
+
// Both read tools are the same character window: identical params, one offset sugar, no line surface.
|
|
265
|
+
for (const name of ["notes_read", "history_read"]) {
|
|
266
|
+
const schema = captured.tools.get(name)?.parameters;
|
|
267
|
+
assert.ok(schema?.properties?.offset_chars, `${name} exposes offset_chars`);
|
|
268
|
+
assert.ok(schema?.properties?.limit_chars, `${name} exposes limit_chars`);
|
|
269
|
+
assert.equal(schema?.properties?.offset_chars?.minimum, undefined, `${name} accepts negative offset_chars`);
|
|
270
|
+
assert.equal(schema?.properties?.limit_chars?.maximum, 50000, `${name} caps limit_chars at 50000`);
|
|
271
|
+
}
|
|
272
|
+
const noteReadSchema = captured.tools.get("notes_read")?.parameters;
|
|
273
|
+
assert.deepEqual(Object.keys(noteReadSchema?.properties ?? {}).sort(), ["limit_chars", "offset_chars", "path", "scope"], "notes_read exposes exactly the character-window params plus scope");
|
|
274
|
+
assert.equal(/start_|stop_line|total_lines/.test(captured.tools.get("notes_read")?.description ?? ""), false, "notes_read prose carries no line surface");
|
|
275
|
+
});
|
|
276
|
+
test("notes_list is most-recently-updated first across merged scopes", async () => {
|
|
277
|
+
const session = manager();
|
|
278
|
+
const captured = makeExtension(session);
|
|
279
|
+
const ctx = context(session);
|
|
280
|
+
const put = (scope, path, updated) => {
|
|
281
|
+
const file = physicalPath(scope, path, ctx);
|
|
282
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
283
|
+
writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreated_at: ${localIso(updated - 1000)}\nupdated_at: ${localIso(updated)}\nlast_accessed: ${localIso(updated)}\naccess_count: 0\n---\n\nbody`);
|
|
284
|
+
};
|
|
285
|
+
const base = 1_700_000_000_000;
|
|
286
|
+
put("session", "b.md", base + 10);
|
|
287
|
+
put("session", "a.md", base + 10);
|
|
288
|
+
put("project", "c.md", base + 5);
|
|
289
|
+
put("global", "e.md", base + 20);
|
|
290
|
+
const files = async (params) => resultJson(await call(captured, "notes_list", params, ctx)).files;
|
|
291
|
+
assert.deepEqual((await files({})).map((file) => file.path), ["e.md", "a.md", "b.md", "c.md"], "updated_at descending with path ascending as the tiebreak");
|
|
292
|
+
// A same-path pair in two scopes keeps both rows; equal timestamps tie-break by scope name.
|
|
293
|
+
put("global", "a.md", base + 10);
|
|
294
|
+
assert.deepEqual((await files({})).filter((file) => file.path === "a.md").map((file) => file.scope), ["global", "session"], "equal timestamps tie-break by scope name");
|
|
295
|
+
assert.deepEqual((await files({ scope: "session" })).map((file) => file.path), ["a.md", "b.md"], "a scope filter narrows the set");
|
|
296
|
+
});
|
|
297
|
+
test("notes are real files that persist across sessions and round-trip Unicode", async () => {
|
|
298
|
+
const original = manager();
|
|
299
|
+
const captured = makeExtension(original);
|
|
300
|
+
const ctx = context(original);
|
|
301
|
+
await call(captured, "notes_write", { path: "checkpoint/进度.md", content: "第一行\nneedle Café", scope: "global" }, ctx);
|
|
302
|
+
// A brand-new session over the same physical root sees the global note: nothing is replayed
|
|
303
|
+
// from session entries, the file itself is the durable artifact.
|
|
304
|
+
const restored = manager();
|
|
305
|
+
const restoredCaptured = makeExtension(restored);
|
|
306
|
+
const restoredCtx = context(restored);
|
|
307
|
+
const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "global", offset_chars: -4 }, restoredCtx);
|
|
308
|
+
const read = resultRead(rawRead);
|
|
309
|
+
assert.equal(read.details.path, "checkpoint/进度.md");
|
|
310
|
+
assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
|
|
311
|
+
assert.equal(read.details.scope, "global");
|
|
312
|
+
const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "global" }, restoredCtx));
|
|
313
|
+
assert.equal(searched.files[0]?.matches[0]?.line, 2);
|
|
314
|
+
const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "global" }, restoredCtx));
|
|
315
|
+
assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
|
|
316
|
+
assert.equal(listedFiles.files[0]?.path, "checkpoint/进度.md");
|
|
317
|
+
// A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
|
|
318
|
+
const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "global" }, restoredCtx));
|
|
319
|
+
assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
|
|
320
|
+
assert.equal(searched.files[0]?.created_at, listedFiles.files[0]?.created_at, "note tools agree on the timestamp format");
|
|
321
|
+
assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
|
|
322
|
+
await assert.rejects(() => call(captured, "notes_write", { path: "../escape", content: "x" }, ctx), /unsupported component/);
|
|
323
|
+
});
|
|
324
|
+
test("stale lifecycle: writes and metadata-only edits close and revive a note", async () => {
|
|
325
|
+
const sm = manager();
|
|
326
|
+
const captured = makeExtension(sm);
|
|
327
|
+
const ctx = context(sm);
|
|
328
|
+
await call(captured, "notes_write", { path: "journal.md", content: "log line" }, ctx);
|
|
329
|
+
// metadata-only: content unchanged, flag set, applied 0
|
|
330
|
+
const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
|
|
331
|
+
assert.equal(markOnly.applied, 0);
|
|
332
|
+
assert.equal(markOnly.meta.stale, true);
|
|
333
|
+
assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
|
|
334
|
+
// explicit revive
|
|
335
|
+
const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
|
|
336
|
+
assert.equal(revived.meta.stale, false, "stale:false revives");
|
|
337
|
+
// write+stale closure then plain write revival
|
|
338
|
+
await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
|
|
339
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
|
|
340
|
+
await call(captured, "notes_write", { path: "journal.md", content: "reopened" }, ctx);
|
|
341
|
+
assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "writing without stale revives");
|
|
342
|
+
// metadata-only on a missing path is the typed not-found arm
|
|
343
|
+
const missing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
344
|
+
assert.equal(missing.error, "note not found");
|
|
345
|
+
});
|
|
346
|
+
test("the boot notes index excludes stale notes while list, read, and search still see them", async () => {
|
|
347
|
+
const sm = manager();
|
|
348
|
+
const captured = makeExtension(sm);
|
|
349
|
+
const ctx = context(sm);
|
|
350
|
+
await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
|
|
351
|
+
await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
|
|
352
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
353
|
+
const boot = captured.sent[0];
|
|
354
|
+
const text = typeof boot?.message.content === "string" ? boot.message.content : "";
|
|
355
|
+
assert.ok(text.includes("fresh.md"), "the fresh note is indexed");
|
|
356
|
+
assert.equal(text.includes("old.md"), false, "the stale note leaves the boot index");
|
|
357
|
+
assert.equal(text.includes("stale content"), false, "the stale preview is not rendered");
|
|
358
|
+
const listed = resultJson(await call(captured, "notes_list", {}, ctx));
|
|
359
|
+
assert.equal(listed.files.find((file) => file.path === "old.md")?.stale, true, "list carries the stale flag");
|
|
360
|
+
assert.equal(listed.files.find((file) => file.path === "fresh.md")?.stale, false);
|
|
361
|
+
// stale notes are still readable and searchable
|
|
362
|
+
const read = resultRead(await call(captured, "notes_read", { path: "old.md" }, ctx));
|
|
363
|
+
assert.ok(read.content.endsWith("stale content"));
|
|
364
|
+
const searched = resultJson(await call(captured, "notes_search", { query: "stale content" }, ctx));
|
|
365
|
+
assert.equal(searched.files[0]?.path, "old.md");
|
|
366
|
+
});
|
|
367
|
+
test("the boot notes index omits itself when every note is stale", async () => {
|
|
368
|
+
const sm = manager();
|
|
369
|
+
const captured = makeExtension(sm);
|
|
370
|
+
const ctx = context(sm);
|
|
371
|
+
await call(captured, "notes_write", { path: "done.md", content: "finished", stale: true }, ctx);
|
|
372
|
+
runHandlers(captured, "session_start", {}, ctx);
|
|
373
|
+
const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
|
|
374
|
+
assert.equal(text.includes("done.md"), false, "no stale note is indexed");
|
|
375
|
+
assert.equal(text.includes("finished"), false, "no stale preview is rendered");
|
|
376
|
+
assert.ok(text.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG), "the rest of the boot block still renders");
|
|
377
|
+
});
|
|
378
|
+
test("paged tool outputs stay bounded and cursors reconstruct history and notes", async () => {
|
|
379
|
+
const session = manager();
|
|
380
|
+
const captured = makeExtension(session);
|
|
381
|
+
const ctx = context(session);
|
|
382
|
+
const historyText = "历史内容-" + "x".repeat(50_000);
|
|
383
|
+
const historyIds = [appendText(session, "user", historyText), appendText(session, "user", historyText), appendText(session, "user", historyText)];
|
|
384
|
+
for (let index = 0; index < 10; index++)
|
|
385
|
+
appendText(session, "user", historyText);
|
|
386
|
+
const historyPages = [];
|
|
387
|
+
let cursor = 0;
|
|
388
|
+
let next = 0;
|
|
389
|
+
while (next !== null) {
|
|
390
|
+
const result = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 1200, cursor }, ctx));
|
|
391
|
+
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
392
|
+
historyPages.push(...result.items);
|
|
393
|
+
next = result.next_cursor;
|
|
394
|
+
if (next !== null)
|
|
395
|
+
cursor = next;
|
|
396
|
+
}
|
|
397
|
+
assert.deepEqual(historyPages.filter((item) => historyIds.includes(item.item_id)).map((item) => item.item_id), historyIds);
|
|
398
|
+
const search = resultJson(await call(captured, "history_search", { query: "历史内容", recent_first: false, max_chars_per_item: 50_000 }, ctx));
|
|
399
|
+
assert.ok(Buffer.byteLength(JSON.stringify(search), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
400
|
+
assert.notEqual(search.next_cursor, null);
|
|
401
|
+
const searchPages = [];
|
|
402
|
+
let searchOffset = 0;
|
|
403
|
+
let searchNext = 0;
|
|
404
|
+
while (searchNext !== null) {
|
|
405
|
+
const result = resultJson(await call(captured, "history_search", { query: "历史内容", recent_first: false, max_chars_per_item: 1200, cursor: searchOffset }, ctx));
|
|
406
|
+
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
407
|
+
searchPages.push(...result.items);
|
|
408
|
+
searchNext = result.next_cursor;
|
|
409
|
+
if (searchNext !== null)
|
|
410
|
+
searchOffset = searchNext;
|
|
411
|
+
}
|
|
412
|
+
assert.equal(searchPages.length, 13);
|
|
413
|
+
assert.equal(searchNext, null);
|
|
414
|
+
const readParts = [];
|
|
415
|
+
let readOffset = 0;
|
|
416
|
+
let readNext = 0;
|
|
417
|
+
while (readNext !== null) {
|
|
418
|
+
const raw = await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: historyIds[0], offset_chars: readOffset, limit_chars: 12000 }, ctx);
|
|
419
|
+
assertWithinBudget(raw, `history_read page at ${readOffset}`);
|
|
420
|
+
const result = resultRead(raw);
|
|
421
|
+
readParts.push(result.content);
|
|
422
|
+
readNext = result.next_offset_chars;
|
|
423
|
+
if (readNext !== null)
|
|
424
|
+
readOffset = readNext;
|
|
425
|
+
}
|
|
426
|
+
assert.equal(readParts.join(""), historyText);
|
|
427
|
+
for (let index = 0; index < 100; index++) {
|
|
428
|
+
await call(captured, "notes_write", { path: `page-${"x".repeat(120)}-${index}.md`, content: Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n") }, ctx);
|
|
429
|
+
}
|
|
430
|
+
const listPages = [];
|
|
431
|
+
let listOffset = 0;
|
|
432
|
+
let listNext = 0;
|
|
433
|
+
while (listNext !== null) {
|
|
434
|
+
const result = resultJson(await call(captured, "notes_list", { max_results: 300, cursor: listOffset }, ctx));
|
|
435
|
+
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
436
|
+
listPages.push(...result.files.map((file) => file.path));
|
|
437
|
+
listNext = result.next_cursor;
|
|
438
|
+
if (listNext !== null)
|
|
439
|
+
listOffset = listNext;
|
|
440
|
+
}
|
|
441
|
+
assert.deepEqual([...listPages].sort((a, b) => a.localeCompare(b)), Array.from({ length: 100 }, (_, index) => `page-${"x".repeat(120)}-${index}.md`).sort((a, b) => a.localeCompare(b)));
|
|
442
|
+
assert.equal(listNext, null);
|
|
443
|
+
const searchFiles = [];
|
|
444
|
+
let notesSearchOffset = 0;
|
|
445
|
+
let notesSearchNext = 0;
|
|
446
|
+
while (notesSearchNext !== null) {
|
|
447
|
+
const result = resultJson(await call(captured, "notes_search", { query: "needle", max_matches_per_file: 100, max_files: 300, cursor: notesSearchOffset }, ctx));
|
|
448
|
+
assert.ok(Buffer.byteLength(JSON.stringify(result), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
449
|
+
searchFiles.push(...result.files);
|
|
450
|
+
notesSearchNext = result.next_cursor;
|
|
451
|
+
if (notesSearchNext !== null)
|
|
452
|
+
notesSearchOffset = notesSearchNext;
|
|
453
|
+
}
|
|
454
|
+
assert.equal(searchFiles.length, 100);
|
|
455
|
+
assert.equal(notesSearchNext, null);
|
|
456
|
+
const bodyText = Array.from({ length: 1000 }, (_, line) => `needle ${line} ${"z".repeat(30)}`).join("\n");
|
|
457
|
+
const noteParts = [];
|
|
458
|
+
let noteOffset = 0;
|
|
459
|
+
let noteNext = 0;
|
|
460
|
+
while (noteNext !== null) {
|
|
461
|
+
const raw = await call(captured, "notes_read", { path: `page-${"x".repeat(120)}-0.md`, offset_chars: noteOffset }, ctx);
|
|
462
|
+
assertWithinBudget(raw, `notes_read page at ${noteOffset}`);
|
|
463
|
+
const result = resultRead(raw);
|
|
464
|
+
// The window is a plain prefix of the file, so the pages join by plain concatenation.
|
|
465
|
+
noteParts.push(result.content);
|
|
466
|
+
noteNext = result.next_offset_chars;
|
|
467
|
+
if (noteNext !== null)
|
|
468
|
+
noteOffset = noteNext;
|
|
469
|
+
}
|
|
470
|
+
const joined = noteParts.join("");
|
|
471
|
+
assert.ok(joined.startsWith("---\n"), "the frontmatter is delivered first");
|
|
472
|
+
assert.ok(joined.endsWith(bodyText), "cursor-following reconstructs the body");
|
|
473
|
+
assert.equal(noteNext, null);
|
|
474
|
+
});
|
|
475
|
+
test("a page cap limits the page, not the enumerable set: cursors stay truthful past the cap", async () => {
|
|
476
|
+
const session = manager();
|
|
477
|
+
const captured = makeExtension(session);
|
|
478
|
+
const ctx = context(session);
|
|
479
|
+
for (let index = 0; index < 60; index++) {
|
|
480
|
+
appendText(session, "user", `entry-${index}`);
|
|
481
|
+
appendText(session, "assistant", `reply-${index}`);
|
|
482
|
+
}
|
|
483
|
+
// history_list: 120 items with limit 50 page as 50/50/20, null only at the true end.
|
|
484
|
+
const windows = resultJson(await call(captured, "history_windows", {}, ctx));
|
|
485
|
+
assert.equal(windows.windows[0]?.item_count, 120);
|
|
486
|
+
const list = async (params) => resultJson(await call(captured, "history_list", params, ctx));
|
|
487
|
+
const first = await list({ limit: 50, recent_first: false, max_chars_per_item: 100 });
|
|
488
|
+
assert.equal(first.items.length, 50);
|
|
489
|
+
assert.equal(first.next_cursor, 50, "limit caps the page, not the enumerable set");
|
|
490
|
+
const second = await list({ limit: 50, cursor: 50, recent_first: false, max_chars_per_item: 100 });
|
|
491
|
+
assert.equal(second.items.length, 50);
|
|
492
|
+
assert.equal(second.next_cursor, 100);
|
|
493
|
+
const third = await list({ limit: 50, cursor: 100, recent_first: false, max_chars_per_item: 100 });
|
|
494
|
+
assert.equal(third.items.length, 20);
|
|
495
|
+
assert.equal(third.next_cursor, null, "null only at the true end");
|
|
496
|
+
// history_search: the same contract holds over the matching set.
|
|
497
|
+
const search = async (params) => resultJson(await call(captured, "history_search", params, ctx));
|
|
498
|
+
const searchFirst = await search({ query: "entry-", limit: 50, recent_first: false, max_chars_per_item: 100 });
|
|
499
|
+
assert.equal(searchFirst.items.length, 50);
|
|
500
|
+
assert.equal(searchFirst.next_cursor, 50);
|
|
501
|
+
const searchTail = await search({ query: "entry-", limit: 50, cursor: 50, recent_first: false, max_chars_per_item: 100 });
|
|
502
|
+
assert.equal(searchTail.items.length, 10);
|
|
503
|
+
assert.equal(searchTail.next_cursor, null);
|
|
504
|
+
// notes_search: max_files caps the page, not the matched files.
|
|
505
|
+
for (let index = 0; index < 7; index++)
|
|
506
|
+
await call(captured, "notes_write", { path: `needle-${index}.md`, content: "needle" }, ctx);
|
|
507
|
+
const notes = async (params) => resultJson(await call(captured, "notes_search", params, ctx));
|
|
508
|
+
const notesFirst = await notes({ query: "needle", max_files: 3 });
|
|
509
|
+
assert.equal(notesFirst.files.length, 3);
|
|
510
|
+
assert.equal(notesFirst.next_cursor, 3);
|
|
511
|
+
const notesSecond = await notes({ query: "needle", max_files: 3, cursor: 3 });
|
|
512
|
+
assert.equal(notesSecond.files.length, 3);
|
|
513
|
+
assert.equal(notesSecond.next_cursor, 6);
|
|
514
|
+
const notesTail = await notes({ query: "needle", max_files: 3, cursor: 6 });
|
|
515
|
+
assert.equal(notesTail.files.length, 1);
|
|
516
|
+
assert.equal(notesTail.next_cursor, null);
|
|
517
|
+
});
|
|
518
|
+
test("multi-query search: OR semantics, dedupe, and bare-string backward compatibility", async () => {
|
|
519
|
+
const session = manager();
|
|
520
|
+
const captured = makeExtension(session);
|
|
521
|
+
const ctx = context(session);
|
|
522
|
+
// One item matches both queries, one only the first, one only the second, one neither.
|
|
523
|
+
const bothId = appendText(session, "user", "alpha beta together");
|
|
524
|
+
const alphaId = appendText(session, "user", "alpha only");
|
|
525
|
+
const betaId = appendText(session, "assistant", "beta only");
|
|
526
|
+
const noneId = appendText(session, "user", "gamma only");
|
|
527
|
+
const historyIds = async (params) => resultJson(await call(captured, "history_search", { recent_first: false, ...params }, ctx)).items.map((item) => item.item_id);
|
|
528
|
+
const orIds = await historyIds({ query: ["alpha", "beta"] });
|
|
529
|
+
assert.deepEqual(orIds, [bothId, alphaId, betaId], "history: an item matching any query is returned once");
|
|
530
|
+
assert.equal(orIds.includes(noneId), false, "history: an item matching no query is not returned");
|
|
531
|
+
assert.deepEqual(await historyIds({ query: ["alpha"] }), [bothId, alphaId], "history: a one-element array searches that literal");
|
|
532
|
+
assert.deepEqual(await historyIds({ query: "alpha" }), orIds.filter((id) => id !== betaId), "history: a bare string still behaves exactly as before");
|
|
533
|
+
assert.deepEqual(await historyIds({ query: "alpha" }), await historyIds({ query: ["alpha"] }), "history: bare string equals the single-element list");
|
|
534
|
+
await call(captured, "notes_write", { path: "both.md", content: "alpha beta\nunrelated" }, ctx);
|
|
535
|
+
await call(captured, "notes_write", { path: "alpha.md", content: "alpha only" }, ctx);
|
|
536
|
+
await call(captured, "notes_write", { path: "beta.md", content: "beta only" }, ctx);
|
|
537
|
+
await call(captured, "notes_write", { path: "gamma.md", content: "gamma only" }, ctx);
|
|
538
|
+
const notesSearch = async (params) => resultJson(await call(captured, "notes_search", params, ctx)).files;
|
|
539
|
+
const orFiles = await notesSearch({ query: ["alpha", "beta"] });
|
|
540
|
+
assert.deepEqual(orFiles.map((file) => file.path), ["alpha.md", "beta.md", "both.md"], "notes: a file matching any query is returned once, path-ordered");
|
|
541
|
+
assert.equal(orFiles.find((file) => file.path === "both.md")?.matches.length, 1, "notes: one line containing both queries is reported once");
|
|
542
|
+
assert.deepEqual((await notesSearch({ query: ["alpha"] })).map((file) => file.path), ["alpha.md", "both.md"], "notes: a one-element array searches that literal");
|
|
543
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.path), ["alpha.md", "both.md"], "notes: a bare string still behaves exactly as before");
|
|
544
|
+
assert.deepEqual((await notesSearch({ query: "alpha" })).map((file) => file.path), (await notesSearch({ query: ["alpha"] })).map((file) => file.path), "notes: bare string equals the single-element list");
|
|
545
|
+
assert.deepEqual((await notesSearch({ query: ["gamma"] })).map((file) => file.path), ["gamma.md"]);
|
|
546
|
+
// An empty array is an argument error, not a silently empty result set.
|
|
547
|
+
await assert.rejects(() => call(captured, "history_search", { query: [] }, ctx), /non-empty array of strings/, "history: empty query array is refused");
|
|
548
|
+
await assert.rejects(() => call(captured, "notes_search", { query: [] }, ctx), /non-empty array of strings/, "notes: empty query array is refused");
|
|
549
|
+
await assert.rejects(() => call(captured, "history_search", { query: ["alpha", 7] }, ctx), /elements must be strings/, "history: non-string query element is refused");
|
|
550
|
+
await assert.rejects(() => call(captured, "notes_search", { query: ["alpha", 7] }, ctx), /elements must be strings/, "notes: non-string query element is refused");
|
|
551
|
+
});
|
|
552
|
+
test("multi-query search paginates over the OR set with no cross-page duplicates", async () => {
|
|
553
|
+
const session = manager();
|
|
554
|
+
const captured = makeExtension(session);
|
|
555
|
+
const ctx = context(session);
|
|
556
|
+
for (let index = 0; index < 12; index++) {
|
|
557
|
+
appendText(session, "user", index % 3 === 0 ? `alpha ${index}` : index % 3 === 1 ? `beta ${index}` : `gamma ${index}`);
|
|
558
|
+
}
|
|
559
|
+
const historyPage = async (cursor) => resultJson(await call(captured, "history_search", { query: ["alpha", "beta"], recent_first: false, max_chars_per_item: 100, limit: 3, cursor }, ctx));
|
|
560
|
+
const historyIds = [];
|
|
561
|
+
let historyNext = 0;
|
|
562
|
+
let historyCursor = 0;
|
|
563
|
+
let historyPages = 0;
|
|
564
|
+
while (historyNext !== null) {
|
|
565
|
+
const result = await historyPage(historyCursor);
|
|
566
|
+
assert.ok(result.items.length > 0, "history: a page is never empty");
|
|
567
|
+
historyIds.push(...result.items.map((item) => item.item_id));
|
|
568
|
+
historyNext = result.next_cursor;
|
|
569
|
+
if (historyNext !== null)
|
|
570
|
+
historyCursor = historyNext;
|
|
571
|
+
assert.ok(++historyPages < 20, "history: pagination terminates");
|
|
572
|
+
}
|
|
573
|
+
assert.equal(historyIds.length, 8, "history: every OR match is reached exactly once across pages");
|
|
574
|
+
assert.equal(new Set(historyIds).size, historyIds.length, "history: no item repeats across pages");
|
|
575
|
+
assert.equal(historyNext, null, "history: null only at the true end");
|
|
576
|
+
assert.equal((await historyPage(0)).next_cursor, 3, "history: next_cursor echoes the next page start");
|
|
577
|
+
const historyTail = await historyPage(6);
|
|
578
|
+
assert.equal(historyTail.items.length, 2);
|
|
579
|
+
assert.equal(historyTail.next_cursor, null, "history: the last page terminates the cursor");
|
|
580
|
+
for (let index = 0; index < 12; index++) {
|
|
581
|
+
const text = index % 3 === 0 ? `alpha ${index}` : index % 3 === 1 ? `beta ${index}` : `gamma ${index}`;
|
|
582
|
+
await call(captured, "notes_write", { path: `f${index}.md`, content: text }, ctx);
|
|
583
|
+
}
|
|
584
|
+
const notesPage = async (cursor) => resultJson(await call(captured, "notes_search", { query: ["alpha", "beta"], max_files: 3, cursor }, ctx));
|
|
585
|
+
const notePaths = [];
|
|
586
|
+
let notesNext = 0;
|
|
587
|
+
let notesCursor = 0;
|
|
588
|
+
let notesPages = 0;
|
|
589
|
+
while (notesNext !== null) {
|
|
590
|
+
const result = await notesPage(notesCursor);
|
|
591
|
+
assert.ok(result.files.length > 0, "notes: a page is never empty");
|
|
592
|
+
notePaths.push(...result.files.map((file) => file.path));
|
|
593
|
+
notesNext = result.next_cursor;
|
|
594
|
+
if (notesNext !== null)
|
|
595
|
+
notesCursor = notesNext;
|
|
596
|
+
assert.ok(++notesPages < 20, "notes: pagination terminates");
|
|
597
|
+
}
|
|
598
|
+
assert.equal(notePaths.length, 8, "notes: every OR match is reached exactly once across pages");
|
|
599
|
+
assert.equal(new Set(notePaths).size, notePaths.length, "notes: no file repeats across pages");
|
|
600
|
+
assert.equal(notesNext, null, "notes: null only at the true end");
|
|
601
|
+
assert.equal((await notesPage(0)).next_cursor, 3, "notes: next_cursor echoes the next page start");
|
|
602
|
+
const notesLastPage = await notesPage(6);
|
|
603
|
+
assert.equal(notesLastPage.files.length, 2);
|
|
604
|
+
assert.equal(notesLastPage.next_cursor, null, "notes: the last page terminates the cursor");
|
|
605
|
+
});
|
|
606
|
+
test("history multi-query search composes with role, tool_name, and window filters", async () => {
|
|
607
|
+
const session = manager();
|
|
608
|
+
const captured = makeExtension(session);
|
|
609
|
+
const ctx = context(session);
|
|
610
|
+
const userId = appendText(session, "user", "alpha root message");
|
|
611
|
+
const assistantId = appendText(session, "assistant", "beta assistant message");
|
|
612
|
+
const toolId = appendText(session, "toolResult", "alpha beta bash output");
|
|
613
|
+
const rootWindow = historyFromSession(ctx)[0].windowId;
|
|
614
|
+
const leaf = session.getLeafId();
|
|
615
|
+
assert.ok(leaf);
|
|
616
|
+
session.appendCompaction("window summary without needles", leaf, 100, { piContext: "reset-v2", windowId: "pcw:test:second" }, true);
|
|
617
|
+
const nextId = appendText(session, "user", "alpha next window");
|
|
618
|
+
const searchIds = async (params) => resultJson(await call(captured, "history_search", { query: ["alpha", "beta"], recent_first: false, ...params }, ctx)).items.map((item) => item.item_id);
|
|
619
|
+
assert.deepEqual(await searchIds({ role: "user" }), [userId, nextId], "role filter composes with multi-query");
|
|
620
|
+
assert.deepEqual(await searchIds({ role: "assistant" }), [assistantId], "role filter narrows the OR set");
|
|
621
|
+
assert.deepEqual(await searchIds({ tool_name: "bash" }), [toolId], "tool_name filter composes with multi-query");
|
|
622
|
+
assert.deepEqual(await searchIds({ tool_name: "read" }), [], "a non-matching tool_name yields nothing");
|
|
623
|
+
assert.deepEqual(await searchIds({ window_id: rootWindow }), [userId, assistantId, toolId], "window filter restricts the OR set to that window");
|
|
624
|
+
assert.deepEqual(await searchIds({ window_id: "pcw:test:second" }), [nextId], "the second window's matches are addressable");
|
|
625
|
+
});
|
|
626
|
+
test("an over-budget note is delivered as a prefix and resumed by next_offset_chars", async () => {
|
|
627
|
+
const session = manager();
|
|
628
|
+
const captured = makeExtension(session);
|
|
629
|
+
const ctx = context(session);
|
|
630
|
+
const huge = `H${"x".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
631
|
+
const text = `${huge}\ntail line`;
|
|
632
|
+
await call(captured, "notes_write", { path: "huge.md", content: text }, ctx);
|
|
633
|
+
const rawFirst = await call(captured, "notes_read", { path: "huge.md" }, ctx);
|
|
634
|
+
assertWithinBudget(rawFirst, "single oversized note");
|
|
635
|
+
const first = resultRead(rawFirst);
|
|
636
|
+
assert.ok(first.content.length > 0, "the page is not empty");
|
|
637
|
+
assert.equal(first.content.includes("…"), false, "the payload is a plain prefix with no marker");
|
|
638
|
+
assert.ok(first.content.startsWith("---\n"), "the frontmatter is delivered first");
|
|
639
|
+
assert.equal(first.header, `[huge.md · chars 0-${first.next_offset_chars} of ${first.total_chars} · continue at offset_chars=${first.next_offset_chars} · session · created ${String(first.details.created_at)} · updated ${String(first.details.updated_at)}]`, "the raw header names the file, the delivered range, the resume cursor, the scope and the timestamps");
|
|
640
|
+
assert.deepEqual(Object.keys(first.details).sort(), ["created_at", "limit_chars", "next_offset_chars", "offset_chars", "path", "scope", "total_chars", "updated_at"], "notes_read details carries exactly the slim window metadata plus scope");
|
|
641
|
+
assert.equal("content" in first.details, false, "details never duplicates the payload");
|
|
642
|
+
assert.equal(first.offset_chars, 0, "the default window starts at the resolved offset 0");
|
|
643
|
+
// Following the cursor reconstructs frontmatter + body by plain concatenation.
|
|
644
|
+
const parts = [first.content];
|
|
645
|
+
let offset = first.next_offset_chars;
|
|
646
|
+
while (offset !== null) {
|
|
647
|
+
const rawChunk = await call(captured, "notes_read", { path: "huge.md", offset_chars: offset }, ctx);
|
|
648
|
+
assertWithinBudget(rawChunk, `huge note chunk at ${offset}`);
|
|
649
|
+
const chunk = resultRead(rawChunk);
|
|
650
|
+
assert.equal(chunk.offset_chars, offset, "the response echoes the resolved absolute offset");
|
|
651
|
+
parts.push(chunk.content);
|
|
652
|
+
offset = chunk.next_offset_chars;
|
|
653
|
+
}
|
|
654
|
+
assert.ok(parts.join("").endsWith(text), "the cursors reconstruct the body exactly");
|
|
655
|
+
// A success carries structured details; an error stays a JSON envelope with no details.
|
|
656
|
+
const missingResult = await call(captured, "notes_read", { path: "no-such.md" }, ctx);
|
|
657
|
+
const missing = resultJson(missingResult);
|
|
658
|
+
assert.deepEqual(Object.keys(missing).sort(), ["error", "path"], "the read error carries exactly error and path");
|
|
659
|
+
assert.equal(missing.error, "note not found");
|
|
660
|
+
assert.equal(missingResult.details, undefined, "a JSON error carries no details metadata");
|
|
661
|
+
});
|
|
662
|
+
test("an over-budget note search match is a named prefix with an honest line address", async () => {
|
|
663
|
+
const session = manager();
|
|
664
|
+
const captured = makeExtension(session);
|
|
665
|
+
const ctx = context(session);
|
|
666
|
+
// The query sits behind a prefix, so its address is a real body-absolute offset, not line 1.
|
|
667
|
+
const hugeLine = `${'p'.repeat(500)}needle ${"y".repeat(TOOL_OUTPUT_MAX_BYTES * 2)}`;
|
|
668
|
+
await call(captured, "notes_write", { path: "a.md", content: "needle small" }, ctx);
|
|
669
|
+
await call(captured, "notes_write", { path: "search.md", content: hugeLine }, ctx);
|
|
670
|
+
const pages = [];
|
|
671
|
+
let cursor = 0;
|
|
672
|
+
let next = 0;
|
|
673
|
+
while (next !== null) {
|
|
674
|
+
const found = resultJson(await call(captured, "notes_search", { query: "needle", cursor }, ctx));
|
|
675
|
+
assert.ok(Buffer.byteLength(JSON.stringify(found), "utf8") <= TOOL_OUTPUT_MAX_BYTES, "match result stays within budget");
|
|
676
|
+
pages.push(...found.files);
|
|
677
|
+
next = found.next_cursor;
|
|
678
|
+
if (next !== null)
|
|
679
|
+
cursor = next;
|
|
680
|
+
}
|
|
681
|
+
assert.deepEqual(pages.map((file) => file.path), ["a.md", "search.md"], "pagination reaches the oversized file instead of looping");
|
|
682
|
+
const oversized = pages[1];
|
|
683
|
+
assert.equal(oversized.matches_total, 1, "the file's full match count is named even though the line was cut");
|
|
684
|
+
assert.equal(oversized.matches.length, 1);
|
|
685
|
+
const match = oversized.matches[0];
|
|
686
|
+
assert.equal(match.truncated, true, "the oversized match line is flagged as truncated");
|
|
687
|
+
assert.equal(match.total_chars, Array.from(hugeLine).length, "total_chars names the full line length");
|
|
688
|
+
assert.ok(hugeLine.startsWith(match.text), "the match text is a plain prefix of the line");
|
|
689
|
+
assert.equal(match.text.includes("…"), false, "no marker is appended to the match text");
|
|
690
|
+
assert.equal(match.offset_chars, 500, "the match carries the body-absolute offset of the query");
|
|
691
|
+
assert.equal(match.line, 1, "the informational line number survives");
|
|
692
|
+
// The body is reconstructible by following notes_read's cursor from the start of the file.
|
|
693
|
+
const parts = [];
|
|
694
|
+
let offset = 0;
|
|
695
|
+
while (offset !== null) {
|
|
696
|
+
const rawChunk = await call(captured, "notes_read", { path: "search.md", offset_chars: offset }, ctx);
|
|
697
|
+
assertWithinBudget(rawChunk, `search.md chunk at ${offset}`);
|
|
698
|
+
const chunk = resultRead(rawChunk);
|
|
699
|
+
assert.equal(chunk.offset_chars, offset, "the read echoes the resolved address");
|
|
700
|
+
parts.push(chunk.content);
|
|
701
|
+
offset = chunk.next_offset_chars;
|
|
702
|
+
}
|
|
703
|
+
assert.ok(parts.join("").endsWith(hugeLine), "resuming across pages reconstructs the matched body line");
|
|
704
|
+
});
|
|
705
|
+
test("history_read delivers a prefix and next_offset_chars names the delivered count", async () => {
|
|
706
|
+
const session = manager();
|
|
707
|
+
const captured = makeExtension(session);
|
|
708
|
+
const ctx = context(session);
|
|
709
|
+
const original = "z".repeat(TOOL_OUTPUT_MAX_BYTES * 3);
|
|
710
|
+
const id = appendText(session, "user", original);
|
|
711
|
+
const rawRead = await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, limit_chars: 50000 }, ctx);
|
|
712
|
+
assertWithinBudget(rawRead, "single history_read call");
|
|
713
|
+
const read = resultRead(rawRead);
|
|
714
|
+
assert.ok(read.content.length > 0, "the read is not empty");
|
|
715
|
+
assert.equal(read.content.includes("…"), false, "no marker is appended to the payload");
|
|
716
|
+
assert.ok(original.startsWith(read.content), "the delivered text is a prefix of the item");
|
|
717
|
+
assert.equal(read.total_chars, original.length);
|
|
718
|
+
assert.deepEqual(Object.keys(read.details), ["window_id", "item_id", "offset_chars", "total_chars", "next_offset_chars", "limit_chars"], "history_read details carries exactly the slim window metadata");
|
|
719
|
+
assert.equal("content" in read.details, false, "details never duplicates the payload");
|
|
720
|
+
assert.equal(read.next_offset_chars, read.offset_chars + Array.from(read.content).length, "the cursor is offset plus delivered code points");
|
|
721
|
+
assert.ok(read.next_offset_chars !== null && read.next_offset_chars < read.total_chars, "the cursor points at the first undelivered character");
|
|
722
|
+
// Following the cursor reaches the true end and reconstructs the item.
|
|
723
|
+
const parts = [read.content];
|
|
724
|
+
let offset = read.next_offset_chars;
|
|
725
|
+
let next = offset;
|
|
726
|
+
while (next !== null) {
|
|
727
|
+
const page = resultRead(await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, offset_chars: offset, limit_chars: 50000 }, ctx));
|
|
728
|
+
assert.equal(page.next_offset_chars, page.offset_chars + Array.from(page.content).length < page.total_chars ? page.offset_chars + Array.from(page.content).length : null, "the cursor is offset plus delivered, null only at item end");
|
|
729
|
+
parts.push(page.content);
|
|
730
|
+
next = page.next_offset_chars;
|
|
731
|
+
if (next !== null)
|
|
732
|
+
offset = next;
|
|
733
|
+
}
|
|
734
|
+
assert.equal(parts.join(""), original, "the cursors reconstruct the item exactly");
|
|
735
|
+
});
|
|
736
|
+
test("an empty body is a frontmatter-only file that terminates cleanly", async () => {
|
|
737
|
+
const session = manager();
|
|
738
|
+
const captured = makeExtension(session);
|
|
739
|
+
const ctx = context(session);
|
|
740
|
+
await call(captured, "notes_write", { path: "empty.md", content: "" }, ctx);
|
|
741
|
+
const empty = resultRead(await call(captured, "notes_read", { path: "empty.md" }, ctx));
|
|
742
|
+
assert.equal(empty.offset_chars, 0);
|
|
743
|
+
assert.ok(empty.content.startsWith("---\n"), "the frontmatter is still delivered");
|
|
744
|
+
assert.ok(empty.content.endsWith("---\n\n"), "an empty body leaves frontmatter and the blank separator only");
|
|
745
|
+
assert.ok(empty.total_chars > 0, "the file is not zero-length once the harness frontmatter is written");
|
|
746
|
+
assert.equal(empty.next_offset_chars, null, "a note that fits terminates instead of self-feeding");
|
|
747
|
+
// An offset beyond the file is an addressing error that names the real length,
|
|
748
|
+
// not a silent empty page.
|
|
749
|
+
const beyond = resultJson(await call(captured, "notes_read", { path: "empty.md", offset_chars: empty.total_chars + 9 }, ctx));
|
|
750
|
+
assert.match(beyond.error ?? "", /past the end/, "a beyond-the-file read is a named error");
|
|
751
|
+
assert.equal(beyond.offset_chars, empty.total_chars + 9, "the error echoes the offending offset");
|
|
752
|
+
assert.equal(beyond.total_chars, empty.total_chars, "the error names the real length");
|
|
753
|
+
});
|
|
754
|
+
test("history items carry honest truncated/total_chars and max_chars_per_item:1 addresses them", async () => {
|
|
755
|
+
const session = manager();
|
|
756
|
+
const captured = makeExtension(session);
|
|
757
|
+
const ctx = context(session);
|
|
758
|
+
const content = `${'padding '.repeat(400)}NEEDLE${' trailing'.repeat(400)}`;
|
|
759
|
+
const id = appendText(session, "user", content);
|
|
760
|
+
const list = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 5 }, ctx));
|
|
761
|
+
const listed = list.items.find((item) => item.item_id === id);
|
|
762
|
+
assert.equal(listed.truncated, true, "a capped item is flagged truncated");
|
|
763
|
+
assert.equal(listed.total_chars, Array.from(content).length, "total_chars is the full code-point length");
|
|
764
|
+
assert.equal(listed.truncated_content, 'paddi', "the payload is the longest fitting prefix, with no marker");
|
|
765
|
+
assert.equal(listed.truncated_content.includes("…"), false);
|
|
766
|
+
const whole = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 50_000 }, ctx));
|
|
767
|
+
const untruncated = whole.items.find((item) => item.item_id === id);
|
|
768
|
+
assert.equal(untruncated.truncated, false, "an item that fits is not flagged truncated");
|
|
769
|
+
assert.equal(untruncated.truncated_content, content, "a fitting item is returned whole");
|
|
770
|
+
const addresses = resultJson(await call(captured, "history_search", { query: "NEEDLE", max_chars_per_item: 1 }, ctx));
|
|
771
|
+
const address = addresses.items.find((item) => item.item_id === id);
|
|
772
|
+
assert.equal(Array.from(address.truncated_content).length, 1, "max_chars_per_item:1 delivers one code point");
|
|
773
|
+
assert.equal(address.truncated, true);
|
|
774
|
+
assert.equal(address.total_chars, Array.from(content).length);
|
|
775
|
+
const resolved = resultRead(await call(captured, "history_read", { window_id: historyFromSession(ctx)[0].windowId, item_id: id, offset_chars: address.match_offset_chars, limit_chars: 6 }, ctx));
|
|
776
|
+
assert.ok(resolved.content.includes("NEEDLE"), "the address resolves to the query through history_read");
|
|
777
|
+
});
|
|
778
|
+
test("tool calls wear their own role and assistant text stays pure", async () => {
|
|
779
|
+
const session = manager();
|
|
780
|
+
const captured = makeExtension(session);
|
|
781
|
+
const ctx = context(session);
|
|
782
|
+
const turnId = session.appendMessage({
|
|
783
|
+
role: "assistant",
|
|
784
|
+
content: [
|
|
785
|
+
{ type: "text", text: "on it" },
|
|
786
|
+
{ type: "toolCall", id: "tc-1", name: "bash", arguments: { command: "keiyaku status" } },
|
|
787
|
+
{ type: "toolCall", id: "tc-2", name: "notes_read", arguments: { path: "x.md" } },
|
|
788
|
+
],
|
|
789
|
+
stopReason: "stop",
|
|
790
|
+
timestamp: Date.now(),
|
|
791
|
+
});
|
|
792
|
+
const windowId = historyFromSession(ctx)[0].windowId;
|
|
793
|
+
const listed = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 50_000 }, ctx));
|
|
794
|
+
const turn = listed.items.find((item) => item.item_id === turnId);
|
|
795
|
+
assert.equal(turn.role, "assistant");
|
|
796
|
+
assert.equal(turn.tool_name, null, "the turn's text item carries no tool identity");
|
|
797
|
+
assert.equal(turn.truncated_content, "on it", "the turn item keeps only the visible text");
|
|
798
|
+
const call1 = listed.items.find((item) => item.item_id === `${turnId}#0`);
|
|
799
|
+
assert.equal(call1.role, "tool_call");
|
|
800
|
+
assert.equal(call1.tool_name, "bash");
|
|
801
|
+
assert.equal(call1.truncated_content, JSON.stringify({ command: "keiyaku status" }), "a call item's content is the call's JSON arguments");
|
|
802
|
+
const call2 = listed.items.find((item) => item.item_id === `${turnId}#1`);
|
|
803
|
+
assert.equal(call2.tool_name, "notes_read");
|
|
804
|
+
// The invocation is searchable exactly where a searcher reaches for it: tool_call + tool_name.
|
|
805
|
+
const calls = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "tool_call", tool_name: "bash" }, ctx));
|
|
806
|
+
assert.deepEqual(calls.items.map((item) => item.item_id), [`${turnId}#0`], "the command line is found on the call item, not the turn");
|
|
807
|
+
const assistantCalls = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "assistant" }, ctx));
|
|
808
|
+
assert.equal(assistantCalls.items.length, 0, "calls never leak into assistant text");
|
|
809
|
+
const assistantText = resultJson(await call(captured, "history_search", { query: "on it", role: "assistant" }, ctx));
|
|
810
|
+
assert.deepEqual(assistantText.items.map((item) => item.item_id), [turnId], "assistant search returns the turn's text item only");
|
|
811
|
+
const outputs = resultJson(await call(captured, "history_search", { query: "keiyaku status", role: "tool" }, ctx));
|
|
812
|
+
assert.equal(outputs.items.length, 0, "nothing ran, so no output carries the command");
|
|
813
|
+
const resolved = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: `${turnId}#0` }, ctx));
|
|
814
|
+
assert.equal(resolved.content, JSON.stringify({ command: "keiyaku status" }), "a call item resolves through history_read like any other");
|
|
815
|
+
});
|
|
816
|
+
test("a vacuous role×tool_name combination is a named error, not a silent empty page", async () => {
|
|
817
|
+
const session = manager();
|
|
818
|
+
const captured = makeExtension(session);
|
|
819
|
+
const ctx = context(session);
|
|
820
|
+
appendText(session, "user", "anything");
|
|
821
|
+
for (const tool of ["history_list", "history_search"]) {
|
|
822
|
+
const base = tool === "history_search" ? { query: "keiyaku" } : {};
|
|
823
|
+
const dead = resultJson(await call(captured, tool, { ...base, role: "assistant", tool_name: "bash" }, ctx));
|
|
824
|
+
assert.match(dead.error ?? "", /only set on "tool_call" and "tool"/, `${tool} names the rule`);
|
|
825
|
+
assert.equal(dead.role, "assistant", "the error echoes the offending role");
|
|
826
|
+
assert.equal(dead.tool_name, "bash", "the error echoes the offending tool_name");
|
|
827
|
+
for (const role of ["user", "system", "developer"]) {
|
|
828
|
+
const also = resultJson(await call(captured, tool, { ...base, role, tool_name: "bash" }, ctx));
|
|
829
|
+
assert.match(also.error ?? "", /never carries one/, `${tool} rejects role ${role} + tool_name too`);
|
|
830
|
+
}
|
|
831
|
+
for (const legit of [{ role: "tool_call", tool_name: "bash" }, { role: "tool", tool_name: "bash" }, { tool_name: "bash" }, { role: "assistant" }]) {
|
|
832
|
+
const fine = resultJson(await call(captured, tool, { ...base, ...legit }, ctx));
|
|
833
|
+
assert.equal(fine.error, undefined, `${tool} accepts ${JSON.stringify(legit)}`);
|
|
834
|
+
assert.ok(Array.isArray(fine.items), `${tool} returns a page for ${JSON.stringify(legit)}`);
|
|
835
|
+
}
|
|
836
|
+
const realWindow = historyFromSession(ctx)[0].windowId;
|
|
837
|
+
const badWindow = resultJson(await call(captured, tool, { ...base, window_id: "pcw:00000000:deadbeef" }, ctx));
|
|
838
|
+
assert.match(badWindow.error ?? "", /unknown window_id/, `${tool} names an unknown window_id`);
|
|
839
|
+
assert.equal(badWindow.window_id, "pcw:00000000:deadbeef", "the error echoes the offending window_id");
|
|
840
|
+
assert.deepEqual(badWindow.known_windows, [realWindow], "the error lists the known windows so it is self-healing");
|
|
841
|
+
const goodWindow = resultJson(await call(captured, tool, { ...base, window_id: realWindow }, ctx));
|
|
842
|
+
assert.equal(goodWindow.error, undefined, `${tool} accepts a real window_id`);
|
|
843
|
+
assert.ok(Array.isArray(goodWindow.items), `${tool} returns a page for a real window_id`);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
test("developer re-role names this extension's entries and leaves native compactions as system", async () => {
|
|
847
|
+
const session = manager();
|
|
848
|
+
const captured = makeExtension(session);
|
|
849
|
+
const ctx = context(session);
|
|
850
|
+
const foreignId = session.appendCustomMessageEntry("other/extension", "foreign custom body", false);
|
|
851
|
+
const extensionId = session.appendCustomMessageEntry(internal.BOOT_TYPE, "extension boot body", false);
|
|
852
|
+
const leaf = session.getLeafId();
|
|
853
|
+
assert.ok(leaf);
|
|
854
|
+
const resetId = session.appendCompaction("reset v2 summary", leaf, 100, { piContext: "reset-v2", windowId: "pcw:test:dev" }, true);
|
|
855
|
+
const nextLeaf = session.getLeafId();
|
|
856
|
+
assert.ok(nextLeaf);
|
|
857
|
+
const nativeId = session.appendCompaction("native summary", nextLeaf, 100, { readFiles: [], modifiedFiles: [] }, true);
|
|
858
|
+
const byRole = async (role) => resultJson(await call(captured, "history_list", { role, recent_first: false }, ctx)).items;
|
|
859
|
+
assert.deepEqual((await byRole("developer")).map((item) => item.item_id), [extensionId, resetId], "developer names exactly this extension's entries");
|
|
860
|
+
assert.deepEqual((await byRole("system")).map((item) => item.item_id), [nativeId], "system stays native Pi compactions only");
|
|
861
|
+
assert.deepEqual((await byRole("user")).map((item) => item.item_id), [foreignId], "foreign custom messages stay user turns");
|
|
862
|
+
});
|
|
863
|
+
test("oversized history tool_name: page stays within budget, item_id intact, metadata visibly truncated", async () => {
|
|
864
|
+
const session = manager();
|
|
865
|
+
const captured = makeExtension(session);
|
|
866
|
+
const ctx = context(session);
|
|
867
|
+
const hugeToolName = `oversized_${"t".repeat(40_000)}`;
|
|
868
|
+
const itemId = appendText(session, "toolResult", "tool output line", hugeToolName);
|
|
869
|
+
const listed = resultJson(await call(captured, "history_list", { recent_first: false, max_chars_per_item: 1200 }, ctx));
|
|
870
|
+
const listedBytes = Buffer.byteLength(JSON.stringify(listed), "utf8");
|
|
871
|
+
console.log(`pathological page bytes: history_list tool_name=40KB -> ${listedBytes}`);
|
|
872
|
+
assert.ok(listedBytes <= TOOL_OUTPUT_MAX_BYTES, `oversized tool_name list page is ${listedBytes} bytes`);
|
|
873
|
+
assert.equal(listed.items.length, 1);
|
|
874
|
+
assert.equal(listed.items[0].item_id, itemId, "item_id identity is untouched");
|
|
875
|
+
assert.equal(listed.items[0].truncated_content, "tool output line", "the payload is preserved when only metadata is oversized");
|
|
876
|
+
assert.match(listed.items[0].tool_name, /…\[truncated \d+ chars\]…/, "tool_name carries the truncation marker");
|
|
877
|
+
const searched = resultJson(await call(captured, "history_search", { query: "tool output", recent_first: false }, ctx));
|
|
878
|
+
const searchedBytes = Buffer.byteLength(JSON.stringify(searched), "utf8");
|
|
879
|
+
console.log(`pathological page bytes: history_search tool_name=40KB -> ${searchedBytes}`);
|
|
880
|
+
assert.ok(searchedBytes <= TOOL_OUTPUT_MAX_BYTES, `oversized tool_name search page is ${searchedBytes} bytes`);
|
|
881
|
+
assert.equal(searched.items.length, 1);
|
|
882
|
+
assert.equal(searched.items[0].item_id, itemId, "search keeps item_id identity");
|
|
883
|
+
assert.match(searched.items[0].tool_name, /…\[truncated \d+ chars\]…/, "search truncates the oversized tool_name visibly");
|
|
884
|
+
});
|
|
885
|
+
test("page() includes one middle-truncated item and advances the cursor", () => {
|
|
886
|
+
const truncate = (item, fits) => ({ ...item, text: middleTruncate(item.text, (candidate) => fits({ ...item, text: candidate })) });
|
|
887
|
+
const first = page([{ text: "a".repeat(TOOL_OUTPUT_MAX_BYTES * 2) }, { text: "b" }], 0, "items", undefined, truncate);
|
|
888
|
+
assert.equal(first.items.length, 1, "the oversized item is included, not skipped");
|
|
889
|
+
assert.match(first.items[0].text, /…\[truncated \d+ chars\]…/);
|
|
890
|
+
assert.equal(first.next_cursor, 1, "the cursor advances past the truncated item");
|
|
891
|
+
assert.ok(Buffer.byteLength(JSON.stringify(first), "utf8") <= TOOL_OUTPUT_MAX_BYTES);
|
|
892
|
+
const last = page([{ text: "a".repeat(TOOL_OUTPUT_MAX_BYTES * 2) }], 0, "items", undefined, truncate);
|
|
893
|
+
assert.equal(last.items.length, 1);
|
|
894
|
+
assert.equal(last.next_cursor, null, "the final oversized item terminates pagination");
|
|
895
|
+
// An oversized item behind a fitting one must not stall: the next page starts on it.
|
|
896
|
+
const behind = page([{ text: "small" }, { text: "c".repeat(TOOL_OUTPUT_MAX_BYTES * 2) }, { text: "tail" }], 0, "items", undefined, truncate);
|
|
897
|
+
assert.equal(behind.items.length, 1);
|
|
898
|
+
assert.equal(behind.next_cursor, 1);
|
|
899
|
+
const resumed = page([{ text: "small" }, { text: "c".repeat(TOOL_OUTPUT_MAX_BYTES * 2) }, { text: "tail" }], 1, "items", undefined, truncate);
|
|
900
|
+
assert.equal(resumed.items.length, 1, "the resumed page carries the truncated item");
|
|
901
|
+
assert.match(resumed.items[0].text, /…\[truncated \d+ chars\]…/);
|
|
902
|
+
assert.equal(resumed.next_cursor, 2, "pagination advances toward the remaining item");
|
|
903
|
+
});
|
|
904
|
+
test("note write/edit tools run sequentially so a parallel batch cannot race the note store", () => {
|
|
905
|
+
const captured = makeExtension(manager());
|
|
906
|
+
for (const name of ["notes_write", "notes_edit"]) {
|
|
907
|
+
assert.equal(captured.tools.get(name)?.executionMode, "sequential", `${name} forbids parallel execution`);
|
|
908
|
+
}
|
|
909
|
+
assert.equal(captured.tools.get("notes_read")?.executionMode, undefined, "read-only note tools keep the default mode");
|
|
910
|
+
});
|
|
911
|
+
test("custom reset boundary removes old provider context but history remains searchable", async () => {
|
|
912
|
+
const sessionManager = manager();
|
|
913
|
+
const captured = makeExtension(sessionManager);
|
|
914
|
+
const ctx = context(sessionManager);
|
|
915
|
+
const oldUserId = appendText(sessionManager, "user", "OLD-UNIQUE-TRANSCRIPT needle");
|
|
916
|
+
appendText(sessionManager, "assistant", "I will use a tool");
|
|
917
|
+
const toolResultId = appendText(sessionManager, "toolResult", "tool result safely recorded");
|
|
918
|
+
const before = await runBeforeCompact(captured, ctx, 123);
|
|
919
|
+
assert.ok(before && "compaction" in before);
|
|
920
|
+
const markerId = sessionManager.getLeafId();
|
|
921
|
+
assert.ok(markerId);
|
|
922
|
+
const marker = sessionManager.getEntry(markerId);
|
|
923
|
+
assert.ok(marker);
|
|
924
|
+
assert.equal(marker.parentId, toolResultId, "marker follows the completed tool result");
|
|
925
|
+
const compactionId = sessionManager.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, before.compaction.tokensBefore, before.compaction.details, true);
|
|
926
|
+
const providerText = JSON.stringify(sessionManager.buildSessionContext().messages);
|
|
927
|
+
assert.equal(providerText.includes("OLD-UNIQUE-TRANSCRIPT"), false);
|
|
928
|
+
assert.equal(providerText.includes(internal.CONTEXT_WINDOW_OPEN_TAG), true);
|
|
929
|
+
const windows = historyFromSession(ctx);
|
|
930
|
+
assert.equal(windows.length, 2);
|
|
931
|
+
const oldWindow = windows[0]?.windowId;
|
|
932
|
+
assert.ok(oldWindow);
|
|
933
|
+
const read = resultRead(await call(captured, "history_read", { window_id: oldWindow, item_id: oldUserId }, ctx));
|
|
934
|
+
assert.match(read.content, /OLD-UNIQUE-TRANSCRIPT/);
|
|
935
|
+
const found = resultJson(await call(captured, "history_search", { query: "needle" }, ctx));
|
|
936
|
+
assert.equal(found.items.length, 1);
|
|
937
|
+
assert.equal(found.items[0]?.item_id, oldUserId);
|
|
938
|
+
assert.ok(sessionManager.getEntry(compactionId));
|
|
939
|
+
});
|
|
940
|
+
test("the boot notes preview keeps short notes whole and long notes head-to-tail", async () => {
|
|
941
|
+
const sessionManager = manager();
|
|
942
|
+
const captured = makeExtension(sessionManager);
|
|
943
|
+
const ctx = context(sessionManager);
|
|
944
|
+
// Unique Unicode code points so an overlap introduced by a naive head+tail concat is detectable.
|
|
945
|
+
const longText = Array.from({ length: 400 }, (_, index) => String.fromCharCode(0x4e00 + index)).join("");
|
|
946
|
+
const shortText = "short-first\nshort-second";
|
|
947
|
+
await call(captured, "notes_write", { path: "long.md", content: longText }, ctx);
|
|
948
|
+
await call(captured, "notes_write", { path: "short.md", content: shortText }, ctx);
|
|
949
|
+
runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
950
|
+
const boot = captured.sent[0];
|
|
951
|
+
const text = typeof boot?.message.content === "string" ? boot.message.content : "";
|
|
952
|
+
assert.ok(text.includes("long.md") && text.includes("short.md"), "both notes are indexed");
|
|
953
|
+
// Short note: complete, with its newline preserved and each line indented 2 spaces.
|
|
954
|
+
assert.ok(text.includes(" short-first\n short-second"), "short note text is shown whole and indented");
|
|
955
|
+
// Long note preview: exactly first 80 + separator + last 240 Unicode characters.
|
|
956
|
+
const chars = Array.from(longText);
|
|
957
|
+
const head = chars.slice(0, 80).join("");
|
|
958
|
+
const tail = chars.slice(chars.length - 240).join("");
|
|
959
|
+
const previewLine = text.split("\n").find((line) => line.startsWith(" ") && line.includes("…"));
|
|
960
|
+
assert.ok(previewLine, "long note carries an ellipsis preview line");
|
|
961
|
+
const preview = Array.from(previewLine.slice(2));
|
|
962
|
+
assert.ok(previewLine.includes(head), "long preview keeps the head");
|
|
963
|
+
assert.ok(previewLine.includes(tail), "long preview keeps the tail");
|
|
964
|
+
assert.equal(preview.length, 321, "head 80 + one separator + tail 240, nothing duplicated");
|
|
965
|
+
assert.equal(previewLine.includes(longText), false, "long note is truncated, not shown whole");
|
|
966
|
+
});
|
|
967
|
+
test("the boot block is persisted at the root and baked into every reset summary", async () => {
|
|
968
|
+
const sessionManager = manager();
|
|
969
|
+
const captured = makeExtension(sessionManager);
|
|
970
|
+
const ctx = context(sessionManager);
|
|
971
|
+
appendText(sessionManager, "user", "task before reset");
|
|
972
|
+
appendText(sessionManager, "assistant", "working");
|
|
973
|
+
await call(captured, "notes_write", { path: "decisions.md", content: "use terra" }, ctx);
|
|
974
|
+
// Root window: session_start persists the boot block without triggering a turn.
|
|
975
|
+
runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
976
|
+
assert.equal(captured.sent.length, 1);
|
|
977
|
+
const rootBoot = captured.sent[0];
|
|
978
|
+
assert.equal(rootBoot?.message.customType, internal.BOOT_TYPE);
|
|
979
|
+
assert.equal(rootBoot?.message.display, false, "boot block stays out of the TUI");
|
|
980
|
+
assert.equal(rootBoot?.options?.triggerTurn, false);
|
|
981
|
+
const rootText = typeof rootBoot?.message.content === "string" ? rootBoot.message.content : "";
|
|
982
|
+
assert.ok(rootText.startsWith(internal.CONTEXT_WINDOW_OPEN_TAG), "root block omits the reset line");
|
|
983
|
+
assert.equal(rootText.includes("Previous context window id:"), false, "root block omits the previous-id line");
|
|
984
|
+
assert.match(rootText, new RegExp(`First context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
|
|
985
|
+
assert.match(rootText, new RegExp(`Current context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
|
|
986
|
+
assert.ok(rootText.includes("decisions.md"));
|
|
987
|
+
const decisionsMeta = listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta;
|
|
988
|
+
assert.ok(decisionsMeta);
|
|
989
|
+
const bootUpdated = assertIsoTimestamp(rootText, "note metadata carries an updated timestamp");
|
|
990
|
+
assert.equal(Date.parse(bootUpdated), decisionsMeta.updated_at, "boot note timestamp restores the persisted updatedAt");
|
|
991
|
+
assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
|
|
992
|
+
// Reset: the boot block IS the compaction summary; no separate boot/hint is persisted.
|
|
993
|
+
await call(captured, "new_context", {}, ctx);
|
|
994
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
995
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
996
|
+
const before = await runBeforeCompact(captured, ctx, 9);
|
|
997
|
+
assert.ok(before && "compaction" in before);
|
|
998
|
+
const details = before.compaction.details;
|
|
999
|
+
assert.equal(details.piContext, "reset-v2");
|
|
1000
|
+
assert.match(details.windowId, new RegExp(`^pcw:${sessionManager.getSessionId().slice(0, 8)}:[0-9a-f]{8}$`));
|
|
1001
|
+
assert.equal(before.compaction.summary.startsWith(internal.CONTEXT_WINDOW_OPEN_TAG), false, "a reset line precedes the identity block");
|
|
1002
|
+
assert.match(before.compaction.summary, new RegExp(`Current context window id: ${details.windowId}`));
|
|
1003
|
+
assert.ok(before.compaction.summary.includes("decisions.md"));
|
|
1004
|
+
const resetUpdated = assertIsoTimestamp(before.compaction.summary, "reset summary keeps the note updated timestamp");
|
|
1005
|
+
assert.equal(Date.parse(resetUpdated), decisionsMeta.updated_at, "reset summary keeps the persisted updatedAt");
|
|
1006
|
+
assert.ok(before.compaction.summary.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
|
|
1007
|
+
const windows = historyFromSession(ctx);
|
|
1008
|
+
assert.ok(before.compaction.summary.includes(`Previous context window id: ${windows[windows.length - 1]?.windowId}`));
|
|
1009
|
+
const compactionId = sessionManager.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 9, details, true);
|
|
1010
|
+
const compactionEntry = sessionManager.getEntry(compactionId);
|
|
1011
|
+
assert.ok(compactionEntry && compactionEntry.type === "compaction");
|
|
1012
|
+
runHandlers(captured, "session_compact", { willRetry: false, compactionEntry }, ctx);
|
|
1013
|
+
completeRequestedCompaction(ctx);
|
|
1014
|
+
// Only the hidden continuation follows a reset; no pi-context/boot message is written.
|
|
1015
|
+
assert.equal(captured.sent.length, 2);
|
|
1016
|
+
assert.equal(captured.sent[1]?.message.display, false);
|
|
1017
|
+
assert.equal(captured.sent[1]?.options?.triggerTurn, true);
|
|
1018
|
+
assert.equal(await runContextHook(captured, ctx), undefined, "context hook never injects");
|
|
1019
|
+
});
|
|
1020
|
+
test("reset window ids are extension-minted and drive history_* lookups", async () => {
|
|
1021
|
+
const sessionManager = manager();
|
|
1022
|
+
const captured = makeExtension(sessionManager);
|
|
1023
|
+
const ctx = context(sessionManager);
|
|
1024
|
+
appendText(sessionManager, "user", "task before reset");
|
|
1025
|
+
const before = await runBeforeCompact(captured, ctx, 100);
|
|
1026
|
+
assert.ok(before && "compaction" in before);
|
|
1027
|
+
const details = before.compaction.details;
|
|
1028
|
+
const compactionId = sessionManager.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 100, details, true);
|
|
1029
|
+
const compactionEntry = sessionManager.getEntry(compactionId);
|
|
1030
|
+
assert.ok(compactionEntry && compactionEntry.type === "compaction");
|
|
1031
|
+
// history_windows reports exactly the minted id carried in details.
|
|
1032
|
+
// The default is newest-first, so the current window is listed first.
|
|
1033
|
+
const windows = resultJson(await call(captured, "history_windows", {}, ctx));
|
|
1034
|
+
assert.equal(windows.windows.length, 2);
|
|
1035
|
+
assert.equal(windows.windows[0]?.window_id, details.windowId, "recent_first defaults to newest-first");
|
|
1036
|
+
// Only an explicit false restores oldest-first window order.
|
|
1037
|
+
const oldestWindows = resultJson(await call(captured, "history_windows", { recent_first: false }, ctx));
|
|
1038
|
+
assert.equal(oldestWindows.windows[0]?.window_id, `pcw:${sessionManager.getSessionId().slice(0, 8)}:root`, "explicit false keeps the oldest window first");
|
|
1039
|
+
assert.equal(oldestWindows.windows[1]?.window_id, details.windowId);
|
|
1040
|
+
// The minted id is Pi's 8-hex entry-id shape, but the window id is ours.
|
|
1041
|
+
assert.match(details.windowId, new RegExp(`^pcw:${sessionManager.getSessionId().slice(0, 8)}:[0-9a-f]{8}$`));
|
|
1042
|
+
// history_* accepts the minted window id and resolves the baked summary item.
|
|
1043
|
+
const listed = resultJson(await call(captured, "history_list", { window_id: details.windowId }, ctx));
|
|
1044
|
+
assert.equal(listed.items.length, 1);
|
|
1045
|
+
assert.equal(listed.items[0]?.item_id, compactionEntry.id);
|
|
1046
|
+
});
|
|
1047
|
+
test("recent_first defaults to newest-first for items and search; only false is oldest-first", async () => {
|
|
1048
|
+
const sessionManager = manager();
|
|
1049
|
+
const captured = makeExtension(sessionManager);
|
|
1050
|
+
const ctx = context(sessionManager);
|
|
1051
|
+
const firstId = appendText(sessionManager, "user", "needle alpha");
|
|
1052
|
+
const secondId = appendText(sessionManager, "assistant", "needle beta");
|
|
1053
|
+
const thirdId = appendText(sessionManager, "user", "needle gamma");
|
|
1054
|
+
const listOrder = async (params) => resultJson(await call(captured, "history_list", params, ctx)).items.map((item) => item.item_id);
|
|
1055
|
+
assert.deepEqual(await listOrder({}), [thirdId, secondId, firstId], "omitted recent_first lists the newest item first");
|
|
1056
|
+
assert.deepEqual(await listOrder({ recent_first: true }), [thirdId, secondId, firstId], "recent_first true lists the newest item first");
|
|
1057
|
+
assert.deepEqual(await listOrder({ recent_first: false }), [firstId, secondId, thirdId], "explicit false lists the oldest item first");
|
|
1058
|
+
const searchOrder = async (params) => resultJson(await call(captured, "history_search", { query: "needle", ...params }, ctx)).items.map((item) => item.item_id);
|
|
1059
|
+
assert.deepEqual(await searchOrder({}), [thirdId, secondId, firstId], "search shares the newest-first default");
|
|
1060
|
+
assert.deepEqual(await searchOrder({ recent_first: false }), [firstId, secondId, thirdId], "search honours an explicit false");
|
|
1061
|
+
});
|
|
1062
|
+
test("a Pi-native compaction with the extension off keeps entry.id as the window id", async () => {
|
|
1063
|
+
const sessionManager = manager();
|
|
1064
|
+
const captured = makeExtension(sessionManager);
|
|
1065
|
+
const ctx = context(sessionManager);
|
|
1066
|
+
appendText(sessionManager, "user", "native compaction");
|
|
1067
|
+
await runCommand(captured, "pi-context", "off", ctx);
|
|
1068
|
+
const compactionId = sessionManager.appendCompaction("Pi native summary", sessionManager.getLeafId(), 100, { readFiles: [], modifiedFiles: [] }, true);
|
|
1069
|
+
const windows = resultJson(await call(captured, "history_windows", {}, ctx));
|
|
1070
|
+
assert.equal(windows.windows[0]?.window_id, `pcw:${sessionManager.getSessionId().slice(0, 8)}:${compactionId}`, "native compactions fall back to entry.id");
|
|
1071
|
+
});
|
|
1072
|
+
test("a reset window baked under the older full-session id still projects and resolves opaquely", async () => {
|
|
1073
|
+
const sessionManager = manager();
|
|
1074
|
+
const captured = makeExtension(sessionManager);
|
|
1075
|
+
const ctx = context(sessionManager);
|
|
1076
|
+
const sessionId = sessionManager.getSessionId();
|
|
1077
|
+
const projectedRoot = `pcw:${sessionId.slice(0, 8)}:root`;
|
|
1078
|
+
// A window id baked by the pre-shortening extension: the full session id is part of the opaque string.
|
|
1079
|
+
const oldWindowId = `pcw:${sessionId}:deadbeef`;
|
|
1080
|
+
const rootItemId = appendText(sessionManager, "user", "message before the old reset");
|
|
1081
|
+
const markerId = sessionManager.getLeafId();
|
|
1082
|
+
assert.ok(markerId);
|
|
1083
|
+
const compactionId = sessionManager.appendCompaction("old reset summary", markerId, 100, { piContext: "reset-v2", windowId: oldWindowId }, true);
|
|
1084
|
+
const currentItemId = appendText(sessionManager, "assistant", "message after the old reset");
|
|
1085
|
+
// The old session still projects both windows: the computed short root and the opaque baked window.
|
|
1086
|
+
const windows = resultJson(await call(captured, "history_windows", { recent_first: false }, ctx));
|
|
1087
|
+
assert.deepEqual(windows.windows.map((window) => window.window_id), [projectedRoot, oldWindowId], "both the short root and the older opaque id project");
|
|
1088
|
+
// history_read resolves items by the older opaque window id, and by the computed root.
|
|
1089
|
+
const oldRead = resultRead(await call(captured, "history_read", { window_id: oldWindowId, item_id: compactionId }, ctx));
|
|
1090
|
+
assert.equal(oldRead.content, "old reset summary");
|
|
1091
|
+
const oldCurrent = resultRead(await call(captured, "history_read", { window_id: oldWindowId, item_id: currentItemId }, ctx));
|
|
1092
|
+
assert.equal(oldCurrent.content, "message after the old reset");
|
|
1093
|
+
const rootRead = resultRead(await call(captured, "history_read", { window_id: projectedRoot, item_id: rootItemId }, ctx));
|
|
1094
|
+
assert.equal(rootRead.content, "message before the old reset");
|
|
1095
|
+
// No normalization: the read path matches window ids exactly and never rewrites an older spelling.
|
|
1096
|
+
const unrewritten = resultJson(await call(captured, "history_read", { window_id: `pcw:${sessionId}:root`, item_id: rootItemId }, ctx));
|
|
1097
|
+
assert.match(unrewritten.error ?? "", /unknown item_id or window_id/);
|
|
1098
|
+
});
|
|
1099
|
+
test("low-budget guidance persists once per window with no transient copy", async () => {
|
|
1100
|
+
const sessionManager = manager();
|
|
1101
|
+
const captured = makeExtension(sessionManager);
|
|
1102
|
+
// Comfortable usage: the hook injects nothing and persists nothing.
|
|
1103
|
+
const comfortable = context(sessionManager, undefined, { tokens: 10_000, percent: 5, contextWindow: 200_000 });
|
|
1104
|
+
assert.equal(await runContextHook(captured, comfortable), undefined, "nothing injected above the reminder");
|
|
1105
|
+
assert.equal(captured.sent.length, 0);
|
|
1106
|
+
// Unknown usage (right after compaction): stay silent.
|
|
1107
|
+
const unknown = context(sessionManager, undefined, { tokens: null, percent: null, contextWindow: 200_000 });
|
|
1108
|
+
assert.equal(await runContextHook(captured, unknown), undefined);
|
|
1109
|
+
// Below threshold: persist once (hidden from the TUI; the user gets one ephemeral
|
|
1110
|
+
// notify instead, no turn triggered) and return no transient copy — history and
|
|
1111
|
+
// the model's view never diverge on position.
|
|
1112
|
+
const low = context(sessionManager, undefined, { tokens: 170_000, percent: 85, contextWindow: 200_000 });
|
|
1113
|
+
assert.equal(await runContextHook(captured, low), undefined, "the context hook injects nothing");
|
|
1114
|
+
assert.equal(captured.sent.length, 1, "persisted exactly once");
|
|
1115
|
+
assert.equal(captured.sent[0]?.message.customType, internal.GUIDANCE_TYPE);
|
|
1116
|
+
assert.equal(captured.sent[0]?.message.display, false, "guidance stays out of the TUI");
|
|
1117
|
+
assert.equal(captured.sent[0]?.options?.triggerTurn, false, "never triggers an extra turn");
|
|
1118
|
+
assert.ok(noticesOf(low).some((notice) => notice.type === "warning" && notice.message.startsWith("pi-context: context budget low")), "the user gets one model-invisible notify instead");
|
|
1119
|
+
const text = captured.sent[0]?.message.content;
|
|
1120
|
+
assert.ok(typeof text === "string" && text.startsWith(internal.GUIDANCE_OPEN_TAG));
|
|
1121
|
+
assert.match(text, /\b1328 tokens\b/, "guidance embeds the model-visible remaining count");
|
|
1122
|
+
// Same window: no duplicate persist.
|
|
1123
|
+
assert.equal(await runContextHook(captured, low), undefined);
|
|
1124
|
+
assert.equal(captured.sent.length, 1, "no duplicate persist, so no re-render");
|
|
1125
|
+
// A reset boundary creates a new window: the reminder re-arms and carries its own measured count.
|
|
1126
|
+
const before = await runBeforeCompact(captured, low, 190_000);
|
|
1127
|
+
assert.ok(before && "compaction" in before);
|
|
1128
|
+
sessionManager.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 190_000, before.compaction.details, true);
|
|
1129
|
+
const newWindow = context(sessionManager, undefined, { tokens: 168_000, percent: 84, contextWindow: 200_000 });
|
|
1130
|
+
assert.equal(await runContextHook(captured, newWindow), undefined, "still no injection in the fresh window");
|
|
1131
|
+
assert.equal(captured.sent.length, 2);
|
|
1132
|
+
const newWindowText = captured.sent[1]?.message.content;
|
|
1133
|
+
assert.ok(typeof newWindowText === "string" && newWindowText.startsWith(internal.GUIDANCE_OPEN_TAG));
|
|
1134
|
+
assert.match(newWindowText, /\b3328 tokens\b/, "fresh window persists its own measured count");
|
|
1135
|
+
});
|
|
1136
|
+
test("new_context continues exactly once and cancellation/failure does not fall back or loop", async () => {
|
|
1137
|
+
const sessionManager = manager();
|
|
1138
|
+
const captured = makeExtension(sessionManager);
|
|
1139
|
+
let requestedCompact;
|
|
1140
|
+
const ctx = context(sessionManager, (options) => {
|
|
1141
|
+
requestedCompact = options;
|
|
1142
|
+
});
|
|
1143
|
+
appendText(sessionManager, "user", "enough history for the hook test");
|
|
1144
|
+
const newContext = await call(captured, "new_context", {}, ctx);
|
|
1145
|
+
assert.equal(newContext.terminate, true);
|
|
1146
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1147
|
+
assert.equal(requestedCompact, undefined, "agent_end does not request compaction while the run is active");
|
|
1148
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1149
|
+
assert.ok(requestedCompact, "manual compaction is deferred until agent_settled");
|
|
1150
|
+
const before = await runBeforeCompact(captured, ctx, 7);
|
|
1151
|
+
assert.ok(before && "compaction" in before);
|
|
1152
|
+
const compactionId = sessionManager.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 7, before.compaction.details, true);
|
|
1153
|
+
const compactionEntry = sessionManager.getEntry(compactionId);
|
|
1154
|
+
assert.ok(compactionEntry && compactionEntry.type === "compaction");
|
|
1155
|
+
const compactEvent = { willRetry: false, compactionEntry };
|
|
1156
|
+
runHandlers(captured, "session_compact", compactEvent, ctx);
|
|
1157
|
+
runHandlers(captured, "session_compact", compactEvent, ctx);
|
|
1158
|
+
assert.equal(captured.sent.length, 0, "the hook never starts a run while compaction is active");
|
|
1159
|
+
completeRequestedCompaction(ctx);
|
|
1160
|
+
assert.equal(captured.sent.length, 1, "exactly one hidden continuation and no hint");
|
|
1161
|
+
assert.equal(captured.sent[0]?.message.display, false);
|
|
1162
|
+
assert.equal(captured.sent[0]?.options?.triggerTurn, true);
|
|
1163
|
+
const failedManager = manager();
|
|
1164
|
+
const failed = makeExtension(failedManager);
|
|
1165
|
+
let failureOptions;
|
|
1166
|
+
const failedCtx = context(failedManager, (options) => {
|
|
1167
|
+
failureOptions = options;
|
|
1168
|
+
});
|
|
1169
|
+
await call(failed, "new_context", {}, failedCtx);
|
|
1170
|
+
runHandlers(failed, "agent_end", {}, failedCtx);
|
|
1171
|
+
runHandlers(failed, "agent_settled", {}, failedCtx);
|
|
1172
|
+
assert.ok(failureOptions?.onError);
|
|
1173
|
+
failureOptions.onError(new Error("not compactable"));
|
|
1174
|
+
runHandlers(failed, "session_compact", compactEvent, failedCtx);
|
|
1175
|
+
assert.equal(failed.sent.length, 0, "failure does not send an accidental continuation");
|
|
1176
|
+
const aborted = await runBeforeCompactAborted(failed, failedCtx);
|
|
1177
|
+
assert.deepEqual(aborted, { cancel: true }, "aborted custom compaction cannot fall through to Pi default summary");
|
|
1178
|
+
runHandlers(failed, "session_compact", { ...compactEvent, willRetry: true }, failedCtx);
|
|
1179
|
+
assert.equal(failed.sent.length, 0, "native overflow retry is left to Pi core, not doubled by the extension");
|
|
1180
|
+
});
|
|
1181
|
+
async function runBeforeCompactAborted(captured, ctx) {
|
|
1182
|
+
const handler = captured.handlers.get("session_before_compact")?.[0];
|
|
1183
|
+
assert.ok(handler);
|
|
1184
|
+
const event = { reason: "manual", willRetry: false, signal: AbortSignal.abort(), preparation: { tokensBefore: 7 } };
|
|
1185
|
+
return (await handler(event, ctx));
|
|
1186
|
+
}
|
|
1187
|
+
test("pi-context command toggles the boot block, guidance, and reset compaction at runtime", async () => {
|
|
1188
|
+
const sessionManager = manager();
|
|
1189
|
+
appendText(sessionManager, "user", "hello");
|
|
1190
|
+
const captured = makeExtension(sessionManager);
|
|
1191
|
+
const low = context(sessionManager, undefined, { tokens: 170_000, contextWindow: 200_000, percent: 85 });
|
|
1192
|
+
// On by default: session_start persists the root boot block; the low budget persists guidance.
|
|
1193
|
+
runHandlers(captured, "session_start", { reason: "startup" }, low);
|
|
1194
|
+
assert.equal(captured.sent.length, 1);
|
|
1195
|
+
assert.equal(captured.sent[0]?.message.customType, internal.BOOT_TYPE);
|
|
1196
|
+
assert.equal(await runContextHook(captured, low), undefined, "the context hook never injects");
|
|
1197
|
+
assert.equal(captured.sent.length, 2, "guidance persisted");
|
|
1198
|
+
assert.equal(captured.sent[1]?.message.customType, internal.GUIDANCE_TYPE);
|
|
1199
|
+
let notices = await runCommand(captured, "pi-context", "off", low);
|
|
1200
|
+
assert.match(notices[0]?.message ?? "", /off/);
|
|
1201
|
+
assert.equal(await runContextHook(captured, low), undefined, "no guidance while off");
|
|
1202
|
+
assert.equal(captured.sent.length, 2, "no guidance persisted while off");
|
|
1203
|
+
runHandlers(captured, "session_start", { reason: "startup" }, low);
|
|
1204
|
+
assert.equal(captured.sent.length, 2, "no boot block persisted while off");
|
|
1205
|
+
assert.equal(await runBeforeCompact(captured, low, 123), undefined, "default Pi compaction applies while off");
|
|
1206
|
+
const offResult = resultJson(await call(captured, "new_context", {}, low));
|
|
1207
|
+
assert.match(offResult.error ?? "", /off/, "new_context refuses while off");
|
|
1208
|
+
notices = await runCommand(captured, "pi-context", "on", low);
|
|
1209
|
+
assert.match(notices[0]?.message ?? "", /on/);
|
|
1210
|
+
runHandlers(captured, "session_start", { reason: "startup" }, low);
|
|
1211
|
+
assert.equal(captured.sent.length, 2, "re-enable preserves the existing boot block without duplication");
|
|
1212
|
+
notices = await runCommand(captured, "pi-context", "maybe", low);
|
|
1213
|
+
assert.equal(notices[0]?.type, "error", "unknown argument rejected");
|
|
1214
|
+
// Bare command reports current state without changing it.
|
|
1215
|
+
notices = await runCommand(captured, "pi-context", "", low);
|
|
1216
|
+
assert.match(notices[0]?.message ?? "", /on/);
|
|
1217
|
+
});
|
|
1218
|
+
test("every compaction path resets instantly for every reason, idle or streaming", async () => {
|
|
1219
|
+
for (const reason of ["manual", "threshold", "overflow"]) {
|
|
1220
|
+
for (const idle of [false, true]) {
|
|
1221
|
+
const sm = manager();
|
|
1222
|
+
appendText(sm, "user", "long task history");
|
|
1223
|
+
const captured = makeExtension(sm);
|
|
1224
|
+
let compactions = 0;
|
|
1225
|
+
const ctx = context(sm, () => { compactions++; }, undefined, idle);
|
|
1226
|
+
assert.equal(captured.handlers.has("input"), false, "no user-input interception");
|
|
1227
|
+
for (let window = 0; window < 2; window++) {
|
|
1228
|
+
// The warning steer already fired from the context hook, so the compaction
|
|
1229
|
+
// request itself is the wipe: every reason, idle or mid-run, resets on the
|
|
1230
|
+
// spot with no model turn in between and nothing sent.
|
|
1231
|
+
const before = await runBeforeCompact(captured, ctx, 100, reason);
|
|
1232
|
+
assert.ok(before && "compaction" in before, `${reason}, idle=${idle}: resets directly`);
|
|
1233
|
+
assert.equal(captured.sent.length, 0, `${reason}, idle=${idle}: no steer, no continuation`);
|
|
1234
|
+
const details = before.compaction.details;
|
|
1235
|
+
assert.equal(details.piContext, "reset-v2");
|
|
1236
|
+
assert.match(before.compaction.summary, new RegExp(`Current context window id: ${details.windowId}`));
|
|
1237
|
+
const id = sm.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 100, details, true);
|
|
1238
|
+
const compactionEntry = sm.getEntry(id);
|
|
1239
|
+
assert.ok(compactionEntry && compactionEntry.type === "compaction");
|
|
1240
|
+
const event = { reason, willRetry: idle && reason === "overflow", compactionEntry };
|
|
1241
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1242
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1243
|
+
assert.equal(compactions, 0, `${reason}, idle=${idle}: an instant reset never asks ctx.compact()`);
|
|
1244
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1245
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1246
|
+
assert.equal(compactions, 0, `${reason}, idle=${idle}: settling after an instant reset is a no-op`);
|
|
1247
|
+
assert.equal(captured.sent.length, 0, `${reason}, idle=${idle}: nothing is ever sent`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
test("the warning steer fires once per window at the reserve-plus-warning line, then crossings reset instantly", async () => {
|
|
1253
|
+
const sm = manager();
|
|
1254
|
+
appendText(sm, "user", "long task history");
|
|
1255
|
+
const captured = makeExtension(sm);
|
|
1256
|
+
let compactions = 0;
|
|
1257
|
+
// Default thresholds: reserve 16384, the shallow reminder at remaining 40960 and the
|
|
1258
|
+
// warning steer at remaining 28672 (= reserve + 12288).
|
|
1259
|
+
const window = 200_000;
|
|
1260
|
+
const at = (remaining, idle = false) => context(sm, () => { compactions++; }, { tokens: window - remaining, percent: 0, contextWindow: window }, idle);
|
|
1261
|
+
const warnings = () => captured.sent.filter((entry) => entry.message.customType === internal.WARNING_TYPE);
|
|
1262
|
+
const reminders = () => captured.sent.filter((entry) => entry.message.customType === internal.GUIDANCE_TYPE);
|
|
1263
|
+
// Above the line the shallow reminder owns the band; no warning is steered.
|
|
1264
|
+
assert.equal(await runContextHook(captured, at(28_673)), undefined);
|
|
1265
|
+
assert.equal(warnings().length, 0, "no warning above the warning line");
|
|
1266
|
+
assert.equal(reminders().length, 1, "the shallow reminder persists instead");
|
|
1267
|
+
// Crossing the line: exactly one warning steer, triggered, hidden from the TUI.
|
|
1268
|
+
const onLine = at(28_672);
|
|
1269
|
+
assert.equal(await runContextHook(captured, onLine), undefined);
|
|
1270
|
+
assert.equal(warnings().length, 1);
|
|
1271
|
+
assert.equal(warnings()[0]?.message.customType, internal.WARNING_TYPE);
|
|
1272
|
+
assert.equal(warnings()[0]?.options?.triggerTurn, true, "the steer reaches the model mid-run");
|
|
1273
|
+
assert.equal(warnings()[0]?.message.display, false, "steer text is model-facing only");
|
|
1274
|
+
assert.ok(noticesOf(onLine).some((notice) => notice.type === "warning" && notice.message.startsWith("pi-context: context budget critical")), "the user gets one model-invisible notify for the steer");
|
|
1275
|
+
// Once per window: deeper sampling does not repeat it.
|
|
1276
|
+
assert.equal(await runContextHook(captured, at(4_000)), undefined);
|
|
1277
|
+
assert.equal(warnings().length, 1, "one warning per window, never an unbounded loop");
|
|
1278
|
+
// The model rode on: Pi's automatic crossing resets on the spot — no cancel, no turn.
|
|
1279
|
+
const crossing = await runBeforeCompact(captured, at(1_000), 100, "threshold");
|
|
1280
|
+
assert.ok(crossing && "compaction" in crossing, "the threshold crossing resets for real");
|
|
1281
|
+
assert.equal(warnings().length, 1, "no second steer at the crossing");
|
|
1282
|
+
assert.equal(compactions, 0, "an instant reset never asks ctx.compact()");
|
|
1283
|
+
runHandlers(captured, "agent_end", {}, at(1_000));
|
|
1284
|
+
runHandlers(captured, "agent_settled", {}, at(1_000));
|
|
1285
|
+
runHandlers(captured, "agent_settled", {}, at(1_000));
|
|
1286
|
+
assert.equal(compactions, 0, "agent_end/settled never request a reset for an instant one");
|
|
1287
|
+
// A completed reset re-arms the warning for the next window, not before.
|
|
1288
|
+
const details = crossing.compaction.details;
|
|
1289
|
+
sm.appendCompaction(crossing.compaction.summary, crossing.compaction.firstKeptEntryId, 100, details, true);
|
|
1290
|
+
assert.equal(await runContextHook(captured, at(30_000)), undefined, "fresh window above the warning line steers no warning");
|
|
1291
|
+
assert.equal(warnings().length, 1);
|
|
1292
|
+
assert.equal(await runContextHook(captured, at(20_000)), undefined, "the fresh window crosses the line again");
|
|
1293
|
+
assert.equal(warnings().length, 2, "the warning re-arms per window");
|
|
1294
|
+
assert.equal(warnings()[1]?.message.customType, internal.WARNING_TYPE);
|
|
1295
|
+
});
|
|
1296
|
+
test("overflow resets on the spot, and manual/new_context never cancel", async () => {
|
|
1297
|
+
const sm = manager();
|
|
1298
|
+
appendText(sm, "user", "long task history");
|
|
1299
|
+
const captured = makeExtension(sm);
|
|
1300
|
+
let compactions = 0;
|
|
1301
|
+
const ctx = context(sm, () => { compactions++; }, undefined, false);
|
|
1302
|
+
// Overflow resets immediately, exactly like the threshold crossing.
|
|
1303
|
+
const overflow = await runBeforeCompact(captured, ctx, 100, "overflow");
|
|
1304
|
+
assert.ok(overflow && "compaction" in overflow, "overflow resets on the spot");
|
|
1305
|
+
assert.equal(captured.sent.length, 0, "no steer at the crossing");
|
|
1306
|
+
// User /compact resets directly.
|
|
1307
|
+
const manual = await runBeforeCompact(captured, ctx, 100, "manual");
|
|
1308
|
+
assert.ok(manual && "compaction" in manual, "manual compaction is never intercepted");
|
|
1309
|
+
assert.equal(captured.sent.length, 0, "manual compaction sends nothing");
|
|
1310
|
+
// new_context requests its reset after the run settles.
|
|
1311
|
+
await call(captured, "new_context", {}, ctx);
|
|
1312
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1313
|
+
assert.equal(compactions, 0, "new_context waits for settled");
|
|
1314
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1315
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1316
|
+
assert.equal(compactions, 1, "new_context still compacts through ctx.compact()");
|
|
1317
|
+
const explicit = await runBeforeCompact(captured, ctx, 100, "manual");
|
|
1318
|
+
assert.ok(explicit && "compaction" in explicit, "new_context reset is allowed");
|
|
1319
|
+
assert.equal(captured.sent.length, 0, "new_context never cancels or emits a steer");
|
|
1320
|
+
});
|
|
1321
|
+
test("the visible countdown ends at the warning line, clamps at zero, and preserves unknown usage", async () => {
|
|
1322
|
+
const fixture = settingsFixture({
|
|
1323
|
+
reserveTokens: 16_384,
|
|
1324
|
+
project: { compaction: { reserveTokens: 32_768 } },
|
|
1325
|
+
});
|
|
1326
|
+
const sm = manager();
|
|
1327
|
+
const captured = makeExtension(sm);
|
|
1328
|
+
runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
|
|
1329
|
+
const readBudget = async (tokens, trusted = true) => {
|
|
1330
|
+
const ctx = context(sm, undefined, { tokens, contextWindow: 200_000, percent: tokens === null ? null : tokens / 2000 }, true, fixture.cwd, trusted);
|
|
1331
|
+
return resultJson(await call(captured, "get_context_remaining", {}, ctx)).remaining_tokens;
|
|
1332
|
+
};
|
|
1333
|
+
assert.equal(await readBudget(72_563), 82_381, "the reported 127437 physical tokens exclude reserve plus runway (45056)");
|
|
1334
|
+
assert.equal(await readBudget(167_232), 0, "inside the runway the countdown reads zero");
|
|
1335
|
+
assert.equal(await readBudget(190_000), 0, "below the reserve, still zero");
|
|
1336
|
+
assert.equal(await readBudget(210_000), 0, "over the physical window");
|
|
1337
|
+
assert.equal(await readBudget(null), null, "unknown usage remains unknown");
|
|
1338
|
+
const absent = context(sm, undefined, undefined, true, fixture.cwd);
|
|
1339
|
+
assert.equal(resultJson(await call(captured, "get_context_remaining", {}, absent)).remaining_tokens, null);
|
|
1340
|
+
const untrusted = context(sm, undefined, { tokens: 72_563, contextWindow: 200_000, percent: 36.2815 }, true, fixture.cwd, false);
|
|
1341
|
+
runHandlers(captured, "session_start", {}, untrusted);
|
|
1342
|
+
assert.equal(await readBudget(72_563, false), 98_765, "session start reloads the global reserve when the project is untrusted");
|
|
1343
|
+
});
|
|
1344
|
+
test("the reminder threshold derives from compaction.reserveTokens plus the pi-context reminder margin", async () => {
|
|
1345
|
+
const fixture = settingsFixture({
|
|
1346
|
+
reserveTokens: 100_000,
|
|
1347
|
+
global: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 30_000 } },
|
|
1348
|
+
});
|
|
1349
|
+
const sm = manager();
|
|
1350
|
+
const captured = makeExtension(sm);
|
|
1351
|
+
// Thresholds are resolved once per session and cached; branch navigation clears the
|
|
1352
|
+
// cache without emitting a boot block, so the next read uses this fixture.
|
|
1353
|
+
runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
|
|
1354
|
+
const window = 300_000;
|
|
1355
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
|
|
1356
|
+
// reminder = 100000 + 30000.
|
|
1357
|
+
assert.equal(await runContextHook(captured, at(130_001)), undefined, "nothing injected above the derived reminder");
|
|
1358
|
+
assert.equal(captured.sent.length, 0, "no guidance above the derived reminder");
|
|
1359
|
+
assert.equal(await runContextHook(captured, at(130_000)), undefined, "derived reminder crossing persists only");
|
|
1360
|
+
assert.equal(captured.sent.length, 1, "derived reminder fires");
|
|
1361
|
+
assert.match(String(captured.sent[0]?.message.content), /\b17712 tokens\b/, "derived reminder embeds the model-visible remaining count");
|
|
1362
|
+
});
|
|
1363
|
+
test("absent pi-context key or margins reproduce the default reminder threshold at Pi's default reserve", async () => {
|
|
1364
|
+
assert.equal(internal.DEFAULT_RESERVE_TOKENS, 16_384);
|
|
1365
|
+
assert.equal(internal.DEFAULT_RESERVE_TOKENS + internal.DEFAULT_REMINDER_MARGIN_TOKENS, 40_960);
|
|
1366
|
+
for (const [label, options] of [
|
|
1367
|
+
["absent key", { global: {} }],
|
|
1368
|
+
["absent margins", { global: { [internal.PI_CONTEXT_SETTINGS_KEY]: {} } }],
|
|
1369
|
+
]) {
|
|
1370
|
+
const fixture = settingsFixture(options);
|
|
1371
|
+
const sm = manager();
|
|
1372
|
+
const captured = makeExtension(sm);
|
|
1373
|
+
// Thresholds are resolved once per session and cached; branch navigation clears the
|
|
1374
|
+
// cache without emitting a boot block, so the next read uses this fixture.
|
|
1375
|
+
runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
|
|
1376
|
+
const window = 200_000;
|
|
1377
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
|
|
1378
|
+
const first = at(40_961);
|
|
1379
|
+
assert.equal(await runContextHook(captured, first), undefined, `${label}: nothing injected above the default reminder`);
|
|
1380
|
+
assert.equal(captured.sent.length, 0, `${label}: no guidance above the default reminder`);
|
|
1381
|
+
assert.equal(await runContextHook(captured, at(40_960)), undefined, `${label}: default reminder crossing persists only`);
|
|
1382
|
+
assert.equal(captured.sent.length, 1, `${label}: default reminder fires`);
|
|
1383
|
+
assert.match(String(captured.sent[0]?.message.content), /\b12288 tokens\b/, label);
|
|
1384
|
+
assert.equal(noticesOf(first).length, 0, `${label}: valid defaults warn nobody`);
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
test("project pi-context reminder margin and reserve override global per key", async () => {
|
|
1388
|
+
const fixture = settingsFixture({
|
|
1389
|
+
reserveTokens: 20_000,
|
|
1390
|
+
global: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 30_000 } },
|
|
1391
|
+
project: { compaction: { reserveTokens: 50_000 }, [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 40_000 } },
|
|
1392
|
+
});
|
|
1393
|
+
// Project reserve wins: reminder = 50000 + 40000 (project margin).
|
|
1394
|
+
const sm = manager();
|
|
1395
|
+
const captured = makeExtension(sm);
|
|
1396
|
+
runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
|
|
1397
|
+
const window = 300_000;
|
|
1398
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
|
|
1399
|
+
assert.equal(await runContextHook(captured, at(90_001)), undefined, "nothing injected above the project-derived reminder");
|
|
1400
|
+
assert.equal(captured.sent.length, 0);
|
|
1401
|
+
assert.equal(await runContextHook(captured, at(90_000)), undefined, "project-derived reminder crossing persists only");
|
|
1402
|
+
assert.equal(captured.sent.length, 1, "project reminder margin wins");
|
|
1403
|
+
});
|
|
1404
|
+
test("an untrusted project is ignored, so global pi-context margins apply", async () => {
|
|
1405
|
+
const fixture = settingsFixture({
|
|
1406
|
+
global: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 30_000 } },
|
|
1407
|
+
project: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 40_000 } },
|
|
1408
|
+
});
|
|
1409
|
+
const sm = manager();
|
|
1410
|
+
const captured = makeExtension(sm);
|
|
1411
|
+
runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, false));
|
|
1412
|
+
const window = 100_000;
|
|
1413
|
+
// Global reminder = 16384 + 30000 = 46384, not the project's 56384.
|
|
1414
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd, false);
|
|
1415
|
+
assert.equal(await runContextHook(captured, at(56_000)), undefined, "untrusted project margin ignored; nothing injected");
|
|
1416
|
+
assert.equal(captured.sent.length, 0, "no guidance from the untrusted project margin");
|
|
1417
|
+
assert.equal(await runContextHook(captured, at(46_384)), undefined, "global margin fires instead");
|
|
1418
|
+
assert.equal(captured.sent.length, 1);
|
|
1419
|
+
});
|
|
1420
|
+
test("the reminder margin is re-read from settings.json on session_start", async () => {
|
|
1421
|
+
const fixture = settingsFixture({ global: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 10_000 } } });
|
|
1422
|
+
const sm = manager();
|
|
1423
|
+
const captured = makeExtension(sm);
|
|
1424
|
+
const window = 100_000;
|
|
1425
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
|
|
1426
|
+
const guidance = () => captured.sent.filter((sent) => sent.message.customType === internal.GUIDANCE_TYPE);
|
|
1427
|
+
// Initial reminder = 16384 + 10000 = 26384; 35000 is above it, so nothing is persisted.
|
|
1428
|
+
runHandlers(captured, "session_start", { reason: "startup" }, at(0));
|
|
1429
|
+
assert.equal(await runContextHook(captured, at(35_000)), undefined);
|
|
1430
|
+
assert.equal(guidance().length, 0, "no guidance above the initial reminder");
|
|
1431
|
+
// Rewrite the global settings file, then session_start must pick up the new margin.
|
|
1432
|
+
writeJson(join(fixture.agentDir, "settings.json"), { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 40_000 } });
|
|
1433
|
+
runHandlers(captured, "session_start", { reason: "startup" }, at(0));
|
|
1434
|
+
// New reminder = 16384 + 40000 = 56384; 35000 is now below it.
|
|
1435
|
+
assert.equal(await runContextHook(captured, at(35_000)), undefined, "the crossing persists only");
|
|
1436
|
+
assert.equal(guidance().length, 1, "reminder margin re-read on session_start");
|
|
1437
|
+
});
|
|
1438
|
+
test("an invalid reminder margin degrades to its default with one warning and never throws", async () => {
|
|
1439
|
+
const fixture = settingsFixture({ global: { [internal.PI_CONTEXT_SETTINGS_KEY]: { reminderMarginTokens: 0 } } });
|
|
1440
|
+
const sm = manager();
|
|
1441
|
+
const captured = makeExtension(sm);
|
|
1442
|
+
const ctx = context(sm, undefined, undefined, true, fixture.cwd);
|
|
1443
|
+
assert.doesNotThrow(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
|
|
1444
|
+
const notices = noticesOf(ctx);
|
|
1445
|
+
assert.equal(notices.length, 1, "one warning for the offending key");
|
|
1446
|
+
assert.equal(notices[0]?.type, "warning");
|
|
1447
|
+
assert.match(notices[0]?.message ?? "", /reminderMarginTokens/);
|
|
1448
|
+
assert.match(notices[0]?.message ?? "", /24576/);
|
|
1449
|
+
const window = 200_000;
|
|
1450
|
+
const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
|
|
1451
|
+
// The degraded reminder is Pi's default reserve + default margin = 40960.
|
|
1452
|
+
assert.equal(await runContextHook(captured, at(40_961)), undefined, "nothing injected above the degraded reminder");
|
|
1453
|
+
assert.equal(await runContextHook(captured, at(40_960)), undefined, "degraded reminder uses its default");
|
|
1454
|
+
assert.equal(captured.sent.length, 2, "root boot plus degraded reminder");
|
|
1455
|
+
assert.equal(notices.length, 1, "warning stays one-time across handler calls");
|
|
1456
|
+
});
|
|
1457
|
+
test("the old threshold flags are no longer registered", () => {
|
|
1458
|
+
const captured = makeExtension(manager());
|
|
1459
|
+
assert.deepEqual(captured.flags, []);
|
|
1460
|
+
});
|
|
1461
|
+
test("the removed pre-prompt/turn_end hooks stay gone; the context hook owns the only steer", async () => {
|
|
1462
|
+
const sm = manager();
|
|
1463
|
+
const captured = makeExtension(sm);
|
|
1464
|
+
// The old pre-prompt/turn_end paths registered hooks and fired on a token threshold
|
|
1465
|
+
// of their own. They are gone: nothing fires outside the context hook and
|
|
1466
|
+
// session_before_compact.
|
|
1467
|
+
assert.equal(captured.handlers.has("before_agent_start"), false, "before_agent_start hook removed");
|
|
1468
|
+
assert.equal(captured.handlers.has("turn_end"), false, "turn_end hook removed");
|
|
1469
|
+
assert.equal(captured.handlers.has("input"), false, "no input copy/replay special case");
|
|
1470
|
+
assert.equal(captured.handlers.has("session_before_compact"), true, "session_before_compact is the sole compaction entry point");
|
|
1471
|
+
// Deep in the warning band the context hook steers the warning; the shallow
|
|
1472
|
+
// guidance is superseded, not stacked on top of it.
|
|
1473
|
+
const window = 200_000;
|
|
1474
|
+
const ctx = context(sm, undefined, { tokens: window - 24_576, percent: 0, contextWindow: window }, false);
|
|
1475
|
+
assert.equal(captured.sent.length, 0, "nothing before the hook runs");
|
|
1476
|
+
assert.equal(await runContextHook(captured, ctx), undefined);
|
|
1477
|
+
assert.equal(captured.sent.length, 1);
|
|
1478
|
+
assert.equal(captured.sent[0]?.message.customType, internal.WARNING_TYPE);
|
|
1479
|
+
assert.equal(captured.sent[0]?.options?.triggerTurn, true);
|
|
1480
|
+
// The compaction request that follows is the wipe itself: instant reset, no cancel.
|
|
1481
|
+
const before = await runBeforeCompact(captured, ctx, 100, "threshold");
|
|
1482
|
+
assert.ok(before && "compaction" in before, "the crossing resets for real");
|
|
1483
|
+
assert.equal(captured.sent.length, 1, "one steer, one real compaction");
|
|
1484
|
+
});
|
|
1485
|
+
test("ordinary new_context after an automatic crossing still requests one reset and starts a fresh run", async () => {
|
|
1486
|
+
for (const reason of [undefined, "threshold", "overflow"]) {
|
|
1487
|
+
const sm = manager();
|
|
1488
|
+
appendText(sm, "user", "work to continue after reset");
|
|
1489
|
+
const captured = makeExtension(sm);
|
|
1490
|
+
let compactions = 0;
|
|
1491
|
+
const ctx = context(sm, () => { compactions++; }, undefined, false);
|
|
1492
|
+
if (reason) {
|
|
1493
|
+
const crossing = await runBeforeCompact(captured, ctx, 100, reason);
|
|
1494
|
+
assert.ok(crossing && "compaction" in crossing, `${reason}: the crossing already reset on the spot`);
|
|
1495
|
+
}
|
|
1496
|
+
const request = await call(captured, "new_context", {}, ctx);
|
|
1497
|
+
assert.equal(request.terminate, true, "end the current tool loop before reset");
|
|
1498
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1499
|
+
assert.equal(compactions, 0, "no request before settled");
|
|
1500
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1501
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1502
|
+
assert.equal(compactions, 1, "explicit reset consumes the request");
|
|
1503
|
+
const before = await runBeforeCompact(captured, ctx, 100, "manual");
|
|
1504
|
+
assert.ok(before && "compaction" in before);
|
|
1505
|
+
const id = sm.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 100, before.compaction.details, true);
|
|
1506
|
+
const event = { willRetry: false, compactionEntry: sm.getEntry(id) };
|
|
1507
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1508
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1509
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1510
|
+
assert.equal(compactions, 1, "no second reset after success");
|
|
1511
|
+
completeRequestedCompaction(ctx);
|
|
1512
|
+
assert.equal(captured.sent.length, 1, "explicit request still owns exactly one continuation");
|
|
1513
|
+
const continuation = captured.sent[0];
|
|
1514
|
+
assert.equal(continuation.options?.triggerTurn, true);
|
|
1515
|
+
// Exercise Pi's real custom-message routing after the old run has settled.
|
|
1516
|
+
// The prompt endpoint is stubbed; no provider request is made.
|
|
1517
|
+
const prompts = [];
|
|
1518
|
+
const runtime = {
|
|
1519
|
+
isStreaming: false,
|
|
1520
|
+
_runAgentPrompt: async (message) => { prompts.push(message); },
|
|
1521
|
+
agent: { steer: () => assert.fail("continuation must start a run, not wait in a steer queue") },
|
|
1522
|
+
};
|
|
1523
|
+
await AgentSession.prototype.sendCustomMessage.call(runtime, continuation.message, continuation.options);
|
|
1524
|
+
assert.equal(prompts.length, 1, "Pi starts a fresh prompt without another user message");
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
test("new_context can reset successive windows without duplicate compactions or continuations", async () => {
|
|
1528
|
+
const sm = manager();
|
|
1529
|
+
const captured = makeExtension(sm);
|
|
1530
|
+
let compactions = 0;
|
|
1531
|
+
const ctx = context(sm, () => { compactions++; });
|
|
1532
|
+
for (let window = 0; window < 2; window++) {
|
|
1533
|
+
appendText(sm, "user", `window ${window}`);
|
|
1534
|
+
const request = resultJson(await call(captured, "new_context", {}, ctx));
|
|
1535
|
+
assert.equal(request.status, "rollover_requested");
|
|
1536
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1537
|
+
runHandlers(captured, "agent_end", {}, ctx);
|
|
1538
|
+
assert.equal(compactions, window, "agent_end only arms the reset");
|
|
1539
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1540
|
+
runHandlers(captured, "agent_settled", {}, ctx);
|
|
1541
|
+
assert.equal(compactions, window + 1);
|
|
1542
|
+
const before = await runBeforeCompact(captured, ctx, 100);
|
|
1543
|
+
assert.ok(before && "compaction" in before);
|
|
1544
|
+
const id = sm.appendCompaction(before.compaction.summary, before.compaction.firstKeptEntryId, 100, before.compaction.details, true);
|
|
1545
|
+
const event = { willRetry: false, compactionEntry: sm.getEntry(id) };
|
|
1546
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1547
|
+
runHandlers(captured, "session_compact", event, ctx);
|
|
1548
|
+
completeRequestedCompaction(ctx);
|
|
1549
|
+
assert.equal(captured.sent.length, window + 1, "one continuation per explicit reset, and no hint");
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
test("boot and guidance deduplicate across extension reload while a new branch can receive them", () => {
|
|
1553
|
+
const sm = manager();
|
|
1554
|
+
appendText(sm, "user", "branch anchor");
|
|
1555
|
+
const anchor = sm.getLeafId();
|
|
1556
|
+
const ctx = context(sm, undefined, { tokens: 170_000, percent: 85, contextWindow: 200_000 });
|
|
1557
|
+
const first = makeExtension(sm);
|
|
1558
|
+
runHandlers(first, "session_start", {}, ctx);
|
|
1559
|
+
runHandlers(first, "context", {}, ctx);
|
|
1560
|
+
assert.equal(first.sent.length, 2);
|
|
1561
|
+
const reloaded = makeExtension(sm);
|
|
1562
|
+
runHandlers(reloaded, "session_start", {}, ctx);
|
|
1563
|
+
runHandlers(reloaded, "context", {}, ctx);
|
|
1564
|
+
assert.equal(reloaded.sent.length, 0, "persisted messages survive runtime replacement");
|
|
1565
|
+
sm.branch(anchor);
|
|
1566
|
+
runHandlers(first, "session_tree", {}, ctx);
|
|
1567
|
+
runHandlers(first, "context", {}, ctx);
|
|
1568
|
+
assert.equal(first.sent.length, 3, "same runtime releases the previous branch's reminder reservation");
|
|
1569
|
+
const fork = makeExtension(sm);
|
|
1570
|
+
runHandlers(fork, "session_start", {}, ctx);
|
|
1571
|
+
runHandlers(fork, "context", {}, ctx);
|
|
1572
|
+
assert.equal(fork.sent.length, 1, "sibling boot is created; this branch's reminder already exists");
|
|
1573
|
+
});
|
|
1574
|
+
test("malformed frontmatter timestamps degrade to a finite fallback without poisoning valid notes or boot rendering", async () => {
|
|
1575
|
+
const sm = manager();
|
|
1576
|
+
const ctx = context(sm);
|
|
1577
|
+
const extension = makeExtension(sm);
|
|
1578
|
+
await call(extension, "notes_write", { path: "good.md", content: "keep me" }, ctx);
|
|
1579
|
+
// Corrupt every timestamp in place; parse must fall back rather than emit NaN.
|
|
1580
|
+
const file = physicalPath("session", "good.md", ctx);
|
|
1581
|
+
writeFileSync(file, readFileSync(file, "utf8").replace(/^(created_at|updated_at|last_accessed): .*$/gm, "$1: not-a-timestamp"));
|
|
1582
|
+
const rows = listNotes(ctx, { scope: "session" });
|
|
1583
|
+
assert.equal(rows.length, 1);
|
|
1584
|
+
assert.ok(Number.isFinite(rows[0].meta.updated_at), "a malformed timestamp degrades to a finite fallback");
|
|
1585
|
+
runHandlers(extension, "session_start", {}, ctx);
|
|
1586
|
+
const rendered = JSON.stringify(extension.sent);
|
|
1587
|
+
assert.ok(rendered.includes("keep me"), "the valid body still renders");
|
|
1588
|
+
assert.equal(rendered.includes("NaN"), false, "no malformed timestamp leaks into the boot block");
|
|
1589
|
+
});
|
|
1590
|
+
test("JSONL reload retains once-per-window boot and reminder without runtime memory", () => {
|
|
1591
|
+
const sm = manager(true);
|
|
1592
|
+
const first = makeExtension(sm);
|
|
1593
|
+
const usage = { tokens: 170_000, percent: 85, contextWindow: 200_000 };
|
|
1594
|
+
const ctx = context(sm, undefined, usage);
|
|
1595
|
+
runHandlers(first, "session_start", {}, ctx);
|
|
1596
|
+
runHandlers(first, "context", {}, ctx);
|
|
1597
|
+
appendText(sm, "assistant", "flush the persisted session");
|
|
1598
|
+
const path = sm.getSessionFile();
|
|
1599
|
+
assert.ok(path);
|
|
1600
|
+
const restored = manager();
|
|
1601
|
+
restored.setSessionFile(path);
|
|
1602
|
+
const loaded = makeExtension(restored);
|
|
1603
|
+
const loadedCtx = context(restored, undefined, usage);
|
|
1604
|
+
runHandlers(loaded, "session_start", {}, loadedCtx);
|
|
1605
|
+
runHandlers(loaded, "context", {}, loadedCtx);
|
|
1606
|
+
assert.equal(loaded.sent.length, 0);
|
|
1607
|
+
const messages = restored.getBranch().filter((entry) => entry.type === "custom_message");
|
|
1608
|
+
assert.equal(messages.filter((entry) => entry.customType === internal.BOOT_TYPE).length, 1);
|
|
1609
|
+
assert.equal(messages.filter((entry) => entry.customType === internal.GUIDANCE_TYPE).length, 1);
|
|
1610
|
+
});
|
|
1611
|
+
test("the warning supersedes the early reminder when usage jumps across both thresholds", async () => {
|
|
1612
|
+
const sm = manager();
|
|
1613
|
+
appendText(sm, "user", "ongoing work");
|
|
1614
|
+
const captured = makeExtension(sm);
|
|
1615
|
+
const ctx = context(sm, undefined, { tokens: 199_000, percent: 99.5, contextWindow: 200_000 }, false);
|
|
1616
|
+
assert.equal(await runContextHook(captured, ctx), undefined);
|
|
1617
|
+
assert.deepEqual(captured.sent.map((entry) => entry.message.customType), [internal.WARNING_TYPE]);
|
|
1618
|
+
const reloaded = makeExtension(sm);
|
|
1619
|
+
runHandlers(reloaded, "context", {}, ctx);
|
|
1620
|
+
assert.equal(reloaded.sent.length, 0, "persisted warning also suppresses a late reminder after reload");
|
|
1621
|
+
});
|
|
1622
|
+
test("warning suppression is branch-local and survives toggling without becoming permanent", async () => {
|
|
1623
|
+
const sm = manager();
|
|
1624
|
+
appendText(sm, "user", "branch anchor");
|
|
1625
|
+
const anchor = sm.getLeafId();
|
|
1626
|
+
const captured = makeExtension(sm);
|
|
1627
|
+
const ctx = context(sm, undefined, { tokens: 199_000, percent: 99.5, contextWindow: 200_000 }, false);
|
|
1628
|
+
await runContextHook(captured, ctx);
|
|
1629
|
+
assert.equal(captured.sent.length, 1);
|
|
1630
|
+
const warnedLeaf = sm.getLeafId();
|
|
1631
|
+
await runCommand(captured, "pi-context", "off", ctx);
|
|
1632
|
+
await runCommand(captured, "pi-context", "on", ctx);
|
|
1633
|
+
runHandlers(captured, "context", {}, ctx);
|
|
1634
|
+
assert.equal(captured.sent.length, 1, "toggle does not revive the steer");
|
|
1635
|
+
sm.branch(anchor);
|
|
1636
|
+
runHandlers(captured, "session_tree", {}, ctx);
|
|
1637
|
+
runHandlers(captured, "context", {}, ctx);
|
|
1638
|
+
assert.equal(captured.sent.length, 2);
|
|
1639
|
+
assert.equal(captured.sent[1].message.customType, internal.WARNING_TYPE, "sibling without the warning still gets its own steer");
|
|
1640
|
+
sm.branch(warnedLeaf);
|
|
1641
|
+
runHandlers(captured, "session_tree", {}, ctx);
|
|
1642
|
+
runHandlers(captured, "context", {}, ctx);
|
|
1643
|
+
assert.equal(captured.sent.length, 2, "returning to the warned branch stays suppressed");
|
|
1644
|
+
});
|
|
1645
|
+
test("argument footguns die loudly and tool-run metadata surfaces (A1/A2/A3/B4/B5)", async () => {
|
|
1646
|
+
const session = manager();
|
|
1647
|
+
const captured = makeExtension(session);
|
|
1648
|
+
const ctx = context(session);
|
|
1649
|
+
// A1: an empty query string is an argument error on both search tools, never a match-everything.
|
|
1650
|
+
for (const tool of ["history_search", "notes_search"]) {
|
|
1651
|
+
await assert.rejects(() => call(captured, tool, { query: "" }, ctx), /empty query matches everything/, `${tool}: bare empty string refused`);
|
|
1652
|
+
await assert.rejects(() => call(captured, tool, { query: ["alpha", ""] }, ctx), /empty query matches everything/, `${tool}: empty array element refused`);
|
|
1653
|
+
}
|
|
1654
|
+
// A2: a positive offset past the end is a named error on both read tools; offset == total stays the legal empty end-read.
|
|
1655
|
+
await call(captured, "notes_write", { path: "a.md", content: "hello" }, ctx);
|
|
1656
|
+
appendText(session, "user", "hello world");
|
|
1657
|
+
const windowId = historyFromSession(ctx)[0].windowId;
|
|
1658
|
+
const listed = resultJson(await call(captured, "history_list", {}, ctx));
|
|
1659
|
+
const target = listed.items.find((candidate) => candidate.total_chars === "hello world".length);
|
|
1660
|
+
assert.ok(target, "the user item is listed");
|
|
1661
|
+
const noteTotal = resultRead(await call(captured, "notes_read", { path: "a.md" }, ctx)).total_chars;
|
|
1662
|
+
const notePastEnd = resultJson(await call(captured, "notes_read", { path: "a.md", offset_chars: noteTotal + 1 }, ctx));
|
|
1663
|
+
assert.match(notePastEnd.error ?? "", /past the end/, "notes: past-end offset is a named error");
|
|
1664
|
+
assert.equal(notePastEnd.offset_chars, noteTotal + 1, "notes: the error echoes the offending offset");
|
|
1665
|
+
assert.equal(notePastEnd.total_chars, noteTotal, "notes: the error names the real length");
|
|
1666
|
+
assert.equal(notePastEnd.path, "a.md", "notes: the error echoes the path");
|
|
1667
|
+
const noteEnd = resultRead(await call(captured, "notes_read", { path: "a.md", offset_chars: noteTotal }, ctx));
|
|
1668
|
+
assert.equal(noteEnd.content, "", "notes: offset == total is the legal empty end-read");
|
|
1669
|
+
assert.equal(noteEnd.next_offset_chars, null, "notes: the end-read terminates");
|
|
1670
|
+
const itemPastEnd = resultJson(await call(captured, "history_read", { window_id: windowId, item_id: target.item_id, offset_chars: 12 }, ctx));
|
|
1671
|
+
assert.match(itemPastEnd.error ?? "", /past the end/, "history: past-end offset is a named error");
|
|
1672
|
+
assert.equal(itemPastEnd.total_chars, 11, "history: the error names the real length");
|
|
1673
|
+
assert.equal(itemPastEnd.item_id, target.item_id, "history: the error echoes the item_id");
|
|
1674
|
+
const itemEnd = resultRead(await call(captured, "history_read", { window_id: windowId, item_id: target.item_id, offset_chars: 11 }, ctx));
|
|
1675
|
+
assert.equal(itemEnd.content, "", "history: offset == total is the legal empty end-read");
|
|
1676
|
+
assert.equal(itemEnd.next_offset_chars, null, "history: the end-read terminates");
|
|
1677
|
+
// A3: editing a missing note is the typed not-found arm; write still creates it.
|
|
1678
|
+
const editMissing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
|
|
1679
|
+
assert.equal(editMissing.error, "note not found", "an edit of a missing note is named");
|
|
1680
|
+
const writeCreates = resultJson(await call(captured, "notes_write", { path: "missing.md", content: "x" }, ctx));
|
|
1681
|
+
assert.equal(writeCreates.error, undefined, "write still creates the note");
|
|
1682
|
+
const editNow = resultJson(await call(captured, "notes_edit", { path: "missing.md", edits: [{ oldText: "x", newText: "y" }] }, ctx));
|
|
1683
|
+
assert.equal(editNow.error, undefined, "edit of an existing note still works");
|
|
1684
|
+
session.appendMessage({ role: "bashExecution", command: "yes", output: "y\ny\n", exitCode: 0, cancelled: false, truncated: true, fullOutputPath: "/tmp/full-yes.txt", timestamp: Date.now() });
|
|
1685
|
+
session.appendMessage({ role: "bashExecution", command: "true", output: "", exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now() });
|
|
1686
|
+
session.appendMessage({ role: "toolResult", content: [{ type: "text", text: "boom" }], toolCallId: "call-err", toolName: "bash", isError: true, timestamp: Date.now() });
|
|
1687
|
+
appendText(session, "toolResult", "fine");
|
|
1688
|
+
const tools = resultJson(await call(captured, "history_list", { role: "tool", limit: 20 }, ctx));
|
|
1689
|
+
const byContent = (needle) => {
|
|
1690
|
+
const found = tools.items.find((candidate) => String(candidate.truncated_content).includes(needle));
|
|
1691
|
+
assert.ok(found, `tool item containing ${JSON.stringify(needle)} is listed`);
|
|
1692
|
+
return found;
|
|
1693
|
+
};
|
|
1694
|
+
const truncatedBash = byContent("yes");
|
|
1695
|
+
assert.equal(truncatedBash.output_truncated, true, "a truncated bash run says so");
|
|
1696
|
+
assert.equal(truncatedBash.full_output_path, "/tmp/full-yes.txt", "a truncated bash run names its full-output path");
|
|
1697
|
+
const cleanBash = byContent("true");
|
|
1698
|
+
assert.equal("output_truncated" in cleanBash, false, "an untruncated bash run carries no truncation keys");
|
|
1699
|
+
assert.equal("full_output_path" in cleanBash, false, "an untruncated bash run names no path");
|
|
1700
|
+
const errored = byContent("boom");
|
|
1701
|
+
assert.equal(errored.tool_error, true, "an errored tool result says so");
|
|
1702
|
+
const fine = byContent("fine");
|
|
1703
|
+
assert.equal("tool_error" in fine, false, "a clean tool result carries no error key");
|
|
1704
|
+
});
|
|
1705
|
+
test("notes_search scopes by glob pattern; a non-matching pattern is an empty page, not an error", async () => {
|
|
1706
|
+
const session = manager();
|
|
1707
|
+
const captured = makeExtension(session);
|
|
1708
|
+
const ctx = context(session);
|
|
1709
|
+
await call(captured, "notes_write", { path: "deep/nested/a.md", content: "needle here" }, ctx);
|
|
1710
|
+
await call(captured, "notes_write", { path: "top.md", content: "needle there" }, ctx);
|
|
1711
|
+
const scoped = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "deep/**" }, ctx));
|
|
1712
|
+
assert.deepEqual(scoped.files.map((file) => file.path), ["deep/nested/a.md"], "a glob scopes the search to the subtree");
|
|
1713
|
+
const none = resultJson(await call(captured, "notes_search", { query: "needle", pattern: "absent/**" }, ctx));
|
|
1714
|
+
assert.equal(none.error, undefined, "a non-matching pattern is not an error");
|
|
1715
|
+
assert.deepEqual(none.files, [], "a non-matching pattern is an empty page");
|
|
1716
|
+
});
|