@astrosheep/pi-context 0.25.2 → 0.26.1

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.
Files changed (120) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +635 -381
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +51 -28
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +276 -236
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/text-match.d.ts +5 -0
  60. package/dist/src/text-match.js +15 -0
  61. package/dist/src/tool-output.d.ts +97 -0
  62. package/dist/src/tool-output.js +1 -15
  63. package/dist/src/tool-schema.d.ts +17 -0
  64. package/dist/test/agent-loop.test.d.ts +1 -0
  65. package/dist/test/agent-loop.test.js +387 -20
  66. package/dist/test/boot.integration.test.d.ts +1 -0
  67. package/dist/test/boot.integration.test.js +59 -35
  68. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  69. package/dist/test/budget-settings.integration.test.js +28 -12
  70. package/dist/test/doctor.test.d.ts +1 -0
  71. package/dist/test/doctor.test.js +10 -2
  72. package/dist/test/dream-skill.test.d.ts +1 -0
  73. package/dist/test/dream-skill.test.js +69 -0
  74. package/dist/test/dream.test.d.ts +1 -0
  75. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  76. package/dist/test/helpers/extension-test-environment.js +9 -0
  77. package/dist/test/helpers/extension.d.ts +104 -0
  78. package/dist/test/helpers/extension.js +7 -81
  79. package/dist/test/helpers/notes.d.ts +6 -0
  80. package/dist/test/helpers/notes.js +13 -0
  81. package/dist/test/history.integration.test.d.ts +1 -0
  82. package/dist/test/history.integration.test.js +17 -19
  83. package/dist/test/notes-library.test.d.ts +1 -0
  84. package/dist/test/notes-library.test.js +128 -0
  85. package/dist/test/notes.integration.test.d.ts +1 -0
  86. package/dist/test/notes.integration.test.js +51 -55
  87. package/dist/test/notes.test.d.ts +1 -0
  88. package/dist/test/notes.test.js +162 -34
  89. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  90. package/dist/test/reset-lifecycle.test.js +142 -85
  91. package/docs/architecture.md +8 -8
  92. package/docs/reset-lifecycle.md +63 -79
  93. package/package.json +35 -2
  94. package/playbook.md +33 -32
  95. package/skills/dream/SKILL.md +12 -0
  96. package/src/context/boot.ts +44 -25
  97. package/src/context/budget.ts +52 -27
  98. package/src/context/context-window.ts +16 -1
  99. package/src/context/prompts.ts +2 -2
  100. package/src/context/reset-artifacts.ts +26 -24
  101. package/src/context/reset-lifecycle.ts +117 -111
  102. package/src/context/runtime.ts +50 -22
  103. package/src/context/thresholds.ts +1 -1
  104. package/src/dream/cli.ts +1 -1
  105. package/src/dream/doctor.ts +5 -2
  106. package/src/index.ts +4 -4
  107. package/src/notes/address.ts +9 -15
  108. package/src/notes/constants.ts +3 -0
  109. package/src/notes/context.ts +40 -0
  110. package/src/notes/frontmatter.ts +18 -12
  111. package/src/notes/index.ts +22 -0
  112. package/src/notes/paths.ts +64 -78
  113. package/src/notes/store.ts +289 -238
  114. package/src/pi/notes/adapter.ts +44 -0
  115. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  116. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  117. package/src/{notes → pi/notes}/tools.ts +25 -23
  118. package/src/protocol.ts +5 -6
  119. package/src/text-match.ts +13 -0
  120. package/src/tool-output.ts +2 -14
@@ -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;
@@ -76,7 +76,7 @@ async function openFixture(options) {
76
76
  }],
77
77
  });
78
78
  await loader.reload();
79
- const sessionManager = SessionManager.inMemory(dir);
79
+ const sessionManager = options.sessionManager ?? (options.sessionDir ? SessionManager.create(dir, options.sessionDir) : SessionManager.inMemory(dir));
80
80
  options.seed?.(sessionManager);
