@astrosheep/pi-context 0.24.0 → 0.25.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 (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
@@ -1,196 +1,461 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import test from "node:test";
3
- import { registerResetLifecycle } from "../src/reset-lifecycle.js";
6
+ import { SessionManager as Manager } from "@earendil-works/pi-coding-agent";
7
+ import piContext, { internal } from "../src/index.js";
8
+ import { initialResetControl, reduceResetControl, registerResetLifecycle, } from "../src/context/reset-lifecycle.js";
9
+ const previousNotesHome = process.env.PI_NOTES_HOME;
10
+ const testNotesHome = mkdtempSync(join(tmpdir(), "pi-context-lifecycle-notes-"));
11
+ process.env.PI_NOTES_HOME = testNotesHome;
12
+ test.after(() => {
13
+ if (previousNotesHome === undefined)
14
+ delete process.env.PI_NOTES_HOME;
15
+ else
16
+ process.env.PI_NOTES_HOME = previousNotesHome;
17
+ rmSync(testNotesHome, { recursive: true, force: true });
18
+ });
4
19
  function harness() {
20
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-lifecycle-test");
5
21
  const handlers = new Map();
6
- const messages = [];
22
+ const tools = new Map();
23
+ const commands = new Map();
24
+ const sent = [];
7
25
  const notices = [];
8
- const requests = [];
9
- let sessionId = "first", currentReset = "", enabled = true, idle = true, pending = false;
10
- let signal;
11
- let throwOnCompact = false;
12
- const ctx = {
13
- sessionManager: { getSessionId: () => sessionId },
14
- isIdle: () => idle,
15
- hasPendingMessages: () => pending,
16
- get signal() { return signal; },
17
- compact: (options) => {
18
- if (throwOnCompact)
19
- throw new Error("synchronous failure");
20
- requests.push(options);
26
+ const api = {
27
+ on(name, handler) {
28
+ const list = handlers.get(name) ?? [];
29
+ list.push(handler);
30
+ handlers.set(name, list);
31
+ return () => { };
21
32
  },
22
- ui: { notify: (message) => notices.push(message) },
23
- };
24
- const lifecycle = registerResetLifecycle({
25
- on: (name, fn) => handlers.set(name, fn),
26
- sendMessage: (message) => messages.push(message.customType),
27
- }, {
28
- isEnabled: () => enabled,
29
- continuation: { customType: "continue", content: "resume", display: false },
30
- buildReset: () => ({ compaction: { summary: "reset", firstKeptEntryId: "marker", tokensBefore: 100, details: {} } }),
31
- isCurrentReset: (id) => id === currentReset,
32
- onReset: () => { },
33
- });
34
- const emit = (name, event = {}) => handlers.get(name)?.(event, ctx);
35
- return {
36
- ctx, lifecycle, emit, messages, notices, requests,
37
- setIdle: (value) => { idle = value; },
38
- setPending: (value) => { pending = value; },
39
- setSignal: (value) => { signal = value; },
40
- setSession: (value) => { sessionId = value; },
41
- setThrow: () => { throwOnCompact = true; },
42
- disable: () => { enabled = false; lifecycle.clear(); },
43
- enable: () => { enabled = true; },
44
- before: (reason = "threshold") => emit("session_before_compact", { reason, signal: new AbortController().signal }),
45
- // Do not await this result until after the manually driven compact callbacks:
46
- // real Pi awaits the originating handler while the continuation can emit its
47
- // own nested agent_settled event.
48
- settle: () => { emit("agent_end"); idle = true; return emit("agent_settled"); },
49
- success: (id = "reset", willRetry = false) => {
50
- currentReset = id;
51
- emit("session_compact", { compactionEntry: { id }, willRetry });
33
+ registerTool(tool) { tools.set(tool.name, tool); },
34
+ registerCommand(name, command) { commands.set(name, command); },
35
+ registerFlag() { },
36
+ appendEntry(customType, data) { sessionManager.appendCustomEntry(customType, data); },
37
+ sendMessage(message, options) {
38
+ sent.push({ customType: message.customType, details: message.details, triggerTurn: options?.triggerTurn });
39
+ sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
52
40
  },
53
- complete: (index = 0) => requests[index].onComplete({}),
54
41
  };
42
+ piContext(api);
43
+ const ctx = {
44
+ sessionManager,
45
+ cwd: "/private/tmp",
46
+ model: undefined,
47
+ isIdle: () => true,
48
+ hasPendingMessages: () => false,
49
+ signal: undefined,
50
+ getContextUsage: () => undefined,
51
+ compact: () => assert.fail("the reset path must not call ctx.compact()"),
52
+ abort: () => { },
53
+ isProjectTrusted: () => true,
54
+ ui: { notify: (message, type) => notices.push({ message, type }) },
55
+ };
56
+ const emit = async (name, event) => {
57
+ const results = [];
58
+ for (const handler of handlers.get(name) ?? [])
59
+ results.push(await handler(event, ctx));
60
+ return results;
61
+ };
62
+ const runCommand = async (name, args = "") => {
63
+ const command = commands.get(name);
64
+ assert.ok(command, `${name} command is registered`);
65
+ const commandCtx = Object.assign({}, ctx, {
66
+ waitForIdle: async () => { },
67
+ ui: { notify: (message, type) => notices.push({ message, type }) },
68
+ });
69
+ await command.handler(args, commandCtx);
70
+ };
71
+ const callTool = async (name) => {
72
+ const tool = tools.get(name);
73
+ assert.ok(tool, `${name} is registered`);
74
+ return tool.execute("call", {}, new AbortController().signal, () => { }, ctx);
75
+ };
76
+ return { sessionManager, handlers, sent, notices, emit, runCommand, callTool };
77
+ }
78
+ function resultEntries(results) {
79
+ let entries = [];
80
+ let shouldContinue = false;
81
+ for (const result of results) {
82
+ if (!result || typeof result !== "object")
83
+ continue;
84
+ const value = result;
85
+ if (value.entries)
86
+ entries = value.entries;
87
+ if (value.continue !== undefined)
88
+ shouldContinue = value.continue;
89
+ }
90
+ return { entries, continue: shouldContinue };
91
+ }
92
+ function appendDrafts(sessionManager, entries) {
93
+ for (const entry of entries) {
94
+ switch (entry.type) {
95
+ case "custom":
96
+ sessionManager.appendCustomEntry(entry.customType, entry.data);
97
+ break;
98
+ case "custom_message":
99
+ sessionManager.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
100
+ break;
101
+ case "context_edit":
102
+ sessionManager.appendContextEdit(entry.targetId, entry.replacement);
103
+ break;
104
+ case "compaction":
105
+ sessionManager.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
106
+ break;
107
+ }
108
+ }
55
109
  }
56
- test("the originating settled handler waits for its continuation's nested settlement", async () => {
110
+ function turnEndFacts(overrides = {}) {
111
+ return {
112
+ aborted: false,
113
+ overflow: false,
114
+ failed: false,
115
+ enabled: true,
116
+ queued: false,
117
+ automaticResetEnabled: true,
118
+ thresholdDue: false,
119
+ ...overrides,
120
+ };
121
+ }
122
+ function beforeSettleFacts(overrides = {}) {
123
+ return {
124
+ queued: false,
125
+ enabled: true,
126
+ automaticResetEnabled: true,
127
+ aborted: false,
128
+ ...overrides,
129
+ };
130
+ }
131
+ function fakeBoundaryEvent(entries = []) {
132
+ return {
133
+ type: "turn_end",
134
+ entries,
135
+ continue: false,
136
+ context: { contextEntries: [], contextMessages: [], llmMessages: [], pendingMessages: [], canContinue: true },
137
+ outcome: "completed",
138
+ turnIndex: 0,
139
+ message: { role: "assistant", content: [], timestamp: Date.now() },
140
+ toolResults: [],
141
+ messageEntryId: "assistant-entry",
142
+ toolResultEntryIds: [],
143
+ };
144
+ }
145
+ test("public reset boundary drafts one marker, one boot, and one continuation after a tool batch", async () => {
57
146
  const h = harness();
58
- assert.equal(h.lifecycle.request(), "rollover_requested");
59
- assert.equal(h.lifecycle.request(), "rollover_already_pending");
60
- const outer = h.settle();
61
- assert.equal(h.requests.length, 1);
62
- h.success();
63
- h.success();
64
- h.complete();
65
- h.complete();
66
- assert.deepEqual(h.messages, ["continue"], "one continuation starts after compaction completion");
67
- let released = false;
68
- void Promise.resolve(outer).then(() => { released = true; });
69
- await Promise.resolve();
70
- assert.equal(released, false, "sending the continuation does not release the original handler");
71
- await h.settle();
72
- await outer;
73
- assert.equal(released, true, "only the continuation's settled event releases its owner");
74
- assert.equal(h.requests.length, 1, "duplicate compact and settled callbacks do not restart reset work");
147
+ h.sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "before reset" }], timestamp: Date.now() });
148
+ const first = await h.callTool("wipe_memory");
149
+ const second = await h.callTool("wipe_memory");
150
+ assert.ok(first.content.length > 0 && second.content.length > 0, "both tool calls return normally");
151
+ const toolBatch = [{ type: "custom_message", customType: "foreign/tool-batch", content: "tool finished", display: false }];
152
+ const boundary = resultEntries(await h.emit("turn_end", fakeBoundaryEvent(toolBatch)));
153
+ assert.equal(boundary.continue, true, "the whole tool batch continues only after the boundary is committed");
154
+ const markerDrafts = boundary.entries.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
155
+ const bootDrafts = boundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE);
156
+ assert.equal(markerDrafts.length, 1, "duplicate wipe requests in one turn dedupe");
157
+ assert.equal(bootDrafts.length, 1);
158
+ assert.equal(boundary.entries[0]?.type, "custom_message", "ordinary tool-batch entries precede the reset drafts");
159
+ assert.equal(boundary.entries[1]?.type, "custom");
160
+ assert.equal(boundary.entries[2]?.type, "custom_message");
161
+ const windowId = markerDrafts[0].data.windowId;
162
+ assert.match(windowId, /^pcw:/);
163
+ assert.equal(bootDrafts[0].details.windowId, windowId);
164
+ const continuationDrafts = boundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.CONTINUATION_TYPE);
165
+ assert.equal(continuationDrafts.length, 1, "the boundary persists exactly one reset message");
166
+ assert.equal(boundary.entries[3]?.type, "custom_message", "the continuation closes the ordered reset shape");
167
+ appendDrafts(h.sessionManager, boundary.entries);
168
+ const branch = h.sessionManager.getBranch();
169
+ assert.deepEqual(branch.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).map((entry) => entry.type === "custom" ? entry.data : undefined), [{ windowId }]);
170
+ assert.equal(branch.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1);
75
171
  });
