@astrosheep/pi-context 0.25.1 → 0.26.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 +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +616 -370
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +19 -15
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +298 -242
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/tool-output.d.ts +101 -0
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +318 -19
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +55 -29
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +8 -7
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- package/dist/test/helpers/extension.d.ts +115 -0
- package/dist/test/helpers/extension.js +6 -6
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +111 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +22 -24
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +137 -7
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +25 -17
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +308 -244
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- package/src/protocol.ts +5 -6
|
@@ -6,7 +6,7 @@ import test from "node:test";
|
|
|
6
6
|
import { createAssistantMessageEventStream, getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
7
7
|
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import piContext, { createPiContext } from "../src/index.js";
|
|
9
|
-
import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, CONTINUATION, CONTINUATION_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
|
|
9
|
+
import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, CONTINUATION, CONTINUATION_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_CONTENT, WARNING_PROMPT, WARNING_TYPE } from "../src/protocol.js";
|
|
10
10
|
async function openFixture(options) {
|
|
11
11
|
const dir = options.cwd ?? mkdtempSync(join(tmpdir(), "pi-context-agent-loop-"));
|
|
12
12
|
const ownsDir = options.cwd === undefined;
|
|
@@ -59,6 +59,7 @@ async function openFixture(options) {
|
|
|
59
59
|
const streamSignals = [];
|
|
60
60
|
const events = [];
|
|
61
61
|
const notices = [];
|
|
62
|
+
let budgetNotices = 0;
|
|
62
63
|
let session;
|
|
63
64
|
const loader = new DefaultResourceLoader({
|
|
64
65
|
cwd: dir,
|
|
@@ -75,7 +76,7 @@ async function openFixture(options) {
|
|
|
75
76
|
}],
|
|
76
77
|
});
|
|
77
78
|
await loader.reload();
|
|
78
|
-
const sessionManager = SessionManager.inMemory(dir);
|
|
79
|
+
const sessionManager = options.sessionManager ?? (options.sessionDir ? SessionManager.create(dir, options.sessionDir) : SessionManager.inMemory(dir));
|
|
79
80
|
options.seed?.(sessionManager);
|
|
80
81
|
const created = await createAgentSession({
|
|
81
82
|
cwd: dir,
|
|
@@ -89,7 +90,7 @@ async function openFixture(options) {
|
|
|
89
90
|
});
|
|
90
91
|
session = created.session;
|
|
91
92
|
session.subscribe((event) => events.push(event));
|
|
92
|
-
session.agent.streamFunction = (_model, context, streamOptions) => {
|
|
93
|
+
session.agent.streamFunction = async (_model, context, streamOptions) => {
|
|
93
94
|
const aborted = streamOptions?.signal?.aborted === true;
|
|
94
95
|
streamContexts.push(context);
|
|
95
96
|
streamSignals.push(aborted);
|
|
@@ -112,7 +113,7 @@ async function openFixture(options) {
|
|
|
112
113
|
}
|
|
113
114
|
requests.push(context);
|
|
114
115
|
const request = requests.length;
|
|
115
|
-
const message = options.script(request, context);
|
|
116
|
+
const message = await options.script(request, context);
|
|
116
117
|
const stream = createAssistantMessageEventStream();
|
|
117
118
|
stream.push({ type: "done", reason: (message.stopReason === "error" || message.stopReason === "aborted" ? "stop" : message.stopReason), message });
|
|
118
119
|
stream.end();
|
|
@@ -120,16 +121,14 @@ async function openFixture(options) {
|
|
|
120
121
|
};
|
|
121
122
|
await session.bindExtensions({
|
|
122
123
|
uiContext: {
|
|
123
|
-
notify(message) {
|
|
124
|
+
notify(message, type) {
|
|
124
125
|
if (message.startsWith("pi-context: memory cleared · ")) {
|
|
125
126
|
const windowId = message.split(" · ")[1];
|
|
126
127
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE && entry.data?.windowId === windowId), "notification follows the reset marker commit");
|
|
127
128
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId), "notification follows the reset boot commit");
|
|
128
129
|
}
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === customType), "budget notifications follow the reminder commit");
|
|
132
|
-
}
|
|
130
|
+
if (type === "warning" && sessionManager.getBranch().some((entry) => entry.type === "custom_message" && (entry.customType === GUIDANCE_TYPE || entry.customType === WARNING_TYPE)))
|
|
131
|
+
budgetNotices++;
|
|
133
132
|
notices.push(message);
|
|
134
133
|
},
|
|
135
134
|
},
|
|
@@ -145,6 +144,7 @@ async function openFixture(options) {
|
|
|
145
144
|
streamSignals,
|
|
146
145
|
events,
|
|
147
146
|
notices,
|
|
147
|
+
budgetNotices: () => budgetNotices,
|
|
148
148
|
close: () => {
|
|
149
149
|
session.dispose();
|
|
150
150
|
if (managesEnvironment) {
|
|
@@ -221,22 +221,22 @@ test("real AgentSession: aborted low-budget requests notify only after a retry c
|
|
|
221
221
|
});
|
|
222
222
|
try {
|
|
223
223
|
const reminders = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
|
|
224
|
-
const notices = () => fixture.
|
|
224
|
+
const notices = () => fixture.budgetNotices();
|
|
225
225
|
await fixture.session.prompt("Establish usage below the reminder line.");
|
|
226
226
|
await fixture.session.waitForIdle();
|
|
227
227
|
assert.equal(reminders().length, 0);
|
|
228
228
|
await fixture.session.prompt("Abort this low-budget request.");
|
|
229
229
|
await fixture.session.waitForIdle();
|
|
230
230
|
assert.equal(reminders().length, 0, "an aborted turn does not commit its reminder");
|
|
231
|
-
assert.equal(notices()
|
|
231
|
+
assert.equal(notices(), 0, "an uncommitted reminder never notifies");
|
|
232
232
|
await fixture.session.prompt("Retry successfully.");
|
|
233
233
|
await fixture.session.waitForIdle();
|
|
234
234
|
assert.equal(reminders().length, 1);
|
|
235
|
-
assert.equal(notices()
|
|
235
|
+
assert.equal(notices(), 1, "the committed retry notifies once");
|
|
236
236
|
await fixture.session.prompt("Continue in the same window.");
|
|
237
237
|
await fixture.session.waitForIdle();
|
|
238
238
|
assert.equal(reminders().length, 1);
|
|
239
|
-
assert.equal(notices()
|
|
239
|
+
assert.equal(notices(), 1, "later turns cannot repeat the notice");
|
|
240
240
|
}
|
|
241
241
|
finally {
|
|
242
242
|
fixture.close();
|
|
@@ -268,13 +268,181 @@ test("real AgentSession: explicit tiny-session wipe ignores keepRecentTokens and
|
|
|
268
268
|
const boot = fixture.sessionManager.getBranch().find((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details && typeof entry.details === "object" && entry.details.windowId === windowId);
|
|
269
269
|
assert.ok(boot && boot.type === "custom_message", "the reset boot is persisted after the marker");
|
|
270
270
|
assert.equal(boot.details.windowId, windowId);
|
|
271
|
-
|
|
271
|
+
const checkpoint = fixture.sessionManager.getBranch().find((entry) => entry.type === "compaction");
|
|
272
|
+
assert.ok(checkpoint && checkpoint.type === "compaction", "explicit wipe commits a native checkpoint without a summary model call");
|
|
273
|
+
assert.equal(checkpoint.summary, "");
|
|
274
|
+
assert.equal(checkpoint.firstKeptEntryId, checkpoint.id, "the checkpoint retains none of the preceding context");
|
|
272
275
|
assert.ok(JSON.stringify(fixture.sessionManager.getBranch()).includes("OLD_CONTEXT_SENTINEL"), "raw history remains readable");
|
|
273
276
|
}
|
|
274
277
|
finally {
|
|
275
278
|
fixture.close();
|
|
276
279
|
}
|
|
277
280
|
});
|
|
281
|
+
test("real AgentSession: manual close-out spans note turns and commits its checkpoint after notes", { timeout: 20000 }, async () => {
|
|
282
|
+
let fixture;
|
|
283
|
+
fixture = await openFixture({
|
|
284
|
+
compactionEnabled: false,
|
|
285
|
+
script: (request, context) => {
|
|
286
|
+
if (request === 1)
|
|
287
|
+
return assistant(fixture, [{ type: "text", text: "before manual close-out" }]);
|
|
288
|
+
if (request === 2) {
|
|
289
|
+
assert.ok(text(context).includes(WARNING_PROMPT), "manual /wipe-memory sends the shared close-out warning");
|
|
290
|
+
return assistant(fixture, [{ type: "toolCall", id: "manual-note-a", name: "notes_write", arguments: { address: "manual-a.md", content: "MANUAL_NOTE_ALPHA" } }], "toolUse");
|
|
291
|
+
}
|
|
292
|
+
if (request === 3)
|
|
293
|
+
return assistant(fixture, [{ type: "toolCall", id: "manual-note-b", name: "notes_write", arguments: { address: "manual-b.md", content: "MANUAL_NOTE_BETA" } }], "toolUse");
|
|
294
|
+
if (request === 4)
|
|
295
|
+
return assistant(fixture, [{ type: "toolCall", id: "manual-wipe", name: "wipe_memory", arguments: {} }], "toolUse");
|
|
296
|
+
assertFreshRequest(fixture, request - 1, "MANUAL_CLOSEOUT_OLD_SENTINEL");
|
|
297
|
+
return assistant(fixture, [{ type: "text", text: "new window resumed" }]);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
await fixture.session.prompt("MANUAL_CLOSEOUT_OLD_SENTINEL");
|
|
302
|
+
await fixture.session.waitForIdle();
|
|
303
|
+
await fixture.session.prompt("/wipe-memory");
|
|
304
|
+
await fixture.session.waitForIdle();
|
|
305
|
+
assert.equal(fixture.requests.length, 5, "two note turns, explicit wipe, and one fresh continuation run");
|
|
306
|
+
assert.equal(resetMarkers(fixture).length, 1);
|
|
307
|
+
const branch = fixture.sessionManager.getBranch();
|
|
308
|
+
const warningIndex = branch.findIndex((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE);
|
|
309
|
+
const checkpointIndex = branch.findIndex((entry) => entry.type === "compaction");
|
|
310
|
+
const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
|
|
311
|
+
assert.ok(warningIndex >= 0 && warningIndex < checkpointIndex, "the warning is durable before the checkpoint");
|
|
312
|
+
const warning = branch[warningIndex];
|
|
313
|
+
assert.ok(warning?.type === "custom_message");
|
|
314
|
+
assert.equal(warning.content, WARNING_CONTENT, "manual and budget close-out persist the same hidden warning");
|
|
315
|
+
assert.equal(branch[markerIndex - 1]?.id, branch[checkpointIndex]?.id, "native retain-none checkpoint immediately precedes the marker");
|
|
316
|
+
assert.ok(branch.slice(0, checkpointIndex).filter((entry) => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "notes_write").length >= 2, "both note writes finish before the checkpoint");
|
|
317
|
+
const marker = resetMarkers(fixture)[0];
|
|
318
|
+
assert.equal(marker.type, "custom");
|
|
319
|
+
const windowId = marker.data.windowId;
|
|
320
|
+
const boot = branch.find((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId);
|
|
321
|
+
assert.ok(boot && boot.type === "custom_message");
|
|
322
|
+
assert.ok(typeof boot.content === "string" && boot.content.includes("manual-a.md") && boot.content.includes("manual-b.md"), "the fresh boot indexes both completed notes");
|
|
323
|
+
assert.equal(boot.display, false);
|
|
324
|
+
const canonical = fixture.sessionManager.buildSessionProjection().messages;
|
|
325
|
+
assert.equal(JSON.stringify(canonical).includes("MANUAL_CLOSEOUT_OLD_SENTINEL"), false, "canonical context excludes the prior window");
|
|
326
|
+
assert.ok(canonical.some((message) => message.role === "compactionSummary"), "the empty native summary wrapper remains canonical");
|
|
327
|
+
assert.ok(JSON.stringify(branch).includes("MANUAL_CLOSEOUT_OLD_SENTINEL"), "raw history remains intact");
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
fixture.close();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
test("real AgentSession: repeated manual commands share one pending window and normal-stop fallback", { timeout: 20000 }, async () => {
|
|
334
|
+
let fixture;
|
|
335
|
+
let announceStarted;
|
|
336
|
+
let releaseResponse;
|
|
337
|
+
const started = new Promise((resolve) => { announceStarted = resolve; });
|
|
338
|
+
const gate = new Promise((resolve) => { releaseResponse = resolve; });
|
|
339
|
+
fixture = await openFixture({
|
|
340
|
+
compactionEnabled: false,
|
|
341
|
+
script: async (request) => {
|
|
342
|
+
if (request === 1) {
|
|
343
|
+
announceStarted();
|
|
344
|
+
await gate;
|
|
345
|
+
}
|
|
346
|
+
return assistant(fixture, [{ type: "text", text: "close-out complete" }]);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
const first = fixture.session.prompt("/wipe-memory");
|
|
351
|
+
await started;
|
|
352
|
+
const second = fixture.session.prompt("/wipe-memory");
|
|
353
|
+
releaseResponse();
|
|
354
|
+
await Promise.all([first, second]);
|
|
355
|
+
await fixture.session.waitForIdle();
|
|
356
|
+
assert.equal(fixture.requests.length, 2, "duplicate command waits do not schedule a second fresh-window request");
|
|
357
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1, "the same pending-window warning is deduplicated");
|
|
358
|
+
assert.equal(resetMarkers(fixture).length, 1, "normal stop commits exactly one reset");
|
|
359
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
releaseResponse();
|
|
363
|
+
fixture.close();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
test("real AgentSession: user abort, synthetic aborted response, and generic error never fall back", { timeout: 25000 }, async () => {
|
|
367
|
+
for (const mode of ["ctx-abort", "synthetic-abort", "generic-error"]) {
|
|
368
|
+
let fixture;
|
|
369
|
+
let didAbort = false;
|
|
370
|
+
fixture = await openFixture({
|
|
371
|
+
compactionEnabled: false,
|
|
372
|
+
hook: mode === "ctx-abort" ? (pi) => pi.on("context", (_event, ctx) => {
|
|
373
|
+
if (!didAbort) {
|
|
374
|
+
didAbort = true;
|
|
375
|
+
ctx.abort();
|
|
376
|
+
}
|
|
377
|
+
}) : undefined,
|
|
378
|
+
script: (request) => {
|
|
379
|
+
if (request === 1 && mode === "synthetic-abort")
|
|
380
|
+
return assistant(fixture, [{ type: "text", text: "synthetic aborted" }], "aborted");
|
|
381
|
+
if (request === 1 && mode === "generic-error")
|
|
382
|
+
return assistant(fixture, [{ type: "text", text: "request failed" }], "error", { errorMessage: "synthetic generic provider failure" });
|
|
383
|
+
return assistant(fixture, [{ type: "text", text: "later user turn" }]);
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
try {
|
|
387
|
+
await fixture.session.prompt("/wipe-memory");
|
|
388
|
+
await fixture.session.waitForIdle();
|
|
389
|
+
assert.equal(resetMarkers(fixture).length, 0, `${mode} does not force a reset`);
|
|
390
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 0);
|
|
391
|
+
assert.equal(fixture.notices.some((notice) => notice.includes("memory cleared")), false);
|
|
392
|
+
if (mode === "ctx-abort")
|
|
393
|
+
assert.equal(fixture.streamSignals.at(-1), true, "this case aborts the real Pi operation signal");
|
|
394
|
+
else
|
|
395
|
+
assert.equal(fixture.streamSignals.at(-1), false, "synthetic abort/error is not a user abort signal");
|
|
396
|
+
await fixture.session.prompt("AFTER_UNSUCCESSFUL_CLOSEOUT");
|
|
397
|
+
await fixture.session.waitForIdle();
|
|
398
|
+
assert.equal(resetMarkers(fixture).length, 0, "no pending close-out leaks into a later prompt");
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
fixture.close();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
test("real AgentSession: steering and follow-up queued during fallback reach the old window once", { timeout: 25000 }, async () => {
|
|
406
|
+
for (const delivery of ["steer", "followUp"]) {
|
|
407
|
+
let fixture;
|
|
408
|
+
let announceStarted;
|
|
409
|
+
let releaseResponse;
|
|
410
|
+
const started = new Promise((resolve) => { announceStarted = resolve; });
|
|
411
|
+
const gate = new Promise((resolve) => { releaseResponse = resolve; });
|
|
412
|
+
fixture = await openFixture({
|
|
413
|
+
compactionEnabled: false,
|
|
414
|
+
script: async (request, context) => {
|
|
415
|
+
if (request === 1) {
|
|
416
|
+
assert.ok(text(context).includes(WARNING_PROMPT));
|
|
417
|
+
announceStarted();
|
|
418
|
+
await gate;
|
|
419
|
+
}
|
|
420
|
+
if (request === 2)
|
|
421
|
+
assert.equal(text(context).split("QUEUED_DURING_FALLBACK").length - 1, 1, `${delivery} is delivered once before fallback`);
|
|
422
|
+
return assistant(fixture, [{ type: "text", text: `response ${request}` }]);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
try {
|
|
426
|
+
const command = fixture.session.prompt("/wipe-memory");
|
|
427
|
+
await started;
|
|
428
|
+
if (delivery === "steer")
|
|
429
|
+
fixture.session.steer("QUEUED_DURING_FALLBACK");
|
|
430
|
+
else
|
|
431
|
+
fixture.session.followUp("QUEUED_DURING_FALLBACK");
|
|
432
|
+
releaseResponse();
|
|
433
|
+
await command;
|
|
434
|
+
await fixture.session.waitForIdle();
|
|
435
|
+
assert.equal(fixture.requests.length, 3, `${delivery} runs once before one fresh continuation`);
|
|
436
|
+
assert.equal(resetMarkers(fixture).length, 1, `${delivery} does not duplicate the fallback reset`);
|
|
437
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_DURING_FALLBACK")).length, 1);
|
|
438
|
+
assertFreshRequest(fixture, 2, "QUEUED_DURING_FALLBACK");
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
releaseResponse();
|
|
442
|
+
fixture.close();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
278
446
|
test("real AgentSession: a reset survives all notes-home read failures with an incomplete boot", { timeout: 15000 }, async () => {
|
|
279
447
|
let fixture;
|
|
280
448
|
fixture = await openFixture({
|
|
@@ -390,6 +558,66 @@ test("real AgentSession: successive resets and a mixed tool batch cut only after
|
|
|
390
558
|
}
|
|
391
559
|
}
|
|
392
560
|
});
|
|
561
|
+
test("real AgentSession: two retain-none checkpoints survive session-file reopen", { timeout: 20000 }, async () => {
|
|
562
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "pi-context-persisted-session-"));
|
|
563
|
+
let fixture;
|
|
564
|
+
let sessionFile = "";
|
|
565
|
+
let sessionId = "";
|
|
566
|
+
fixture = await openFixture({
|
|
567
|
+
compactionEnabled: false,
|
|
568
|
+
sessionDir,
|
|
569
|
+
script: (request) => request < 3
|
|
570
|
+
? assistant(fixture, [{ type: "toolCall", id: `persisted-wipe-${request}`, name: "wipe_memory", arguments: {} }], "toolUse")
|
|
571
|
+
: assistant(fixture, [{ type: "text", text: "second reset window complete" }]),
|
|
572
|
+
});
|
|
573
|
+
try {
|
|
574
|
+
await fixture.session.prompt("PERSISTED_OLD_WINDOW_SENTINEL");
|
|
575
|
+
await fixture.session.waitForIdle();
|
|
576
|
+
assert.equal(fixture.requests.length, 3, "two explicit requests and one final continuation, with no summary-model request");
|
|
577
|
+
assert.equal(resetMarkers(fixture).length, 2);
|
|
578
|
+
assertFreshRequest(fixture, 2, "PERSISTED_OLD_WINDOW_SENTINEL");
|
|
579
|
+
const branch = fixture.sessionManager.getBranch();
|
|
580
|
+
const checkpoints = branch.filter((entry) => entry.type === "compaction");
|
|
581
|
+
assert.equal(checkpoints.length, 2);
|
|
582
|
+
const markers = resetMarkers(fixture);
|
|
583
|
+
assert.equal(markers[0]?.parentId, checkpoints[0]?.id);
|
|
584
|
+
assert.equal(markers[1]?.parentId, checkpoints[1]?.id);
|
|
585
|
+
assert.equal(checkpoints[0]?.type === "compaction" ? checkpoints[0].firstKeptEntryId : undefined, checkpoints[0]?.id);
|
|
586
|
+
assert.equal(checkpoints[1]?.type === "compaction" ? checkpoints[1].firstKeptEntryId : undefined, checkpoints[1]?.id);
|
|
587
|
+
sessionFile = fixture.sessionManager.getSessionFile() ?? "";
|
|
588
|
+
sessionId = fixture.sessionManager.getSessionId();
|
|
589
|
+
assert.ok(sessionFile && existsSync(sessionFile), "the SDK persisted the real session file");
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
fixture.close();
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
const reopened = SessionManager.open(sessionFile, sessionDir);
|
|
596
|
+
assert.equal(reopened.getSessionId(), sessionId);
|
|
597
|
+
const branch = reopened.getBranch();
|
|
598
|
+
assert.equal(branch.filter((entry) => entry.type === "compaction").length, 2);
|
|
599
|
+
assert.equal(branch.filter((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE).length, 2);
|
|
600
|
+
assert.ok(JSON.stringify(branch).includes("PERSISTED_OLD_WINDOW_SENTINEL"), "raw history survives disk reopen");
|
|
601
|
+
const canonical = reopened.buildSessionProjection().messages;
|
|
602
|
+
assert.equal(JSON.stringify(canonical).includes("PERSISTED_OLD_WINDOW_SENTINEL"), false, "disk-loaded canonical context agrees with the fresh request");
|
|
603
|
+
assert.ok(canonical.some((message) => message.role === "compactionSummary"), "the accepted empty-summary wrapper survives reload");
|
|
604
|
+
const system = getCurrentSystemMessage(canonical);
|
|
605
|
+
assert.ok(JSON.stringify(system).includes("Use the tools as requested."), "the complete system prompt survives the checkpoint");
|
|
606
|
+
assert.ok(system?.toolsAdded?.some((tool) => tool.name === "wipe_memory"), "the active tool loadout survives reopen");
|
|
607
|
+
const reopenedMarkers = branch.filter((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
|
|
608
|
+
const latestMarker = reopenedMarkers.at(-1);
|
|
609
|
+
const previousMarker = reopenedMarkers[0];
|
|
610
|
+
assert.ok(latestMarker?.type === "custom" && previousMarker?.type === "custom");
|
|
611
|
+
const latestWindowId = latestMarker.data.windowId;
|
|
612
|
+
const previousWindowId = previousMarker.data.windowId;
|
|
613
|
+
const latestBoot = branch.find((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === latestWindowId);
|
|
614
|
+
assert.ok(latestBoot && latestBoot.type === "custom_message" && typeof latestBoot.content === "string");
|
|
615
|
+
assert.ok(latestBoot.content.includes(`Previous context window id: ${previousWindowId}`));
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
393
621
|
test("real AgentSession: steering and follow-up are delivered exactly once in the fresh window", async () => {
|
|
394
622
|
for (const deliverAs of ["steer", "followUp"]) {
|
|
395
623
|
let fixture;
|
|
@@ -531,6 +759,7 @@ test("real AgentSession: overflow and recoverable length reset and retry once; r
|
|
|
531
759
|
await fixture.session.prompt(`RECOVER_${failure}_SENTINEL`);
|
|
532
760
|
await fixture.session.waitForIdle();
|
|
533
761
|
assert.equal(resetMarkers(fixture).length, 1, `${failure} performs one reset`);
|
|
762
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1, `${failure} commits one native reset checkpoint`);
|
|
534
763
|
assert.equal(fixture.requests.length, 2, `${failure} resumes once`);
|
|
535
764
|
assertFreshRequest(fixture, 1, `RECOVER_${failure}_SENTINEL`);
|
|
536
765
|
}
|
|
@@ -546,8 +775,8 @@ test("real AgentSession: overflow and recoverable length reset and retry once; r
|
|
|
546
775
|
await repeated.session.prompt("REPEATED_OVERFLOW_SENTINEL");
|
|
547
776
|
await repeated.session.waitForIdle();
|
|
548
777
|
assert.equal(resetMarkers(repeated).length, 1, "repeated overflow does not create repeated windows");
|
|
778
|
+
assert.equal(repeated.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1, "bounded overflow recovery commits one retain-none checkpoint");
|
|
549
779
|
assert.equal(repeated.requests.length, 2, "repeated overflow stops after one recovery request");
|
|
550
|
-
assert.ok(repeated.events.some((event) => event.type === "compaction_end" && typeof event.errorMessage === "string"));
|
|
551
780
|
}
|
|
552
781
|
finally {
|
|
553
782
|
repeated.close();
|
|
@@ -709,7 +938,7 @@ test("real AgentSession: injected settings managers own live policy and ignore c
|
|
|
709
938
|
await first.session.waitForIdle();
|
|
710
939
|
assert.equal(resetMarkers(first).length, 1, "live enablement and a lower reserve apply on the next turn");
|
|
711
940
|
firstHighUsage.next = true;
|
|
712
|
-
firstManager.applyOverrides({ compaction: { reserveTokens:
|
|
941
|
+
firstManager.applyOverrides({ compaction: { reserveTokens: 80_000 } });
|
|
713
942
|
await first.session.prompt("INJECTED_LIVE_HIGH_RESERVE");
|
|
714
943
|
await first.session.waitForIdle();
|
|
715
944
|
assert.equal(resetMarkers(first).length, 2, "a live reserve increase drives the next reset decision");
|
|
@@ -781,14 +1010,83 @@ test("real AgentSession: injected model overrides select the active model's rese
|
|
|
781
1010
|
rmSync(notesRoot, { recursive: true, force: true });
|
|
782
1011
|
}
|
|
783
1012
|
});
|
|
1013
|
+
test("real AgentSession: persisted budget warning re-arms after a later abort/error", { timeout: 30000 }, async () => {
|
|
1014
|
+
for (const mode of ["ctx-abort", "generic-error"]) {
|
|
1015
|
+
let fixture;
|
|
1016
|
+
let warningTurnStarted;
|
|
1017
|
+
let releaseWarningTurn;
|
|
1018
|
+
let didAbort = false;
|
|
1019
|
+
const warningStarted = new Promise((resolve) => { warningTurnStarted = resolve; });
|
|
1020
|
+
const warningGate = new Promise((resolve) => { releaseWarningTurn = resolve; });
|
|
1021
|
+
fixture = await openFixture({
|
|
1022
|
+
compactionEnabled: true,
|
|
1023
|
+
hook: mode === "ctx-abort" ? (pi, _getSession, requests) => pi.on("context", (_event, ctx) => {
|
|
1024
|
+
if (!didAbort && requests.length === 3) {
|
|
1025
|
+
didAbort = true;
|
|
1026
|
+
ctx.abort();
|
|
1027
|
+
}
|
|
1028
|
+
}) : undefined,
|
|
1029
|
+
script: async (request, context) => {
|
|
1030
|
+
if (request === 1)
|
|
1031
|
+
return assistant(fixture, [{ type: "toolCall", id: "rearm-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
|
|
1032
|
+
if (request === 2)
|
|
1033
|
+
return assistant(fixture, [{ type: "toolCall", id: "rearm-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
1034
|
+
if (request === 3) {
|
|
1035
|
+
assert.ok(text(context).includes(WARNING_PROMPT), "the low-budget request carries the close-out warning");
|
|
1036
|
+
warningTurnStarted();
|
|
1037
|
+
await warningGate;
|
|
1038
|
+
return assistant(fixture, [{ type: "toolCall", id: "rearm-note", name: "notes_write", arguments: { address: "warning-rearm.md", content: "DURABLE_WARNING_CHECKPOINT" } }], "toolUse", { usage: usage(60_000) });
|
|
1039
|
+
}
|
|
1040
|
+
if (request === 4 && mode === "generic-error") {
|
|
1041
|
+
assert.equal(text(context).split(WARNING_PROMPT).length - 1, 1, "the durable warning is not injected a second time");
|
|
1042
|
+
assert.ok(text(context).includes("REARM_QUEUED_FOLLOWUP"));
|
|
1043
|
+
return assistant(fixture, [{ type: "text", text: "ordinary provider failure" }], "error", { errorMessage: "synthetic non-overflow failure", usage: usage(60_000) });
|
|
1044
|
+
}
|
|
1045
|
+
return assistant(fixture, [{ type: "text", text: `normal response ${request}` }], "stop", { usage: usage(60_000) });
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
try {
|
|
1049
|
+
const initialTurn = fixture.session.prompt(`REARM_${mode}_INITIAL`);
|
|
1050
|
+
await warningStarted;
|
|
1051
|
+
fixture.session.steer("REARM_QUEUED_FOLLOWUP");
|
|
1052
|
+
releaseWarningTurn();
|
|
1053
|
+
await initialTurn;
|
|
1054
|
+
await fixture.session.waitForIdle();
|
|
1055
|
+
const afterFailure = fixture.sessionManager.getBranch();
|
|
1056
|
+
assert.equal(afterFailure.filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1, "the warning was persisted exactly once before the failed follow-up");
|
|
1057
|
+
assert.ok(existsSync(join(fixture.notesRoot, "pi", "session", fixture.sessionManager.getSessionId(), "warning-rearm.md")), "the preceding note/tool turn completed");
|
|
1058
|
+
assert.equal(resetMarkers(fixture).length, 0, `${mode} itself must not reset`);
|
|
1059
|
+
assert.equal(afterFailure.filter((entry) => entry.type === "compaction").length, 0);
|
|
1060
|
+
const failedContext = fixture.streamContexts[3];
|
|
1061
|
+
assert.ok(failedContext);
|
|
1062
|
+
assert.equal(text(failedContext).split(WARNING_PROMPT).length - 1, 1, "the persisted warning appears once, without duplicate injection");
|
|
1063
|
+
assert.equal(text(failedContext).split("REARM_QUEUED_FOLLOWUP").length - 1, 1, "queued follow-up reaches the failed request once");
|
|
1064
|
+
assert.equal(fixture.streamSignals.at(-1), mode === "ctx-abort", "the abort case cancels Pi's real signal; the error case does not");
|
|
1065
|
+
await fixture.session.prompt("REARM_AFTER_FAILED_TURN");
|
|
1066
|
+
await fixture.session.waitForIdle();
|
|
1067
|
+
assert.equal(resetMarkers(fixture).length, 1, "the next low-budget normal stop re-arms and commits one reset");
|
|
1068
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1, "re-arming does not duplicate the durable warning");
|
|
1069
|
+
const checkpoint = fixture.sessionManager.getBranch().find((entry) => entry.type === "compaction");
|
|
1070
|
+
assert.ok(checkpoint?.type === "compaction");
|
|
1071
|
+
assert.equal(checkpoint.firstKeptEntryId, checkpoint.id, "the retry closes with one retain-none checkpoint");
|
|
1072
|
+
assertFreshRequest(fixture, fixture.requests.length - 1, "REARM_AFTER_FAILED_TURN");
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
releaseWarningTurn();
|
|
1076
|
+
fixture.close();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
784
1080
|
test("real AgentSession: warning precedes a durable checkpoint, including failed writes and an ignored warning", { timeout: 20000 }, async () => {
|
|
785
1081
|
for (const scenario of ["checkpoint", "write-error", "ignored-warning"]) {
|
|
786
1082
|
let fixture;
|
|
787
1083
|
fixture = await openFixture({
|
|
788
1084
|
compactionEnabled: true,
|
|
789
1085
|
script: (request, context) => {
|
|
790
|
-
if (request === 3)
|
|
791
|
-
assert.ok(text(context).includes(GUIDANCE_OPEN_TAG), "the
|
|
1086
|
+
if (request === 3) {
|
|
1087
|
+
assert.ok(text(context).includes(GUIDANCE_OPEN_TAG), "the warning remains in its hidden guidance wrapper");
|
|
1088
|
+
assert.ok(text(context).includes(WARNING_PROMPT), "the shared close-out warning reaches the provider before checkpoint choice");
|
|
1089
|
+
}
|
|
792
1090
|
if (request === 1)
|
|
793
1091
|
return assistant(fixture, [{ type: "toolCall", id: "budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
|
|
794
1092
|
if (request === 2)
|
|
@@ -821,8 +1119,9 @@ test("real AgentSession: warning precedes a durable checkpoint, including failed
|
|
|
821
1119
|
assert.ok(branch.some((entry) => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "notes_write" && entry.message.isError), "the failed write is a durable tool error");
|
|
822
1120
|
}
|
|
823
1121
|
else {
|
|
824
|
-
assert.equal(resetMarkers(fixture).length,
|
|
1122
|
+
assert.equal(resetMarkers(fixture).length, 1, "normal stop falls back to the same reset even when the warning was ignored");
|
|
825
1123
|
assert.equal(existsSync(noteFile), false);
|
|
1124
|
+
assertFreshRequest(fixture, fixture.requests.length - 1, `WARNING_${scenario.toUpperCase()}_SENTINEL`);
|
|
826
1125
|
}
|
|
827
1126
|
}
|
|
828
1127
|
finally {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|