@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.
Files changed (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +616 -370
  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 +19 -15
  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 +298 -242
  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/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +318 -19
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +25 -17
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. 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 (message.includes("context budget low") || message.includes("context budget critical")) {
130
- const customType = message.includes("context budget critical") ? WARNING_TYPE : GUIDANCE_TYPE;
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.notices.filter((message) => message.includes("context budget low"));
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().length, 0, "an uncommitted reminder never notifies");
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().length, 1, "the committed retry notifies once");
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().length, 1, "later turns cannot repeat the notice");
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
- assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 0, "the explicit path does not manufacture a native compaction");
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: 90_000 } });
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 critical warning reaches the provider before checkpoint choice");
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, 0, "ignoring the warning does not reset automatically");
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 {};