76
- test("a reset requested by a continuation completes before its predecessor releases", async () => {
172
+ test("off stops future automatic/manual reset requests while an existing marker remains authoritative", async () => {
77
173
  const h = harness();
78
- h.lifecycle.request();
79
- const first = h.settle();
80
- h.success("first");
81
- h.complete();
82
- assert.deepEqual(h.messages, ["continue"]);
83
- // This models wipe_memory being called during the first continuation run.
84
- assert.equal(h.lifecycle.request(), "rollover_requested");
85
- const second = h.settle();
86
- assert.equal(h.requests.length, 2, "the continuation's settled handler starts its requested reset");
87
- h.success("second");
88
- h.complete(1);
89
- assert.deepEqual(h.messages, ["continue", "continue"]);
90
- let firstReleased = false;
91
- void Promise.resolve(first).then(() => { firstReleased = true; });
92
- await Promise.resolve();
93
- assert.equal(firstReleased, false, "the predecessor remains owned while the second continuation runs");
94
- await h.settle();
95
- await second;
96
- await first;
97
- assert.equal(firstReleased, true);
98
- assert.equal(h.lifecycle.request(), "rollover_requested", "a later window can request another reset");
174
+ await h.callTool("wipe_memory");
175
+ const first = resultEntries(await h.emit("turn_end", fakeBoundaryEvent()));
176
+ assert.ok(first.entries.some((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE));
177
+ appendDrafts(h.sessionManager, first.entries);
178
+ await h.runCommand("pi-context", "off");
179
+ const before = await h.emit("session_before_compact", {
180
+ type: "session_before_compact",
181
+ reason: "manual",
182
+ willRetry: false,
183
+ branchEntries: h.sessionManager.getBranch(),
184
+ preparation: { tokensBefore: 100, firstKeptEntryId: null, keptMessages: [], droppedMessages: [] },
185
+ signal: new AbortController().signal,
186
+ });
187
+ assert.deepEqual(before.at(-1), { cancel: true }, "/compact is canceled when an existing marker would expose old canonical history");
188
+ const afterOff = resultEntries(await h.emit("turn_end", fakeBoundaryEvent()));
189
+ assert.equal(afterOff.entries.length, 0, "off does not create another reset");
190
+ await h.runCommand("pi-context", "on");
191
+ await h.runCommand("wipe-memory");
192
+ assert.equal(h.sent.length, 2, "/wipe-memory writes one hidden boot and one continuation without a model turn");
193
+ assert.equal(h.sent[0]?.triggerTurn, false);
194
+ assert.equal(h.sent[0]?.customType, internal.BOOT_TYPE);
195
+ assert.equal(h.sent[1]?.customType, internal.CONTINUATION_TYPE);
196
+ const markers = h.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
197
+ assert.equal(markers.length, 2, "off does not resurrect history; re-enabled wipe-memory creates the explicit new marker");
99
198
  });
100
- test("automatic compactions reset on the spot, with no continuation", () => {
101
- const h = harness();
102
- assert.ok(h.before().compaction, "the native attempt becomes our reset immediately");
103
- assert.deepEqual(h.messages, []);
199
+ test("reset construction failure preserves incoming and budget drafts without continuation", async () => {
200
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-failure-test");
201
+ const handlers = new Map();
202
+ const notices = [];
203
+ const api = {
204
+ on(name, handler) {
205
+ const list = handlers.get(name) ?? [];
206
+ list.push(handler);
207
+ handlers.set(name, list);
208
+ return () => { };
209
+ },
210
+ };
211
+ const ctx = {
212
+ sessionManager,
213
+ model: undefined,
214
+ signal: undefined,
215
+ hasPendingMessages: () => false,
216
+ ui: { notify: (message, type) => notices.push({ message, type }) },
217
+ };
218
+ const budgetDraft = { type: "custom_message", customType: internal.GUIDANCE_TYPE, content: "budget draft", display: false };
219
+ const lifecycle = registerResetLifecycle(api, {
220
+ isEnabled: () => true,
221
+ budget: {
222
+ automaticResetEnabled: () => true,
223
+ resetDue: () => false,
224
+ consumeTurnEnd: () => [budgetDraft],
225
+ clear: () => { },
226
+ },
227
+ buildReset: () => { throw new Error("synthetic reset construction failure"); },
228
+ });
229
+ lifecycle.request();
230
+ const incoming = { type: "custom_message", customType: "foreign/boundary", content: "foreign draft", display: false };
231
+ const results = [];
232
+ for (const handler of handlers.get("turn_end") ?? [])
233
+ results.push(await handler(fakeBoundaryEvent([incoming]), ctx));
234
+ const result = resultEntries(results);
235
+ assert.deepEqual(result.entries, [incoming, budgetDraft], "already-built drafts survive reset construction failure");
236
+ assert.equal(result.continue, false, "a failed reset does not request continuation");
237
+ assert.equal(notices.at(-1)?.type, "warning");
238
+ assert.match(notices.at(-1)?.message ?? "", /could not build reset/);
104
239
  });
105
- test("failure, synchronous scheduling errors, and cancellation release their owners without retry", async () => {
106
- const failed = harness();
107
- failed.lifecycle.request();
108
- const outer = failed.settle();
109
- failed.requests[0].onError(new Error("Nothing to compact"));
110
- failed.requests[0].onError(new Error("duplicate callback"));
111
- failed.complete();
112
- await outer;
113
- assert.equal(failed.notices.length, 1);
114
- assert.deepEqual(failed.messages, []);
115
- assert.equal(failed.lifecycle.request(), "rollover_requested", "a later explicit request is possible");
116
- const synchronous = harness();
117
- synchronous.setThrow();
118
- synchronous.lifecycle.request();
119
- await synchronous.settle();
120
- assert.equal(synchronous.notices.length, 1);
121
- assert.equal(synchronous.lifecycle.request(), "rollover_requested");
122
- const aborted = harness();
123
- aborted.lifecycle.request();
124
- aborted.setSignal(AbortSignal.abort());
125
- await aborted.settle();
126
- assert.equal(aborted.requests.length, 0);
127
- assert.deepEqual(aborted.messages, []);
240
+ test("a queued success clears an overflow failure before settle recovery can reset", async () => {
241
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-queued-overflow-test");
242
+ const handlers = new Map();
243
+ const api = {
244
+ on(name, handler) {
245
+ const list = handlers.get(name) ?? [];
246
+ list.push(handler);
247
+ handlers.set(name, list);
248
+ return () => { };
249
+ },
250
+ };
251
+ const ctx = {
252
+ sessionManager,
253
+ model: { contextWindow: 100_000, maxTokens: 4_096 },
254
+ signal: undefined,
255
+ hasPendingMessages: () => false,
256
+ ui: { notify() { } },
257
+ };
258
+ let resetCount = 0;
259
+ const lifecycle = registerResetLifecycle(api, {
260
+ isEnabled: () => true,
261
+ budget: {
262
+ automaticResetEnabled: () => true,
263
+ resetDue: () => false,
264
+ consumeTurnEnd: () => [],
265
+ clear: () => { },
266
+ },
267
+ buildReset: () => {
268
+ resetCount += 1;
269
+ return [];
270
+ },
271
+ });
272
+ const failed = fakeBoundaryEvent();
273
+ failed.message = { ...failed.message, stopReason: "error", errorMessage: "Prompt too long: context exceeds maximum context length" };
274
+ const turnEnd = handlers.get("turn_end")?.[0];
275
+ const beforeSettle = handlers.get("agent_before_settle")?.[0];
276
+ assert.ok(turnEnd && beforeSettle);
277
+ await turnEnd(failed, ctx);
278
+ const queued = {
279
+ ...failed,
280
+ type: "agent_before_settle",
281
+ outcome: "error",
282
+ context: { ...failed.context, pendingMessages: [{ role: "user", content: [{ type: "text", text: "queued success" }], timestamp: Date.now() }] },
283
+ };
284
+ assert.equal(await beforeSettle(queued, ctx), undefined, "a queued message defers overflow recovery");
285
+ const success = fakeBoundaryEvent();
286
+ await turnEnd(success, ctx);
287
+ const settled = { ...queued, context: { ...queued.context, pendingMessages: [] }, outcome: "completed" };
288
+ assert.equal(await beforeSettle(settled, ctx), undefined, "the successful queued turn clears the stale recovery");
289
+ assert.equal(resetCount, 0);
128
290
  });
129
- test("shutdown, tree invalidation, toggling off, and stale sessions release waiters safely", async () => {
130
- for (const boundary of ["session_shutdown", "session_start", "session_tree", "off", "session-change"]) {
131
- const h = harness();
132
- h.lifecycle.request();
133
- const outer = h.settle();
134
- h.success();
135
- h.complete();
136
- if (boundary === "off") {
137
- h.disable();
138
- h.enable();
139
- }
140
- else if (boundary === "session-change") {
141
- h.setSession("second");
142
- h.complete();
143
- h.emit("session_tree");
144
- }
145
- else
146
- h.emit(boundary);
147
- h.complete();
148
- h.requests[0].onError(new Error("late error"));
149
- await outer;
150
- assert.deepEqual(h.notices, [], boundary);
151
- assert.deepEqual(h.messages, ["continue"], boundary);
152
- if (boundary === "session_shutdown")
153
- h.emit("session_start");
154
- if (boundary === "session-change")
155
- h.emit("session_tree");
156
- assert.equal(h.lifecycle.request(), "rollover_requested", `${boundary}: a fresh request still works`);
157
- }
291
+ test("reset-control: an explicit request deduplicates and is consumed at the turn boundary", () => {
292
+ const idle = Object.freeze({ ...initialResetControl() });
293
+ const first = reduceResetControl(idle, { type: "request" });
294
+ assert.equal(first.effect, "requested");
295
+ assert.deepEqual(first.state, { request: "explicit", overflow: "idle" });
296
+ const again = reduceResetControl(first.state, { type: "request" });
297
+ assert.equal(again.effect, "already-requested");
298
+ assert.deepEqual(again.state, first.state, "a duplicate request does not change state");
299
+ const committed = reduceResetControl(again.state, { type: "turn_end", facts: turnEndFacts() });
300
+ assert.equal(committed.effect, "commit-boundary");
301
+ assert.deepEqual(committed.state, initialResetControl(), "the request is consumed whether or not it commits");
158
302
  });
159
- test("queued or competing work is not duplicated and releases an unneeded continuation owner", async () => {
160
- const competing = harness();
161
- competing.lifecycle.request();
162
- competing.setIdle(false);
163
- assert.equal(competing.emit("agent_settled"), undefined, "another run owns the first settled event");
164
- competing.setIdle(true);
165
- const outer = competing.settle();
166
- competing.success();
167
- competing.setIdle(false);
168
- competing.complete();
169
- await outer;
170
- assert.deepEqual(competing.messages, [], "an active prompt owns continuation");
171
- const queued = harness();
172
- queued.lifecycle.request();
173
- const queuedOuter = queued.settle();
174
- queued.success();
175
- queued.setPending(true);
176
- queued.complete();
177
- await queuedOuter;
178
- assert.deepEqual(queued.messages, [], "queued user work is never duplicated");
303
+ test("reset-control: turn_end commits explicit and threshold resets, and skips disabled or failed turns", () => {
304
+ const explicit = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts() });
305
+ assert.equal(explicit.effect, "commit-boundary");
306
+ const threshold = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ thresholdDue: true }) });
307
+ assert.equal(threshold.effect, "commit-boundary");
308
+ const neither = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts() });
309
+ assert.equal(neither.effect, "none");
310
+ const disabled = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts({ enabled: false, thresholdDue: true }) });
311
+ assert.equal(disabled.effect, "none");
312
+ assert.deepEqual(disabled.state, initialResetControl(), "a disabled turn drops the explicit request without committing");
313
+ const failed = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts({ failed: true, thresholdDue: true }) });
314
+ assert.equal(failed.effect, "none");
315
+ assert.deepEqual(failed.state, initialResetControl());
179
316
  });