81
81
  const created = await createAgentSession({
82
82
  cwd: dir,
@@ -90,7 +90,7 @@ async function openFixture(options) {
90
90
  });
91
91
  session = created.session;
92
92
  session.subscribe((event) => events.push(event));
93
- session.agent.streamFunction = (_model, context, streamOptions) => {
93
+ session.agent.streamFunction = async (_model, context, streamOptions) => {
94
94
  const aborted = streamOptions?.signal?.aborted === true;
95
95
  streamContexts.push(context);
96
96
  streamSignals.push(aborted);
@@ -113,7 +113,7 @@ async function openFixture(options) {
113
113
  }
114
114
  requests.push(context);
115
115
  const request = requests.length;
116
- const message = options.script(request, context);
116
+ const message = await options.script(request, context);
117
117
  const stream = createAssistantMessageEventStream();
118
118
  stream.push({ type: "done", reason: (message.stopReason === "error" || message.stopReason === "aborted" ? "stop" : message.stopReason), message });
119
119
  stream.end();
@@ -127,7 +127,7 @@ async function openFixture(options) {
127
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");
128
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");
129
129
  }
130
- if (type === "warning" && sessionManager.getBranch().some((entry) => entry.type === "custom_message" && (entry.customType === GUIDANCE_TYPE || entry.customType === WARNING_TYPE)))
130
+ if (type === "warning" && /^pi-context: Context (?:almost full|running low)/.test(message))
131
131
  budgetNotices++;
132
132
  notices.push(message);
133
133
  },
@@ -213,30 +213,98 @@ function assertFreshRequest(fixture, requestIndex, oldSentinel) {
213
213
  assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
214
214
  assert.equal(body.split(CONTINUATION).length - 1, 1, "the fresh window carries exactly one reset message");
215
215
  }
216
- test("real AgentSession: aborted low-budget requests notify only after a retry commits the reminder", async () => {
216
+ test("real AgentSession: aborted early guidance retries silently with automatic reset disabled", async () => {
217
217
  let fixture;
218
218
  fixture = await openFixture({
219
219
  compactionEnabled: false,
220
220
  script: (request) => assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 2 ? "aborted" : "stop", { usage: usage(50_000) }),
221
221
  });
222
222
  try {
223
- const reminders = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
223
+ const guidance = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
224
224
  const notices = () => fixture.budgetNotices();
225
225
  await fixture.session.prompt("Establish usage below the reminder line.");
226
226
  await fixture.session.waitForIdle();
227
- assert.equal(reminders().length, 0);
227
+ assert.equal(guidance().length, 0);
228
228
  await fixture.session.prompt("Abort this low-budget request.");
229
229
  await fixture.session.waitForIdle();
230
- assert.equal(reminders().length, 0, "an aborted turn does not commit its reminder");
231
- assert.equal(notices(), 0, "an uncommitted reminder never notifies");
230
+ assert.equal(guidance().length, 0, "an aborted turn does not commit early guidance");
231
+ assert.equal(notices(), 0, "early guidance never notifies");
232
232
  await fixture.session.prompt("Retry successfully.");
233
233
  await fixture.session.waitForIdle();
234
- assert.equal(reminders().length, 1);
235
- assert.equal(notices(), 1, "the committed retry notifies once");
234
+ assert.equal(guidance().length, 1, "the successful retry commits the model guidance");
235
+ assert.equal(notices(), 0, "committed early guidance stays UI-silent");
236
236
  await fixture.session.prompt("Continue in the same window.");
237
237
  await fixture.session.waitForIdle();
238
- assert.equal(reminders().length, 1);
239
- assert.equal(notices(), 1, "later turns cannot repeat the notice");
238
+ assert.equal(guidance().length, 1);
239
+ assert.equal(notices(), 0, "later turns cannot toast the early reminder");
240
+ }
241
+ finally {
242
+ fixture.close();
243
+ }
244
+ });
245
+ test("real AgentSession: an aborted final warning stays silent until a successful retry commits it", async () => {
246
+ let fixture;
247
+ fixture = await openFixture({
248
+ compactionEnabled: true,
249
+ script: (request, context) => {
250
+ if (request === 1)
251
+ return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
252
+ if (request === 2)
253
+ return assistant(fixture, [{ type: "toolCall", id: "abort-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
254
+ assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model on both attempts");
255
+ return assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 3 ? "aborted" : "stop", { usage: usage(60_000) });
256
+ },
257
+ });
258
+ try {
259
+ const warnings = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE);
260
+ const notices = () => fixture.budgetNotices();
261
+ await fixture.session.prompt("Establish usage and reach the final runway.");
262
+ await fixture.session.waitForIdle();
263
+ assert.equal(fixture.requests.length, 3, "budget probes reach one final-warning attempt");
264
+ assert.equal(warnings().length, 0, "the aborted attempt leaves no durable warning");
265
+ assert.equal(notices(), 0, "an uncommitted warning never notifies");
266
+ await fixture.session.prompt("Retry successfully.");
267
+ await fixture.session.waitForIdle();
268
+ assert.equal(warnings().length, 1, "the successful retry commits one hidden warning");
269
+ assert.equal(resetMarkers(fixture).length, 1, "normal close-out resets after the committed warning");
270
+ assert.equal(notices(), 1, "the committed warning notifies once, including after reset");
271
+ assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance remains UI-silent");
272
+ }
273
+ finally {
274
+ fixture.close();
275
+ }
276
+ });
277
+ test("real AgentSession: final warning survives its reset boundary and notifies once", { timeout: 20000 }, async () => {
278
+ let fixture;
279
+ fixture = await openFixture({
280
+ compactionEnabled: true,
281
+ script: (request, context) => {
282
+ if (request === 1)
283
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
284
+ if (request === 2)
285
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
286
+ if (request === 3) {
287
+ assert.ok(text(context).includes(WARNING_PROMPT), "the final warning reaches the model");
288
+ return assistant(fixture, [{ type: "toolCall", id: "final-budget-wipe", name: "wipe_memory", arguments: {} }], "toolUse", { usage: usage(60_000) });
289
+ }
290
+ return assistant(fixture, [{ type: "text", text: "fresh window ready" }]);
291
+ },
292
+ });
293
+ try {
294
+ assert.equal(fixture.notices.length, 0, "boot is silent");
295
+ await fixture.session.prompt("FINAL_WARNING_RESET_SENTINEL");
296
+ await fixture.session.waitForIdle();
297
+ assert.equal(fixture.requests.length, 4, "two budget probes, close-out tool, and fresh-window request run");
298
+ assertFreshRequest(fixture, 3, "FINAL_WARNING_RESET_SENTINEL");
299
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1);
300
+ assert.equal(resetMarkers(fixture).length, 1, "the reset commits in the same boundary as the final warning");
301
+ assert.equal(fixture.budgetNotices(), 1, "the old-window committed warning reaches the UI after reset");
302
+ assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "reset notification remains intact");
303
+ assert.equal(fixture.notices.some((notice) => notice.includes("Context running low")), false, "early guidance stays silent");
304
+ await fixture.session.prompt("Continue after reset.");
305
+ await fixture.session.waitForIdle();
306
+ assert.equal(fixture.budgetNotices(), 1, "later turns cannot repeat the consumed final warning");
307
+ assert.equal(fixture.notices.filter((notice) => notice.startsWith("pi-context: memory cleared · ")).length, 1, "the prior reset notification is also deduplicated");
240
308
  }
241
309
  finally {
242
310
  fixture.close();
@@ -268,13 +336,181 @@ test("real AgentSession: explicit tiny-session wipe ignores keepRecentTokens and
268
336
  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
337
  assert.ok(boot && boot.type === "custom_message", "the reset boot is persisted after the marker");
270
338
  assert.equal(boot.details.windowId, windowId);
271
- assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 0, "the explicit path does not manufacture a native compaction");
339
+ const checkpoint = fixture.sessionManager.getBranch().find((entry) => entry.type === "compaction");
340
+ assert.ok(checkpoint && checkpoint.type === "compaction", "explicit wipe commits a native checkpoint without a summary model call");
341
+ assert.equal(checkpoint.summary, "");
342
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id, "the checkpoint retains none of the preceding context");
272
343
  assert.ok(JSON.stringify(fixture.sessionManager.getBranch()).includes("OLD_CONTEXT_SENTINEL"), "raw history remains readable");
273
344
  }
274
345
  finally {
275
346
  fixture.close();
276
347
  }
277
348
  });
349
+ test("real AgentSession: manual close-out spans note turns and commits its checkpoint after notes", { timeout: 20000 }, async () => {
350
+ let fixture;
351
+ fixture = await openFixture({
352
+ compactionEnabled: false,
353
+ script: (request, context) => {
354
+ if (request === 1)
355
+ return assistant(fixture, [{ type: "text", text: "before manual close-out" }]);
356
+ if (request === 2) {
357
+ assert.ok(text(context).includes(WARNING_PROMPT), "manual /wipe-memory sends the shared close-out warning");
358
+ return assistant(fixture, [{ type: "toolCall", id: "manual-note-a", name: "notes_write", arguments: { address: "manual-a.md", content: "MANUAL_NOTE_ALPHA" } }], "toolUse");
359
+ }
360
+ if (request === 3)
361
+ return assistant(fixture, [{ type: "toolCall", id: "manual-note-b", name: "notes_write", arguments: { address: "manual-b.md", content: "MANUAL_NOTE_BETA" } }], "toolUse");
362
+ if (request === 4)
363
+ return assistant(fixture, [{ type: "toolCall", id: "manual-wipe", name: "wipe_memory", arguments: {} }], "toolUse");
364
+ assertFreshRequest(fixture, request - 1, "MANUAL_CLOSEOUT_OLD_SENTINEL");
365
+ return assistant(fixture, [{ type: "text", text: "new window resumed" }]);
366
+ },
367
+ });
368
+ try {
369
+ await fixture.session.prompt("MANUAL_CLOSEOUT_OLD_SENTINEL");
370
+ await fixture.session.waitForIdle();
371
+ await fixture.session.prompt("/wipe-memory");
372
+ await fixture.session.waitForIdle();
373
+ assert.equal(fixture.requests.length, 5, "two note turns, explicit wipe, and one fresh continuation run");
374
+ assert.equal(resetMarkers(fixture).length, 1);
375
+ const branch = fixture.sessionManager.getBranch();
376
+ const warningIndex = branch.findIndex((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE);
377
+ const checkpointIndex = branch.findIndex((entry) => entry.type === "compaction");
378
+ const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
379
+ assert.ok(warningIndex >= 0 && warningIndex < checkpointIndex, "the warning is durable before the checkpoint");
380
+ const warning = branch[warningIndex];
381
+ assert.ok(warning?.type === "custom_message");
382
+ assert.equal(warning.content, WARNING_CONTENT, "manual and budget close-out persist the same hidden warning");
383
+ assert.equal(branch[markerIndex - 1]?.id, branch[checkpointIndex]?.id, "native retain-none checkpoint immediately precedes the marker");
384
+ 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");
385
+ const marker = resetMarkers(fixture)[0];
386
+ assert.equal(marker.type, "custom");
387
+ const windowId = marker.data.windowId;
388
+ const boot = branch.find((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId);
389
+ assert.ok(boot && boot.type === "custom_message");
390
+ 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");
391
+ assert.equal(boot.display, false);
392
+ const canonical = fixture.sessionManager.buildSessionProjection().messages;
393
+ assert.equal(JSON.stringify(canonical).includes("MANUAL_CLOSEOUT_OLD_SENTINEL"), false, "canonical context excludes the prior window");
394
+ assert.ok(canonical.some((message) => message.role === "compactionSummary"), "the empty native summary wrapper remains canonical");
395
+ assert.ok(JSON.stringify(branch).includes("MANUAL_CLOSEOUT_OLD_SENTINEL"), "raw history remains intact");
396
+ }
397
+ finally {
398
+ fixture.close();
399
+ }
400
+ });
401
+ test("real AgentSession: repeated manual commands share one pending window and normal-stop fallback", { timeout: 20000 }, async () => {
402
+ let fixture;
403
+ let announceStarted;
404
+ let releaseResponse;
405
+ const started = new Promise((resolve) => { announceStarted = resolve; });
406
+ const gate = new Promise((resolve) => { releaseResponse = resolve; });
407
+ fixture = await openFixture({
408
+ compactionEnabled: false,
409
+ script: async (request) => {
410
+ if (request === 1) {
411
+ announceStarted();
412
+ await gate;
413
+ }
414
+ return assistant(fixture, [{ type: "text", text: "close-out complete" }]);
415
+ },
416
+ });
417
+ try {
418
+ const first = fixture.session.prompt("/wipe-memory");
419
+ await started;
420
+ const second = fixture.session.prompt("/wipe-memory");
421
+ releaseResponse();
422
+ await Promise.all([first, second]);
423
+ await fixture.session.waitForIdle();
424
+ assert.equal(fixture.requests.length, 2, "duplicate command waits do not schedule a second fresh-window request");
425
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1, "the same pending-window warning is deduplicated");
426
+ assert.equal(resetMarkers(fixture).length, 1, "normal stop commits exactly one reset");
427
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1);
428
+ }
429
+ finally {
430
+ releaseResponse();
431
+ fixture.close();
432
+ }
433
+ });
434
+ test("real AgentSession: user abort, synthetic aborted response, and generic error never fall back", { timeout: 25000 }, async () => {
435
+ for (const mode of ["ctx-abort", "synthetic-abort", "generic-error"]) {
436
+ let fixture;
437
+ let didAbort = false;
438
+ fixture = await openFixture({
439
+ compactionEnabled: false,
440
+ hook: mode === "ctx-abort" ? (pi) => pi.on("context", (_event, ctx) => {
441
+ if (!didAbort) {
442
+ didAbort = true;
443
+ ctx.abort();
444
+ }
445
+ }) : undefined,
446
+ script: (request) => {
447
+ if (request === 1 && mode === "synthetic-abort")
448
+ return assistant(fixture, [{ type: "text", text: "synthetic aborted" }], "aborted");
449
+ if (request === 1 && mode === "generic-error")
450
+ return assistant(fixture, [{ type: "text", text: "request failed" }], "error", { errorMessage: "synthetic generic provider failure" });
451
+ return assistant(fixture, [{ type: "text", text: "later user turn" }]);
452
+ },
453
+ });
454
+ try {
455
+ await fixture.session.prompt("/wipe-memory");
456
+ await fixture.session.waitForIdle();
457
+ assert.equal(resetMarkers(fixture).length, 0, `${mode} does not force a reset`);
458
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 0);
459
+ assert.equal(fixture.notices.some((notice) => notice.includes("memory cleared")), false);
460
+ if (mode === "ctx-abort")
461
+ assert.equal(fixture.streamSignals.at(-1), true, "this case aborts the real Pi operation signal");
462
+ else
463
+ assert.equal(fixture.streamSignals.at(-1), false, "synthetic abort/error is not a user abort signal");
464
+ await fixture.session.prompt("AFTER_UNSUCCESSFUL_CLOSEOUT");
465
+ await fixture.session.waitForIdle();
466
+ assert.equal(resetMarkers(fixture).length, 0, "no pending close-out leaks into a later prompt");
467
+ }
468
+ finally {
469
+ fixture.close();
470
+ }
471
+ }
472
+ });
473
+ test("real AgentSession: steering and follow-up queued during fallback reach the old window once", { timeout: 25000 }, async () => {
474
+ for (const delivery of ["steer", "followUp"]) {
475
+ let fixture;
476
+ let announceStarted;
477
+ let releaseResponse;
478
+ const started = new Promise((resolve) => { announceStarted = resolve; });
479
+ const gate = new Promise((resolve) => { releaseResponse = resolve; });
480
+ fixture = await openFixture({
481
+ compactionEnabled: false,
482
+ script: async (request, context) => {
483
+ if (request === 1) {
484
+ assert.ok(text(context).includes(WARNING_PROMPT));
485
+ announceStarted();
486
+ await gate;
487
+ }
488
+ if (request === 2)
489
+ assert.equal(text(context).split("QUEUED_DURING_FALLBACK").length - 1, 1, `${delivery} is delivered once before fallback`);
490
+ return assistant(fixture, [{ type: "text", text: `response ${request}` }]);
491
+ },
492
+ });
493
+ try {
494
+ const command = fixture.session.prompt("/wipe-memory");
495
+ await started;
496
+ if (delivery === "steer")
497
+ fixture.session.steer("QUEUED_DURING_FALLBACK");
498
+ else
499
+ fixture.session.followUp("QUEUED_DURING_FALLBACK");
500
+ releaseResponse();
501
+ await command;
502
+ await fixture.session.waitForIdle();
503
+ assert.equal(fixture.requests.length, 3, `${delivery} runs once before one fresh continuation`);
504
+ assert.equal(resetMarkers(fixture).length, 1, `${delivery} does not duplicate the fallback reset`);
505
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_DURING_FALLBACK")).length, 1);
506
+ assertFreshRequest(fixture, 2, "QUEUED_DURING_FALLBACK");
507
+ }
508
+ finally {
509
+ releaseResponse();
510
+ fixture.close();
511
+ }
512
+ }
513
+ });
278
514
  test("real AgentSession: a reset survives all notes-home read failures with an incomplete boot", { timeout: 15000 }, async () => {
279
515
  let fixture;
280
516
  fixture = await openFixture({
@@ -390,6 +626,66 @@ test("real AgentSession: successive resets and a mixed tool batch cut only after
390
626
  }
391
627
  }
392
628
  });
629
+ test("real AgentSession: two retain-none checkpoints survive session-file reopen", { timeout: 20000 }, async () => {
630
+ const sessionDir = mkdtempSync(join(tmpdir(), "pi-context-persisted-session-"));
631
+ let fixture;
632
+ let sessionFile = "";
633
+ let sessionId = "";
634
+ fixture = await openFixture({
635
+ compactionEnabled: false,
636
+ sessionDir,
637
+ script: (request) => request < 3
638
+ ? assistant(fixture, [{ type: "toolCall", id: `persisted-wipe-${request}`, name: "wipe_memory", arguments: {} }], "toolUse")
639
+ : assistant(fixture, [{ type: "text", text: "second reset window complete" }]),
640
+ });
641
+ try {
642
+ await fixture.session.prompt("PERSISTED_OLD_WINDOW_SENTINEL");
643
+ await fixture.session.waitForIdle();
644
+ assert.equal(fixture.requests.length, 3, "two explicit requests and one final continuation, with no summary-model request");
645
+ assert.equal(resetMarkers(fixture).length, 2);
646
+ assertFreshRequest(fixture, 2, "PERSISTED_OLD_WINDOW_SENTINEL");
647
+ const branch = fixture.sessionManager.getBranch();
648
+ const checkpoints = branch.filter((entry) => entry.type === "compaction");
649
+ assert.equal(checkpoints.length, 2);
650
+ const markers = resetMarkers(fixture);
651
+ assert.equal(markers[0]?.parentId, checkpoints[0]?.id);
652
+ assert.equal(markers[1]?.parentId, checkpoints[1]?.id);
653
+ assert.equal(checkpoints[0]?.type === "compaction" ? checkpoints[0].firstKeptEntryId : undefined, checkpoints[0]?.id);
654
+ assert.equal(checkpoints[1]?.type === "compaction" ? checkpoints[1].firstKeptEntryId : undefined, checkpoints[1]?.id);
655
+ sessionFile = fixture.sessionManager.getSessionFile() ?? "";
656
+ sessionId = fixture.sessionManager.getSessionId();
657
+ assert.ok(sessionFile && existsSync(sessionFile), "the SDK persisted the real session file");
658
+ }
659
+ finally {
660
+ fixture.close();
661
+ }
662
+ try {
663
+ const reopened = SessionManager.open(sessionFile, sessionDir);
664
+ assert.equal(reopened.getSessionId(), sessionId);
665
+ const branch = reopened.getBranch();
666
+ assert.equal(branch.filter((entry) => entry.type === "compaction").length, 2);
667
+ assert.equal(branch.filter((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE).length, 2);
668
+ assert.ok(JSON.stringify(branch).includes("PERSISTED_OLD_WINDOW_SENTINEL"), "raw history survives disk reopen");
669
+ const canonical = reopened.buildSessionProjection().messages;
670
+ assert.equal(JSON.stringify(canonical).includes("PERSISTED_OLD_WINDOW_SENTINEL"), false, "disk-loaded canonical context agrees with the fresh request");
671
+ assert.ok(canonical.some((message) => message.role === "compactionSummary"), "the accepted empty-summary wrapper survives reload");
672
+ const system = getCurrentSystemMessage(canonical);
673
+ assert.ok(JSON.stringify(system).includes("Use the tools as requested."), "the complete system prompt survives the checkpoint");
674
+ assert.ok(system?.toolsAdded?.some((tool) => tool.name === "wipe_memory"), "the active tool loadout survives reopen");
675
+ const reopenedMarkers = branch.filter((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
676
+ const latestMarker = reopenedMarkers.at(-1);
677
+ const previousMarker = reopenedMarkers[0];
678
+ assert.ok(latestMarker?.type === "custom" && previousMarker?.type === "custom");
679
+ const latestWindowId = latestMarker.data.windowId;
680
+ const previousWindowId = previousMarker.data.windowId;
681
+ const latestBoot = branch.find((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === latestWindowId);
682
+ assert.ok(latestBoot && latestBoot.type === "custom_message" && typeof latestBoot.content === "string");
683
+ assert.ok(latestBoot.content.includes(`Previous context window id: ${previousWindowId}`));
684
+ }
685
+ finally {
686
+ rmSync(sessionDir, { recursive: true, force: true });
687
+ }
688
+ });
393
689
  test("real AgentSession: steering and follow-up are delivered exactly once in the fresh window", async () => {
394
690
  for (const deliverAs of ["steer", "followUp"]) {
395
691
  let fixture;
@@ -531,6 +827,7 @@ test("real AgentSession: overflow and recoverable length reset and retry once; r
531
827
  await fixture.session.prompt(`RECOVER_${failure}_SENTINEL`);
532
828
  await fixture.session.waitForIdle();
533
829
  assert.equal(resetMarkers(fixture).length, 1, `${failure} performs one reset`);
830
+ assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1, `${failure} commits one native reset checkpoint`);
534
831
  assert.equal(fixture.requests.length, 2, `${failure} resumes once`);
535
832
  assertFreshRequest(fixture, 1, `RECOVER_${failure}_SENTINEL`);
536
833
  }
@@ -546,8 +843,8 @@ test("real AgentSession: overflow and recoverable length reset and retry once; r
546
843
  await repeated.session.prompt("REPEATED_OVERFLOW_SENTINEL");
547
844
  await repeated.session.waitForIdle();
548
845
  assert.equal(resetMarkers(repeated).length, 1, "repeated overflow does not create repeated windows");
846
+ assert.equal(repeated.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 1, "bounded overflow recovery commits one retain-none checkpoint");
549
847
  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
848
  }
552
849
  finally {
553
850
  repeated.close();
@@ -709,7 +1006,7 @@ test("real AgentSession: injected settings managers own live policy and ignore c
709
1006
  await first.session.waitForIdle();
710
1007
  assert.equal(resetMarkers(first).length, 1, "live enablement and a lower reserve apply on the next turn");
711
1008
  firstHighUsage.next = true;
712
- firstManager.applyOverrides({ compaction: { reserveTokens: 90_000 } });
1009
+ firstManager.applyOverrides({ compaction: { reserveTokens: 80_000 } });
713
1010
  await first.session.prompt("INJECTED_LIVE_HIGH_RESERVE");
714
1011
  await first.session.waitForIdle();
715
1012
  assert.equal(resetMarkers(first).length, 2, "a live reserve increase drives the next reset decision");
@@ -781,14 +1078,83 @@ test("real AgentSession: injected model overrides select the active model's rese
781
1078
  rmSync(notesRoot, { recursive: true, force: true });
782
1079
  }
783
1080
  });
1081
+ test("real AgentSession: persisted budget warning re-arms after a later abort/error", { timeout: 30000 }, async () => {
1082
+ for (const mode of ["ctx-abort", "generic-error"]) {
1083
+ let fixture;
1084
+ let warningTurnStarted;
1085
+ let releaseWarningTurn;
1086
+ let didAbort = false;
1087
+ const warningStarted = new Promise((resolve) => { warningTurnStarted = resolve; });
1088
+ const warningGate = new Promise((resolve) => { releaseWarningTurn = resolve; });
1089
+ fixture = await openFixture({
1090
+ compactionEnabled: true,
1091
+ hook: mode === "ctx-abort" ? (pi, _getSession, requests) => pi.on("context", (_event, ctx) => {
1092
+ if (!didAbort && requests.length === 3) {
1093
+ didAbort = true;
1094
+ ctx.abort();
1095
+ }
1096
+ }) : undefined,
1097
+ script: async (request, context) => {
1098
+ if (request === 1)
1099
+ return assistant(fixture, [{ type: "toolCall", id: "rearm-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
1100
+ if (request === 2)
1101
+ return assistant(fixture, [{ type: "toolCall", id: "rearm-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
1102
+ if (request === 3) {
1103
+ assert.ok(text(context).includes(WARNING_PROMPT), "the low-budget request carries the close-out warning");
1104
+ warningTurnStarted();
1105
+ await warningGate;
1106
+ 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) });
1107
+ }
1108
+ if (request === 4 && mode === "generic-error") {
1109
+ assert.equal(text(context).split(WARNING_PROMPT).length - 1, 1, "the durable warning is not injected a second time");
1110
+ assert.ok(text(context).includes("REARM_QUEUED_FOLLOWUP"));
1111
+ return assistant(fixture, [{ type: "text", text: "ordinary provider failure" }], "error", { errorMessage: "synthetic non-overflow failure", usage: usage(60_000) });
1112
+ }
1113
+ return assistant(fixture, [{ type: "text", text: `normal response ${request}` }], "stop", { usage: usage(60_000) });
1114
+ },
1115
+ });
1116
+ try {
1117
+ const initialTurn = fixture.session.prompt(`REARM_${mode}_INITIAL`);
1118
+ await warningStarted;
1119
+ fixture.session.steer("REARM_QUEUED_FOLLOWUP");
1120
+ releaseWarningTurn();
1121
+ await initialTurn;
1122
+ await fixture.session.waitForIdle();
1123
+ const afterFailure = fixture.sessionManager.getBranch();
1124
+ 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");
1125
+ assert.ok(existsSync(join(fixture.notesRoot, "pi", "session", fixture.sessionManager.getSessionId(), "warning-rearm.md")), "the preceding note/tool turn completed");
1126
+ assert.equal(resetMarkers(fixture).length, 0, `${mode} itself must not reset`);
1127
+ assert.equal(afterFailure.filter((entry) => entry.type === "compaction").length, 0);
1128
+ const failedContext = fixture.streamContexts[3];
1129
+ assert.ok(failedContext);
1130
+ assert.equal(text(failedContext).split(WARNING_PROMPT).length - 1, 1, "the persisted warning appears once, without duplicate injection");
1131
+ assert.equal(text(failedContext).split("REARM_QUEUED_FOLLOWUP").length - 1, 1, "queued follow-up reaches the failed request once");
1132
+ assert.equal(fixture.streamSignals.at(-1), mode === "ctx-abort", "the abort case cancels Pi's real signal; the error case does not");
1133
+ await fixture.session.prompt("REARM_AFTER_FAILED_TURN");
1134
+ await fixture.session.waitForIdle();
1135
+ assert.equal(resetMarkers(fixture).length, 1, "the next low-budget normal stop re-arms and commits one reset");
1136
+ 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");
1137
+ const checkpoint = fixture.sessionManager.getBranch().find((entry) => entry.type === "compaction");
1138
+ assert.ok(checkpoint?.type === "compaction");
1139
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id, "the retry closes with one retain-none checkpoint");
1140
+ assertFreshRequest(fixture, fixture.requests.length - 1, "REARM_AFTER_FAILED_TURN");
1141
+ }
1142
+ finally {
1143
+ releaseWarningTurn();
1144
+ fixture.close();
1145
+ }
1146
+ }
1147
+ });
784
1148
  test("real AgentSession: warning precedes a durable checkpoint, including failed writes and an ignored warning", { timeout: 20000 }, async () => {
785
1149
  for (const scenario of ["checkpoint", "write-error", "ignored-warning"]) {
786
1150
  let fixture;
787
1151
  fixture = await openFixture({
788
1152
  compactionEnabled: true,
789
1153
  script: (request, context) => {
790
- if (request === 3)
791
- assert.ok(text(context).includes(GUIDANCE_OPEN_TAG), "the critical warning reaches the provider before checkpoint choice");
1154
+ if (request === 3) {
1155
+ assert.ok(text(context).includes(GUIDANCE_OPEN_TAG), "the warning remains in its hidden guidance wrapper");
1156
+ assert.ok(text(context).includes(WARNING_PROMPT), "the shared close-out warning reaches the provider before checkpoint choice");
1157
+ }
792
1158
  if (request === 1)
793
1159
  return assistant(fixture, [{ type: "toolCall", id: "budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
794
1160
  if (request === 2)
@@ -821,8 +1187,9 @@ test("real AgentSession: warning precedes a durable checkpoint, including failed
821
1187
  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
1188
  }
823
1189
  else {
824
- assert.equal(resetMarkers(fixture).length, 0, "ignoring the warning does not reset automatically");
1190
+ assert.equal(resetMarkers(fixture).length, 1, "normal stop falls back to the same reset even when the warning was ignored");
825
1191
  assert.equal(existsSync(noteFile), false);
1192
+ assertFreshRequest(fixture, fixture.requests.length - 1, `WARNING_${scenario.toUpperCase()}_SENTINEL`);
826
1193
  }
827
1194
  }
828
1195
  finally {
@@ -0,0 +1 @@
1
+ export {};