@astrosheep/pi-context 0.25.2 → 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 +613 -367
  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 +16 -12
  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 +309 -10
  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 +19 -11
  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;
@@ -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();
@@ -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 {};
@@ -1,8 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
+ import { rmSync, writeFileSync } from "node:fs";
2
3
  import test from "node:test";
3
4
  import { historyFromSession, internal } from "../src/index.js";
4
- import { listNotes } from "../src/notes/store.js";
5
- import { CONTINUATION_TYPE } from "../src/protocol.js";
5
+ import { listNotes } from "./helpers/notes.js";
6
+ import { CONTINUATION_TYPE, WARNING_TYPE } from "../src/protocol.js";
6
7
  import { appendText, call, commitTurnEndBoundary, context, installExtensionTestEnvironment, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
7
8
  const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
8
9
  test.beforeEach(() => testEnvironment.beforeEach());
@@ -21,7 +22,12 @@ test("custom reset marker removes old provider context while history remains sea
21
22
  const branch = sessionManager.getBranch();
22
23
  const marker = branch.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
23
24
  assert.ok(marker && marker.type === "custom");
24
- assert.equal(marker.parentId, toolResultId, "marker follows the completed tool result");
25
+ const checkpoint = branch.find((entry) => entry.type === "compaction");
26
+ assert.ok(checkpoint && checkpoint.type === "compaction", "reset persists a native retain-none checkpoint");
27
+ assert.equal(checkpoint.parentId, toolResultId, "checkpoint follows the completed tool result");
28
+ assert.equal(checkpoint.summary, "");
29
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id);
30
+ assert.equal(marker.parentId, checkpoint.id, "marker follows the native checkpoint");
25
31
  assert.deepEqual(Object.keys(marker.data), ["windowId"]);
26
32
  const projected = await runContextWithSystemHook(captured, ctx, sessionManager.buildSessionContext().messages);
27
33
  const providerText = JSON.stringify(projected?.messages ?? []);
@@ -46,14 +52,14 @@ test("the root boot and reset boot carry durable window identity", async () => {
46
52
  appendText(sessionManager, "assistant", "working");
47
53
  await call(captured, "notes_write", { path: "decisions.md", content: "use terra" }, ctx);
48
54
  // Root window: session_start persists the boot block without triggering a turn.
49
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
55
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
50
56
  assert.equal(captured.sent.length, 1);
51
57
  const rootBoot = captured.sent[0];
52
58
  assert.equal(rootBoot?.message.customType, internal.BOOT_TYPE);
53
59
  assert.equal(rootBoot?.message.display, false, "boot block stays out of the TUI");
54
60
  assert.equal(rootBoot?.options?.triggerTurn, false);
55
61
  assert.equal(noticesOf(ctx).length, 0, "initial boot is silent");
56
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
62
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
57
63
  assert.equal(noticesOf(ctx).length, 0, "reload is silent");
58
64
  assert.deepEqual(rootBoot?.message.details, { windowId: `pcw:${sessionManager.getSessionId().slice(0, 8)}:root` });
59
65
  const rootText = typeof rootBoot?.message.content === "string" ? rootBoot.message.content : "";
@@ -63,7 +69,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
63
69
  assert.match(rootText, new RegExp(`First context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
64
70
  assert.match(rootText, new RegExp(`Current context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
65
71
  assert.ok(rootText.includes("decisions.md"));
66
- const decisionsMeta = listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta;
72
+ const decisionsMeta = (await listNotes(ctx, { scope: "session" })).find((row) => row.path === "decisions.md")?.meta;
67
73
  assert.ok(decisionsMeta);
68
74
  assert.match(rootText, /updated \d+s ago\)/, "boot note metadata carries a relative update time");
69
75
  assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
@@ -72,8 +78,8 @@ test("the root boot and reset boot carry durable window identity", async () => {
72
78
  assert.equal(noticesOf(ctx).length, 0, "requesting a reset does not announce success");
73
79
  const boundary = await commitTurnEndBoundary(captured, sessionManager, ctx);
74
80
  assert.equal(boundary.continue, true);
75
- runHandlers(captured, "turn_start", {}, ctx);
76
- runHandlers(captured, "agent_settled", {}, ctx);
81
+ await runHandlers(captured, "turn_start", {}, ctx);
82
+ await runHandlers(captured, "agent_settled", {}, ctx);
77
83
  assert.equal(noticesOf(ctx).filter((notice) => notice.message.includes("memory cleared")).length, 1, "the committed reset is announced once");
78
84
  const entries = sessionManager.getBranch();
79
85
  const marker = entries.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
@@ -85,7 +91,28 @@ test("the root boot and reset boot carry durable window identity", async () => {
85
91
  const continuation = entries.find((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
86
92
  assert.ok(continuation && continuation.type === "custom_message" && continuation.display === false, "the resumed run is represented by one hidden continuation");
87
93
  });
88
- test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", () => {
94
+ test("an aborted async boot repair emits no boot, continuation, or incomplete-notes notice", async () => {
95
+ const notesHome = process.env.PI_NOTES_HOME;
96
+ assert.ok(notesHome);
97
+ rmSync(notesHome, { recursive: true, force: true });
98
+ writeFileSync(notesHome, "not a directory");
99
+ const sessionManager = manager();
100
+ const windowId = "pcw:aborted-boot-repair";
101
+ sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
102
+ const captured = makeExtension(sessionManager);
103
+ const ctx = context(sessionManager);
104
+ const controller = new AbortController();
105
+ ctx.signal = controller.signal;
106
+ const sessionStart = captured.handlers.get("session_start")?.[0];
107
+ assert.ok(sessionStart);
108
+ const pending = sessionStart({ reason: "startup" }, ctx);
109
+ controller.abort();
110
+ await pending;
111
+ assert.equal(captured.sent.length, 0, "aborted snapshot work cannot append the reset boot or continuation");
112
+ assert.equal(noticesOf(ctx).some((notice) => notice.message.includes("notes index incomplete")), false, "aborted snapshot work cannot notify about missing homes");
113
+ assert.equal(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && [internal.BOOT_TYPE, CONTINUATION_TYPE].includes(entry.customType)), false);
114
+ });
115
+ test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", async () => {
89
116
  const sessionManager = manager(true);
90
117
  const windowId = "pcw:metadata-tail";
91
118
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -93,7 +120,7 @@ test("a marker tail with only metadata repairs its missing boot and continuation
93
120
  sessionManager.appendThinkingLevelChange("low");
94
121
  const captured = makeExtension(sessionManager);
95
122
  const ctx = context(sessionManager);
96
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
123
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
97
124
  const branch = sessionManager.getBranch();
98
125
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
99
126
  const bootEntries = branch.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details && typeof entry.details === "object" && entry.details.windowId === windowId);
@@ -108,13 +135,13 @@ test("a marker tail with only metadata repairs its missing boot and continuation
108
135
  assert.equal(captured.sent[1]?.message.customType, CONTINUATION_TYPE);
109
136
  assert.equal(noticesOf(ctx).length, 0, "repairing a reset tail is not a new reset");
110
137
  });
111
- test("a bare marker tail repairs the full ordered reset shape", () => {
138
+ test("a bare marker tail repairs the full ordered reset shape", async () => {
112
139
  const sessionManager = manager(true);
113
140
  const windowId = "pcw:bare-marker";
114
141
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
115
142
  const captured = makeExtension(sessionManager);
116
143
  const ctx = context(sessionManager);
117
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
144
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
118
145
  assert.equal(captured.sent.length, 2, "a bare marker emits a boot and a continuation");
119
146
  const branch = sessionManager.getBranch();
120
147
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
@@ -123,7 +150,7 @@ test("a bare marker tail repairs the full ordered reset shape", () => {
123
150
  assert.ok(bootIndex > markerIndex, "the repaired boot follows its marker");
124
151
  assert.ok(continuationIndex > bootIndex, "the repaired continuation follows its boot");
125
152
  });
126
- test("a marker tail that already carries a boot repairs only its missing continuation, once", () => {
153
+ test("a marker tail that already carries a boot repairs only its missing continuation, once", async () => {
127
154
  const sessionManager = manager(true);
128
155
  const windowId = "pcw:missing-continuation";
129
156
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -131,23 +158,23 @@ test("a marker tail that already carries a boot repairs only its missing continu
131
158
  sessionManager.appendCustomMessageEntry(internal.BOOT_TYPE, "already-persisted boot", false, { windowId });
132
159
  const captured = makeExtension(sessionManager);
133
160
  const ctx = context(sessionManager);
134
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
161
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
135
162
  assert.equal(captured.sent.length, 1, "only the missing continuation is emitted");
136
163
  assert.equal(captured.sent[0]?.message.customType, CONTINUATION_TYPE);
137
164
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1, "the existing boot is never duplicated");
138
165
  assert.ok(sessionManager.getBranch().findIndex((entry) => entry.id === markerId) >= 0);
139
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
166
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
140
167
  assert.equal(captured.sent.length, 1, "a complete reset tail is idempotent");
141
168
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1);
142
169
  });
143
- test("a marker tail followed by real conversation is never repaired", () => {
170
+ test("a marker tail followed by real conversation is never repaired", async () => {
144
171
  const sessionManager = manager(true);
145
172
  const windowId = "pcw:unsafe-tail";
146
173
  sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
147
174
  appendText(sessionManager, "user", "post-marker work that must not be pushed behind a late boot");
148
175
  const captured = makeExtension(sessionManager);
149
176
  const ctx = context(sessionManager);
150
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
177
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
151
178
  assert.equal(captured.sent.length, 0, "an unsafe marker tail emits nothing");
152
179
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 0, "no boot is appended after real work");
153
180
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 0, "no continuation is appended after real work");
@@ -180,37 +207,36 @@ test("wipe_memory uses one turn boundary and never calls ctx.compact", async ()
180
207
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
181
208
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details && typeof entry.details === "object" && "windowId" in entry.details).length, 1);
182
209
  });
183
- test("pi-context command toggles future work, /wipe-memory is the manual path, and active markers cancel /compact", async () => {
210
+ test("pi-context command toggles future work, /wipe-memory starts close-out, and /compact remains disabled", async () => {
184
211
  const sessionManager = manager();
185
212
  appendText(sessionManager, "user", "hello");
186
213
  const captured = makeExtension(sessionManager);
187
214
  const low = context(sessionManager, undefined, { tokens: 170_000, contextWindow: 200_000, percent: 85 });
188
215
  // On by default: session_start persists the root boot block.
189
- runHandlers(captured, "session_start", { reason: "startup" }, low);
216
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
190
217
  assert.equal(captured.sent.length, 1);
191
218
  assert.equal(captured.sent[0]?.message.customType, internal.BOOT_TYPE);
192
219
  let notices = await runCommand(captured, "pi-context", "off", low);
193
220
  assert.match(notices[0]?.message ?? "", /off/);
194
221
  assert.equal(await runContextHook(captured, low), undefined, "no guidance while off");
195
222
  assert.equal(sentOf(captured, internal.GUIDANCE_TYPE).length, 0, "no guidance persisted while off");
196
- runHandlers(captured, "session_start", { reason: "startup" }, low);
223
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
197
224
  assert.equal(captured.sent.length, 1, "no new boot block is persisted while off");
198
225
  const offResult = resultJson(await call(captured, "wipe_memory", {}, low));
199
226
  assert.match(offResult.error ?? "", /off/, "wipe_memory refuses while off");
200
227
  notices = await runCommand(captured, "pi-context", "on", low);
201
228
  assert.match(notices[0]?.message ?? "", /on/);
202
229
  notices = await runCommand(captured, "wipe-memory", "", low);
203
- assert.equal(notices.length, 1, "manual clear emits exactly one notification");
204
- assert.match(notices[0]?.message ?? "", /memory cleared/);
205
- assert.equal(captured.sent.length, 3, "/wipe-memory writes one hidden boot and one continuation without triggering a model turn");
206
- assert.equal(captured.sent[1]?.options?.triggerTurn, false);
207
- assert.equal(captured.sent[1]?.message.customType, internal.BOOT_TYPE);
208
- assert.equal(captured.sent[2]?.message.customType, CONTINUATION_TYPE);
209
- assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
230
+ assert.equal(notices.length, 0, "the command begins a model close-out rather than announcing a reset request");
231
+ assert.equal(captured.sent.length, 2, "the command persists one hidden warning after the startup boot");
232
+ assert.equal(captured.sent[1]?.options?.triggerTurn, true, "the shared warning starts an ordinary agent turn");
233
+ assert.equal(captured.sent[1]?.message.customType, WARNING_TYPE);
234
+ assert.equal(captured.sent[1]?.message.display, false);
235
+ assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 0, "no reset is claimed until close-out completes");
210
236
  const markerContext = await runManualCompact(captured, low);
211
- assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while a marker is active");
237
+ assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while context windows are enabled");
212
238
  const projected = await runContextWithSystemHook(captured, low, sessionManager.buildSessionContext().messages);
213
- assert.equal(JSON.stringify(projected?.messages ?? []).includes("hello"), false, "off keeps the existing wipe in force");
239
+ assert.equal(JSON.stringify(projected?.messages ?? []).includes("hello"), true, "the old window remains active until the close-out commits");
214
240
  notices = await runCommand(captured, "pi-context", "maybe", low);
215
241
  assert.equal(notices[0]?.type, "error", "unknown argument rejected");
216
242
  // Bare command reports current state without changing it.
@@ -0,0 +1 @@
1
+ export {};