180
- test("foreign boundaries and native compactions do not manufacture a continuation", async () => {
181
- const h = harness();
182
- h.lifecycle.request();
183
- const outer = h.settle();
184
- h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
185
- h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
186
- assert.equal(h.lifecycle.request(), "rollover_already_pending");
187
- h.complete();
188
- await outer;
189
- assert.deepEqual(h.messages, []);
190
- const native = harness();
191
- native.lifecycle.request();
192
- native.success("native", false);
193
- await native.settle();
194
- assert.equal(native.requests.length, 0);
195
- assert.deepEqual(native.messages, []);
317
+ test("reset-control: aborted turns clear the boundary, settlement keeps only an explicit request", () => {
318
+ const both = { request: "explicit", overflow: "pending" };
319
+ const aborted = reduceResetControl(both, { type: "turn_end", facts: turnEndFacts({ aborted: true }) });
320
+ assert.equal(aborted.effect, "none");
321
+ assert.deepEqual(aborted.state, initialResetControl(), "an abort manufactures no continuation");
322
+ const settled = reduceResetControl(both, { type: "settled" });
323
+ assert.deepEqual(settled.state, { request: "explicit", overflow: "idle" }, "settlement ends the failure chain only");
324
+ const cleared = reduceResetControl(both, { type: "clear" });
325
+ assert.deepEqual(cleared.state, initialResetControl());
326
+ });
327
+ test("reset-control: overflow recovery is armed at turn_end and spent exactly once at settle", () => {
328
+ const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
329
+ assert.deepEqual(armed.state, { request: "none", overflow: "pending" });
330
+ const recovered = reduceResetControl(armed.state, { type: "before_settle", facts: beforeSettleFacts() });
331
+ assert.equal(recovered.effect, "recover-overflow", "the first settle commits the bounded recovery");
332
+ assert.deepEqual(recovered.state, { request: "none", overflow: "spent" });
333
+ const repeated = reduceResetControl(recovered.state, { type: "before_settle", facts: beforeSettleFacts() });
334
+ assert.equal(repeated.effect, "none", "a spent recovery is never retried");
335
+ const rearmed = reduceResetControl(recovered.state, { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
336
+ assert.deepEqual(rearmed.state, { request: "none", overflow: "pending-spent" });
337
+ const bounded = reduceResetControl(rearmed.state, { type: "before_settle", facts: beforeSettleFacts() });
338
+ assert.equal(bounded.effect, "none", "a second failure chain stays bounded to the spent attempt");
339
+ assert.deepEqual(bounded.state, { request: "none", overflow: "spent" });
340
+ });
341
+ test("reset-control: a queued turn defers recovery and its success supersedes the failure", () => {
342
+ const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
343
+ const deferred = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ queued: true }) });
344
+ assert.equal(deferred.effect, "none");
345
+ assert.deepEqual(deferred.state, armed, "a queued message leaves the overflow chain armed");
346
+ const success = reduceResetControl(armed, { type: "turn_end", facts: turnEndFacts() });
347
+ assert.deepEqual(success.state, initialResetControl(), "a successful queued turn clears the stale failure");
348
+ });
349
+ test("reset-control: disabled mode and explicit aborts disarm overflow without a recovery", () => {
350
+ const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
351
+ const disabled = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ enabled: false }) });
352
+ assert.equal(disabled.effect, "none");
353
+ assert.deepEqual(disabled.state, { request: "none", overflow: "idle" });
354
+ const automaticOff = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ automaticResetEnabled: false }) });
355
+ assert.equal(automaticOff.effect, "none");
356
+ assert.deepEqual(automaticOff.state, { request: "none", overflow: "idle" });
357
+ const aborted = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ aborted: true }) });
358
+ assert.equal(aborted.effect, "none");
359
+ assert.deepEqual(aborted.state, { request: "none", overflow: "idle" });
360
+ });
361
+ test("reset-control: policy guards are consulted only in the branches that need them", () => {
362
+ const tracked = (overrides) => {
363
+ const calls = { queued: 0, automatic: 0, threshold: 0 };
364
+ const facts = {
365
+ aborted: overrides.aborted ?? false,
366
+ overflow: overrides.overflow ?? false,
367
+ failed: overrides.failed ?? false,
368
+ enabled: overrides.enabled ?? true,
369
+ get queued() { calls.queued += 1; return overrides.queuedResult ?? false; },
370
+ get automaticResetEnabled() { calls.automatic += 1; return overrides.automaticResult ?? true; },
371
+ get thresholdDue() { calls.threshold += 1; return overrides.thresholdResult ?? true; },
372
+ };
373
+ return { facts, calls };
374
+ };
375
+ const aborted = tracked({ aborted: true });
376
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts: aborted.facts });
377
+ assert.deepEqual(aborted.calls, { queued: 0, automatic: 0, threshold: 0 }, "an abort consults no policy guard");
378
+ const failed = tracked({ failed: true });
379
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts: failed.facts });
380
+ assert.deepEqual(failed.calls, { queued: 0, automatic: 0, threshold: 0 }, "a failed turn consults no threshold guard");
381
+ const disabled = tracked({ enabled: false });
382
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts: disabled.facts });
383
+ assert.deepEqual(disabled.calls, { queued: 0, automatic: 0, threshold: 0 }, "a disabled turn consults no threshold guard");
384
+ const overflow = tracked({ overflow: true, queuedResult: false });
385
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts: overflow.facts });
386
+ assert.deepEqual(overflow.calls, { queued: 1, automatic: 1, threshold: 0 }, "an overflow turn consults only the overflow guards");
387
+ const complete = tracked({ thresholdResult: false });
388
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts: complete.facts });
389
+ assert.deepEqual(complete.calls, { queued: 0, automatic: 0, threshold: 1 }, "a completed turn consults only the threshold guard");
390
+ const settleCalls = { queued: 0, enabled: 0, automatic: 0 };
391
+ const settleFacts = {
392
+ get queued() { settleCalls.queued += 1; return true; },
393
+ get enabled() { settleCalls.enabled += 1; return true; },
394
+ get automaticResetEnabled() { settleCalls.automatic += 1; return true; },
395
+ aborted: false,
396
+ };
397
+ reduceResetControl({ request: "none", overflow: "pending" }, { type: "before_settle", facts: settleFacts });
398
+ assert.deepEqual(settleCalls, { queued: 1, enabled: 0, automatic: 0 }, "a queued settle consults only the pending-message guard");
399
+ });
400
+ test("a committed reset places incoming and budget drafts before marker -> boot -> continuation", async () => {
401
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-ordering-test");
402
+ const handlers = new Map();
403
+ const api = {
404
+ on(name, handler) {
405
+ const list = handlers.get(name) ?? [];
406
+ list.push(handler);
407
+ handlers.set(name, list);
408
+ return () => { };
409
+ },
410
+ };
411
+ const ctx = {
412
+ sessionManager,
413
+ model: undefined,
414
+ signal: undefined,
415
+ hasPendingMessages: () => false,
416
+ ui: { notify() { } },
417
+ };
418
+ const windowId = "pcw:ordering:test";
419
+ const budgetDraft = { type: "custom_message", customType: internal.GUIDANCE_TYPE, content: "budget draft", display: false };
420
+ const lifecycle = registerResetLifecycle(api, {
421
+ isEnabled: () => true,
422
+ budget: {
423
+ automaticResetEnabled: () => true,
424
+ resetDue: () => false,
425
+ consumeTurnEnd: () => [budgetDraft],
426
+ clear: () => { },
427
+ },
428
+ buildReset: () => [
429
+ { type: "custom", customType: internal.RESET_MARKER_TYPE, data: { windowId } },
430
+ { type: "custom_message", customType: internal.BOOT_TYPE, content: "boot", display: false, details: { windowId } },
431
+ { type: "custom_message", customType: internal.CONTINUATION_TYPE, content: "continuation", display: false },
432
+ ],
433
+ });
434
+ lifecycle.request();
435
+ const incoming = { type: "custom_message", customType: "foreign/boundary", content: "incoming", display: false };
436
+ const results = [];
437
+ for (const handler of handlers.get("turn_end") ?? [])
438
+ results.push(await handler(fakeBoundaryEvent([incoming]), ctx));
439
+ const result = resultEntries(results);
440
+ assert.equal(result.continue, true);
441
+ const customTypes = (entries) => entries.map((entry) => {
442
+ assert.ok(entry.type === "custom" || entry.type === "custom_message", "the boundary only carries named reset drafts here");
443
+ return entry.customType;
444
+ });
445
+ assert.deepEqual(customTypes(result.entries), [
446
+ "foreign/boundary",
447
+ internal.GUIDANCE_TYPE,
448
+ internal.RESET_MARKER_TYPE,
449
+ internal.BOOT_TYPE,
450
+ internal.CONTINUATION_TYPE,
451
+ ], "ordinary and budget drafts precede the closed reset shape");
452
+ appendDrafts(sessionManager, result.entries);
453
+ const branch = sessionManager.getBranch();
454
+ const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
455
+ assert.ok(markerIndex >= 0);
456
+ assert.deepEqual(customTypes(branch.slice(markerIndex)), [
457
+ internal.RESET_MARKER_TYPE,
458
+ internal.BOOT_TYPE,
459
+ internal.CONTINUATION_TYPE,
460
+ ], "the persisted reset shape remains marker -> boot -> continuation");
196
461
  });