@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.
- package/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- 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 {
|
|
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
|
|
22
|
+
const tools = new Map();
|
|
23
|
+
const commands = new Map();
|
|
24
|
+
const sent = [];
|
|
7
25
|
const notices = [];
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
sendMessage
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
assert.
|
|
62
|
-
|
|
63
|
-
h.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
assert.equal(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
assert.
|
|
74
|
-
assert.equal(
|
|
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("
|
|
172
|
+
test("off stops future automatic/manual reset requests while an existing marker remains authoritative", async () => {
|
|
77
173
|
const h = harness();
|
|
78
|
-
h.
|
|
79
|
-
const first = h.
|
|
80
|
-
|
|
81
|
-
h.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await
|
|
93
|
-
assert.equal(
|
|
94
|
-
await h.
|
|
95
|
-
await
|
|
96
|
-
|
|
97
|
-
assert.equal(
|
|
98
|
-
assert.equal(h.
|
|
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("
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
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("
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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("
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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("
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
assert.equal(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
assert.
|
|
171
|
-
|
|
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("
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
assert.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
assert.equal(
|
|
195
|
-
assert.deepEqual(
|
|
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
|
});
|