@astrosheep/pi-context 0.24.0 → 0.25.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.
- package/README.md +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1861 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +97 -0
- package/dist/src/context/prompts.js +94 -0
- package/dist/src/context/reset-lifecycle.js +134 -0
- package/dist/src/context/runtime.js +236 -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 +7 -5
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +813 -221
- package/dist/test/boot.integration.test.js +167 -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 +393 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +273 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +248 -180
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +16 -14
- package/package.json +11 -10
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +103 -0
- package/src/context/prompts.ts +111 -0
- package/src/context/reset-lifecycle.ts +145 -0
- package/src/context/runtime.ts +246 -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 +7 -5
- 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,238 +1,830 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
-
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
7
|
-
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import piContext from "../src/index.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
import { createAssistantMessageEventStream, getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
7
|
+
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import piContext, { createPiContext } from "../src/index.js";
|
|
9
|
+
import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
|
|
10
|
+
async function openFixture(options) {
|
|
11
|
+
const dir = options.cwd ?? mkdtempSync(join(tmpdir(), "pi-context-agent-loop-"));
|
|
12
|
+
const ownsDir = options.cwd === undefined;
|
|
13
|
+
const notesRoot = options.notesRoot ?? mkdtempSync(join(tmpdir(), "pi-context-agent-loop-notes-"));
|
|
14
|
+
const ownsNotesRoot = options.notesRoot === undefined;
|
|
15
|
+
const agentDir = options.agentDir ?? dir;
|
|
16
|
+
const managesEnvironment = options.manageEnvironment ?? true;
|
|
17
|
+
const previousDir = managesEnvironment ? process.env.PI_CODING_AGENT_DIR : undefined;
|
|
18
|
+
const previousNotesRoot = managesEnvironment ? process.env.PI_NOTES_HOME : undefined;
|
|
19
|
+
if (managesEnvironment) {
|
|
20
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
17
21
|
process.env.PI_NOTES_HOME = notesRoot;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const probe = usageMode && !checkpoint && ((!fresh && !sawWarning) || (mode === "ignored-warning" && sawWarning) || (fresh && freshTurns === 2 && !sawGuidance));
|
|
104
|
-
const tokens = usageMode ? (fresh ? (freshTurns === 1 ? 100 : 50000) : sawWarning ? 70000 : n === 1 ? 50000 : 60000) : 100;
|
|
105
|
-
const tool = explicitReset || nestedCheckpoint || (mode === "uncompactable" && n === 1);
|
|
106
|
-
const call = probe ? "get_context_remaining" : checkpoint || nestedCheckpoint ? "notes_write" : tool ? "wipe_memory" : undefined;
|
|
107
|
-
const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id,
|
|
108
|
-
content: probe ? [{ type: "toolCall", id: "probe-call", name: "get_context_remaining", arguments: {} }]
|
|
109
|
-
: checkpoint || nestedCheckpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: nestedCheckpoint ? "NESTED_RESET_PADDING ".repeat(300) : "CHECKPOINT_SENTINEL" } }]
|
|
110
|
-
: tool ? [{ type: "toolCall", id: "reset-call", name: "wipe_memory", arguments: {} }]
|
|
111
|
-
: [{ type: "text", text: fresh ? "Resumed." : "Working." }],
|
|
112
|
-
stopReason: call ? "toolUse" : "stop", timestamp: Date.now(),
|
|
113
|
-
usage: { input: tokens, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: tokens + 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
114
|
-
};
|
|
115
|
-
const stream = createAssistantMessageEventStream();
|
|
116
|
-
stream.push({ type: "done", reason: message.stopReason, message });
|
|
117
|
-
stream.end();
|
|
118
|
-
return stream;
|
|
22
|
+
}
|
|
23
|
+
if (options.notesRootFile) {
|
|
24
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
25
|
+
writeFileSync(notesRoot, "blocked notes root");
|
|
26
|
+
}
|
|
27
|
+
const runtime = await ModelRuntime.create({
|
|
28
|
+
authPath: join(dir, "auth.json"),
|
|
29
|
+
modelsPath: null,
|
|
30
|
+
modelsStorePath: join(dir, "models"),
|
|
31
|
+
refreshOnCreate: false,
|
|
32
|
+
});
|
|
33
|
+
await runtime.setRuntimeApiKey("openai", "scripted-test-key");
|
|
34
|
+
const base = runtime.getModels("openai")[0];
|
|
35
|
+
assert.ok(base, "the installed SDK exposes a scripted model");
|
|
36
|
+
const model = { ...base, contextWindow: options.contextWindow ?? 100_000, maxTokens: 4096 };
|
|
37
|
+
runtime.stream = (() => { throw new Error("unexpected native model request"); });
|
|
38
|
+
runtime.complete = (() => { throw new Error("unexpected native model request"); });
|
|
39
|
+
runtime.streamSimple = (() => { throw new Error("unexpected native model request"); });
|
|
40
|
+
runtime.completeSimple = (() => { throw new Error("unexpected native model request"); });
|
|
41
|
+
if (options.writeSettings !== false)
|
|
42
|
+
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({
|
|
43
|
+
compaction: {
|
|
44
|
+
enabled: options.defaultCompactionEnabled ?? options.compactionEnabled ?? true,
|
|
45
|
+
reserveTokens: options.defaultReserveTokens ?? 32_768,
|
|
46
|
+
keepRecentTokens: options.keepRecentTokens ?? 200,
|
|
47
|
+
},
|
|
48
|
+
retry: { enabled: false },
|
|
49
|
+
}));
|
|
50
|
+
if (options.projectSettings !== undefined) {
|
|
51
|
+
mkdirSync(join(dir, ".pi"), { recursive: true });
|
|
52
|
+
writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify(options.projectSettings));
|
|
53
|
+
}
|
|
54
|
+
const settingsManager = typeof options.settingsManager === "function"
|
|
55
|
+
? options.settingsManager(model)
|
|
56
|
+
: options.settingsManager ?? SettingsManager.create(dir, agentDir, { projectTrusted: options.projectTrusted ?? true });
|
|
57
|
+
const requests = [];
|
|
58
|
+
const streamContexts = [];
|
|
59
|
+
const streamSignals = [];
|
|
60
|
+
const events = [];
|
|
61
|
+
const notices = [];
|
|
62
|
+
let session;
|
|
63
|
+
const loader = new DefaultResourceLoader({
|
|
64
|
+
cwd: dir,
|
|
65
|
+
agentDir,
|
|
66
|
+
settingsManager,
|
|
67
|
+
noExtensions: true,
|
|
68
|
+
noSkills: true,
|
|
69
|
+
noThemes: true,
|
|
70
|
+
noPromptTemplates: true,
|
|
71
|
+
systemPromptOverride: () => options.systemPrompt ?? "Use the tools as requested.",
|
|
72
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
73
|
+
extensionFactories: [options.settingsManager ? createPiContext({ settingsManager }) : piContext, (pi) => {
|
|
74
|
+
options.hook?.(pi, () => session, requests);
|
|
75
|
+
}],
|
|
76
|
+
});
|
|
77
|
+
await loader.reload();
|
|
78
|
+
const sessionManager = SessionManager.inMemory(dir);
|
|
79
|
+
options.seed?.(sessionManager);
|
|
80
|
+
const created = await createAgentSession({
|
|
81
|
+
cwd: dir,
|
|
82
|
+
agentDir,
|
|
83
|
+
modelRuntime: runtime,
|
|
84
|
+
model,
|
|
85
|
+
settingsManager,
|
|
86
|
+
sessionManager,
|
|
87
|
+
resourceLoader: loader,
|
|
88
|
+
tools: options.tools ?? ["wipe_memory", "notes_write", "get_context_remaining"],
|
|
89
|
+
});
|
|
90
|
+
session = created.session;
|
|
91
|
+
session.subscribe((event) => events.push(event));
|
|
92
|
+
session.agent.streamFunction = (_model, context, streamOptions) => {
|
|
93
|
+
const aborted = streamOptions?.signal?.aborted === true;
|
|
94
|
+
streamContexts.push(context);
|
|
95
|
+
streamSignals.push(aborted);
|
|
96
|
+
if (aborted) {
|
|
97
|
+
const message = {
|
|
98
|
+
role: "assistant",
|
|
99
|
+
api: model.api,
|
|
100
|
+
provider: model.provider,
|
|
101
|
+
model: model.id,
|
|
102
|
+
content: [],
|
|
103
|
+
stopReason: "aborted",
|
|
104
|
+
errorMessage: "scripted request aborted",
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
usage: usage(0, 0),
|
|
119
107
|
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
108
|
+
const stream = createAssistantMessageEventStream();
|
|
109
|
+
stream.push({ type: "error", reason: "aborted", error: message });
|
|
110
|
+
stream.end();
|
|
111
|
+
return stream;
|
|
112
|
+
}
|
|
113
|
+
requests.push(context);
|
|
114
|
+
const request = requests.length;
|
|
115
|
+
const message = options.script(request, context);
|
|
116
|
+
const stream = createAssistantMessageEventStream();
|
|
117
|
+
stream.push({ type: "done", reason: (message.stopReason === "error" || message.stopReason === "aborted" ? "stop" : message.stopReason), message });
|
|
118
|
+
stream.end();
|
|
119
|
+
return stream;
|
|
120
|
+
};
|
|
121
|
+
await session.bindExtensions({
|
|
122
|
+
uiContext: {
|
|
123
|
+
notify(message) {
|
|
124
|
+
if (message.startsWith("pi-context: memory cleared · ")) {
|
|
125
|
+
const windowId = message.split(" · ")[1];
|
|
126
|
+
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE && entry.data?.windowId === windowId), "notification follows the reset marker commit");
|
|
127
|
+
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId), "notification follows the reset boot commit");
|
|
126
128
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
129
|
+
if (message.includes("context budget low") || message.includes("context budget critical")) {
|
|
130
|
+
const customType = message.includes("context budget critical") ? WARNING_TYPE : GUIDANCE_TYPE;
|
|
131
|
+
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === customType), "budget notifications follow the reminder commit");
|
|
132
|
+
}
|
|
133
|
+
notices.push(message);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
dir,
|
|
139
|
+
notesRoot,
|
|
140
|
+
sessionManager,
|
|
141
|
+
session,
|
|
142
|
+
model,
|
|
143
|
+
requests,
|
|
144
|
+
streamContexts,
|
|
145
|
+
streamSignals,
|
|
146
|
+
events,
|
|
147
|
+
notices,
|
|
148
|
+
close: () => {
|
|
149
|
+
session.dispose();
|
|
150
|
+
if (managesEnvironment) {
|
|
151
|
+
if (previousDir === undefined)
|
|
152
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
153
|
+
else
|
|
154
|
+
process.env.PI_CODING_AGENT_DIR = previousDir;
|
|
155
|
+
if (previousNotesRoot === undefined)
|
|
156
|
+
delete process.env.PI_NOTES_HOME;
|
|
157
|
+
else
|
|
158
|
+
process.env.PI_NOTES_HOME = previousNotesRoot;
|
|
149
159
|
}
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
if (ownsDir)
|
|
161
|
+
rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
if (ownsNotesRoot)
|
|
163
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function usage(input, output = 1) {
|
|
168
|
+
return {
|
|
169
|
+
input,
|
|
170
|
+
output,
|
|
171
|
+
cacheRead: 0,
|
|
172
|
+
cacheWrite: 0,
|
|
173
|
+
totalTokens: input + output,
|
|
174
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function assistant(fixture, content, stopReason = "stop", extra = {}) {
|
|
178
|
+
return {
|
|
179
|
+
role: "assistant",
|
|
180
|
+
api: fixture.model.api,
|
|
181
|
+
provider: fixture.model.provider,
|
|
182
|
+
model: fixture.model.id,
|
|
183
|
+
content,
|
|
184
|
+
stopReason,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
usage: usage(100),
|
|
187
|
+
...extra,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function text(message) {
|
|
191
|
+
return JSON.stringify(message?.messages ?? []);
|
|
192
|
+
}
|
|
193
|
+
function resetMarkers(fixture) {
|
|
194
|
+
return fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
|
|
195
|
+
}
|
|
196
|
+
function contextRemainingResults(fixture) {
|
|
197
|
+
const results = [];
|
|
198
|
+
for (const entry of fixture.sessionManager.getBranch()) {
|
|
199
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== "get_context_remaining")
|
|
200
|
+
continue;
|
|
201
|
+
const content = entry.message.content.find((part) => part.type === "text")?.text;
|
|
202
|
+
if (content === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
const value = JSON.parse(content);
|
|
205
|
+
if (typeof value.remaining_tokens === "number" || value.remaining_tokens === null)
|
|
206
|
+
results.push(value.remaining_tokens);
|
|
207
|
+
}
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
function assertFreshRequest(fixture, requestIndex, oldSentinel) {
|
|
211
|
+
const body = text(fixture.requests[requestIndex]);
|
|
212
|
+
assert.equal(body.includes(oldSentinel), false, "the new provider request excludes the old window transcript");
|
|
213
|
+
assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
|
|
214
|
+
}
|
|
215
|
+
test("real AgentSession: aborted low-budget requests notify only after a retry commits the reminder", async () => {
|
|
216
|
+
let fixture;
|
|
217
|
+
fixture = await openFixture({
|
|
218
|
+
compactionEnabled: false,
|
|
219
|
+
script: (request) => assistant(fixture, [{ type: "text", text: `response ${request}` }], request === 2 ? "aborted" : "stop", { usage: usage(50_000) }),
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
const reminders = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
|
|
223
|
+
const notices = () => fixture.notices.filter((message) => message.includes("context budget low"));
|
|
224
|
+
await fixture.session.prompt("Establish usage below the reminder line.");
|
|
225
|
+
await fixture.session.waitForIdle();
|
|
226
|
+
assert.equal(reminders().length, 0);
|
|
227
|
+
await fixture.session.prompt("Abort this low-budget request.");
|
|
228
|
+
await fixture.session.waitForIdle();
|
|
229
|
+
assert.equal(reminders().length, 0, "an aborted turn does not commit its reminder");
|
|
230
|
+
assert.equal(notices().length, 0, "an uncommitted reminder never notifies");
|
|
231
|
+
await fixture.session.prompt("Retry successfully.");
|
|
232
|
+
await fixture.session.waitForIdle();
|
|
233
|
+
assert.equal(reminders().length, 1);
|
|
234
|
+
assert.equal(notices().length, 1, "the committed retry notifies once");
|
|
235
|
+
await fixture.session.prompt("Continue in the same window.");
|
|
236
|
+
await fixture.session.waitForIdle();
|
|
237
|
+
assert.equal(reminders().length, 1);
|
|
238
|
+
assert.equal(notices().length, 1, "later turns cannot repeat the notice");
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
fixture.close();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
test("real AgentSession: explicit tiny-session wipe ignores keepRecentTokens and preserves raw history", async () => {
|
|
245
|
+
let fixture;
|
|
246
|
+
fixture = await openFixture({
|
|
247
|
+
compactionEnabled: false,
|
|
248
|
+
keepRecentTokens: 1_000_000,
|
|
249
|
+
script: (request, context) => request === 1
|
|
250
|
+
? assistant(fixture, [{ type: "toolCall", id: "wipe-1", name: "wipe_memory", arguments: {} }], "toolUse")
|
|
251
|
+
: assistant(fixture, [{ type: "text", text: "resumed in a new window" }]),
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
assert.equal(fixture.requests.length, 0, "startup boot does not trigger a model request");
|
|
255
|
+
assert.equal(fixture.notices.length, 0, "startup boot is silent");
|
|
256
|
+
await fixture.session.prompt("OLD_CONTEXT_SENTINEL: retain this only in durable history.");
|
|
257
|
+
await fixture.session.waitForIdle();
|
|
258
|
+
assert.equal(fixture.notices.filter((message) => message.includes("memory cleared")).length, 1, "the reset notifies once");
|
|
259
|
+
assert.equal(fixture.requests.length, 2, "the tool starts exactly one continuation");
|
|
260
|
+
assert.ok(text(fixture.requests[0]).includes("OLD_CONTEXT_SENTINEL"));
|
|
261
|
+
assertFreshRequest(fixture, 1, "OLD_CONTEXT_SENTINEL");
|
|
262
|
+
assert.equal(resetMarkers(fixture).length, 1, "explicit wipe does not depend on Pi's compaction keep window");
|
|
263
|
+
const marker = resetMarkers(fixture)[0];
|
|
264
|
+
assert.equal(marker.type, "custom");
|
|
265
|
+
assert.deepEqual(Object.keys(marker.data), ["windowId"]);
|
|
266
|
+
const windowId = marker.data.windowId;
|
|
267
|
+
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);
|
|
268
|
+
assert.ok(boot && boot.type === "custom_message", "the reset boot is persisted after the marker");
|
|
269
|
+
assert.equal(boot.details.windowId, windowId);
|
|
270
|
+
assert.equal(fixture.sessionManager.getBranch().filter((entry) => entry.type === "compaction").length, 0, "the explicit path does not manufacture a native compaction");
|
|
271
|
+
assert.ok(JSON.stringify(fixture.sessionManager.getBranch()).includes("OLD_CONTEXT_SENTINEL"), "raw history remains readable");
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
fixture.close();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test("real AgentSession: a reset survives all notes-home read failures with an incomplete boot", { timeout: 15000 }, async () => {
|
|
278
|
+
let fixture;
|
|
279
|
+
fixture = await openFixture({
|
|
280
|
+
compactionEnabled: false,
|
|
281
|
+
notesRootFile: true,
|
|
282
|
+
script: (request) => request === 1
|
|
283
|
+
? assistant(fixture, [{ type: "toolCall", id: "wipe-notes-failure", name: "wipe_memory", arguments: {} }], "toolUse")
|
|
284
|
+
: assistant(fixture, [{ type: "text", text: "resumed despite notes failure" }]),
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
await fixture.session.prompt("NOTES_FAILURE_RESET_SENTINEL");
|
|
288
|
+
await fixture.session.waitForIdle();
|
|
289
|
+
assert.equal(fixture.requests.length, 2, "the failed notes index does not cancel the continuation");
|
|
290
|
+
assertFreshRequest(fixture, 1, "NOTES_FAILURE_RESET_SENTINEL");
|
|
291
|
+
assert.equal(resetMarkers(fixture).length, 1, "one reset marker is committed");
|
|
292
|
+
const marker = resetMarkers(fixture)[0];
|
|
293
|
+
assert.equal(marker.type, "custom");
|
|
294
|
+
const windowId = marker.data.windowId;
|
|
295
|
+
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);
|
|
296
|
+
assert.ok(boot && boot.type === "custom_message");
|
|
297
|
+
const bootText = typeof boot.content === "string" ? boot.content : JSON.stringify(boot.content);
|
|
298
|
+
assert.ok(bootText.includes("Notes index incomplete"));
|
|
299
|
+
assert.ok(bootText.includes("notes_list can retry after recovery"));
|
|
300
|
+
assert.ok(bootText.includes("<context_window_protocol>"));
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
fixture.close();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
test("real AgentSession: reset projection drops legacy message shapes but keeps raw history and equal/backwards new timestamps", { timeout: 15000 }, async () => {
|
|
307
|
+
let fixture;
|
|
308
|
+
const legacyTimestamp = Date.now();
|
|
309
|
+
const legacyTexts = [
|
|
310
|
+
"OLD_BASH_EXECUTION",
|
|
311
|
+
"OLD_TOOL_RESULT",
|
|
312
|
+
"OLD_COMPACTION_SUMMARY_WRAPPER",
|
|
313
|
+
"OLD_BRANCH_SUMMARY",
|
|
314
|
+
];
|
|
315
|
+
fixture = await openFixture({
|
|
316
|
+
compactionEnabled: false,
|
|
317
|
+
seed: (sessionManager) => {
|
|
318
|
+
sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "OLD_USER_BEFORE_LEGACY_ENTRIES" }], timestamp: legacyTimestamp });
|
|
319
|
+
sessionManager.appendMessage({ role: "bashExecution", command: legacyTexts[0], output: legacyTexts[0], exitCode: 0, cancelled: false, truncated: false, timestamp: legacyTimestamp });
|
|
320
|
+
sessionManager.appendMessage({ role: "toolResult", content: [{ type: "text", text: legacyTexts[1] }], toolCallId: "legacy-tool", toolName: "bash", isError: false, timestamp: legacyTimestamp });
|
|
321
|
+
const compactionParent = sessionManager.getLeafId();
|
|
322
|
+
sessionManager.appendCompaction(legacyTexts[2], compactionParent, 123, { legacy: true }, true);
|
|
323
|
+
sessionManager.branchWithSummary(sessionManager.getLeafId(), legacyTexts[3], { legacy: true }, true);
|
|
324
|
+
},
|
|
325
|
+
script: (request, context) => {
|
|
326
|
+
if (request === 1)
|
|
327
|
+
return assistant(fixture, [{ type: "toolCall", id: "wipe-legacy", name: "wipe_memory", arguments: {} }], "toolUse");
|
|
328
|
+
if (request === 2) {
|
|
329
|
+
const body = text(context);
|
|
330
|
+
for (const legacy of legacyTexts)
|
|
331
|
+
assert.equal(body.includes(legacy), false, `${legacy} is cut from the reset projection`);
|
|
332
|
+
return assistant(fixture, [{ type: "toolCall", id: "new-timestamp", name: "notes_write", arguments: { address: "equal-timestamp.md", content: "NEW_EQUAL_BACKWARDS_MESSAGE" } }], "toolUse", { timestamp: legacyTimestamp - 1 });
|
|
158
333
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
334
|
+
const body = text(context);
|
|
335
|
+
for (const legacy of legacyTexts)
|
|
336
|
+
assert.equal(body.includes(legacy), false, `${legacy} stays absent after another fresh-window turn`);
|
|
337
|
+
assert.ok(body.includes("NEW_EQUAL_BACKWARDS_MESSAGE"), "a new message with an equal/backwards timestamp survives the projection");
|
|
338
|
+
const newAssistant = context.messages.find((message) => message.role === "assistant" && JSON.stringify(message).includes("NEW_EQUAL_BACKWARDS_MESSAGE"));
|
|
339
|
+
assert.equal(newAssistant?.timestamp, legacyTimestamp - 1, "projection preserves the new message timestamp");
|
|
340
|
+
return assistant(fixture, [{ type: "text", text: "legacy projection clean" }]);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
await fixture.session.prompt("NEW_WINDOW_PROJECTION_SENTINEL");
|
|
345
|
+
await fixture.session.waitForIdle();
|
|
346
|
+
assert.equal(fixture.requests.length, 3, "the fresh window accepts one additional new-window tool turn");
|
|
347
|
+
assert.equal(resetMarkers(fixture).length, 1);
|
|
348
|
+
const raw = JSON.stringify(fixture.sessionManager.getBranch());
|
|
349
|
+
for (const legacy of legacyTexts)
|
|
350
|
+
assert.ok(raw.includes(legacy), `${legacy} remains in durable raw history`);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
fixture.close();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
test("real AgentSession: successive resets and a mixed tool batch cut only after every tool result", async () => {
|
|
357
|
+
for (const scenario of ["successive", "mixed"]) {
|
|
358
|
+
let fixture;
|
|
359
|
+
fixture = await openFixture({
|
|
360
|
+
compactionEnabled: false,
|
|
361
|
+
script: (request) => {
|
|
362
|
+
if (scenario === "successive" && request < 3)
|
|
363
|
+
return assistant(fixture, [{ type: "toolCall", id: `wipe-${request}`, name: "wipe_memory", arguments: {} }], "toolUse");
|
|
364
|
+
if (scenario === "mixed" && request === 1)
|
|
365
|
+
return assistant(fixture, [
|
|
366
|
+
{ type: "toolCall", id: "wipe-1", name: "wipe_memory", arguments: {} },
|
|
367
|
+
{ type: "toolCall", id: "note-1", name: "notes_write", arguments: { address: "batch.md", content: "MIXED_BATCH_NOTE" } },
|
|
368
|
+
], "toolUse");
|
|
369
|
+
return assistant(fixture, [{ type: "text", text: "fresh completion" }]);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
await fixture.session.prompt(`OLD_${scenario.toUpperCase()}_SENTINEL`);
|
|
374
|
+
await fixture.session.waitForIdle();
|
|
375
|
+
const expected = scenario === "successive" ? 2 : 1;
|
|
376
|
+
assert.equal(resetMarkers(fixture).length, expected);
|
|
377
|
+
assert.equal(fixture.requests.length, expected + 1);
|
|
378
|
+
assertFreshRequest(fixture, fixture.requests.length - 1, `OLD_${scenario.toUpperCase()}_SENTINEL`);
|
|
379
|
+
if (scenario === "mixed") {
|
|
380
|
+
const branch = fixture.sessionManager.getBranch();
|
|
381
|
+
const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE);
|
|
382
|
+
assert.ok(markerIndex > 0, "the marker follows the completed tool batch");
|
|
383
|
+
assert.ok(branch.slice(0, markerIndex).some((entry) => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "notes_write"), "notes_write completes before the cut");
|
|
384
|
+
assert.ok(existsSync(join(fixture.notesRoot, "pi", "session", fixture.sessionManager.getSessionId(), "batch.md")));
|
|
183
385
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
fixture.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
test("real AgentSession: steering and follow-up are delivered exactly once in the fresh window", async () => {
|
|
393
|
+
for (const deliverAs of ["steer", "followUp"]) {
|
|
394
|
+
let fixture;
|
|
395
|
+
fixture = await openFixture({
|
|
396
|
+
compactionEnabled: false,
|
|
397
|
+
hook: (pi, getSession) => {
|
|
398
|
+
pi.on("tool_call", async (event) => {
|
|
399
|
+
if (event.toolName !== "wipe_memory")
|
|
400
|
+
return;
|
|
401
|
+
if (deliverAs === "steer")
|
|
402
|
+
await getSession().steer("QUEUED_ONCE");
|
|
403
|
+
else
|
|
404
|
+
await getSession().followUp("QUEUED_ONCE");
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
script: (request) => request === 1
|
|
408
|
+
? assistant(fixture, [{ type: "toolCall", id: "wipe-queue", name: "wipe_memory", arguments: {} }], "toolUse")
|
|
409
|
+
: assistant(fixture, [{ type: "text", text: "queued message handled" }]),
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
await fixture.session.prompt(`OLD_${deliverAs}_SENTINEL`);
|
|
413
|
+
await fixture.session.waitForIdle();
|
|
414
|
+
assert.equal(fixture.requests.length, 2, `${deliverAs} does not duplicate the resumed request`);
|
|
415
|
+
assertFreshRequest(fixture, 1, `OLD_${deliverAs}_SENTINEL`);
|
|
416
|
+
assert.equal(text(fixture.requests[1]).split("QUEUED_ONCE").length - 1, 1, `${deliverAs} arrives once in the new window`);
|
|
417
|
+
const queued = fixture.sessionManager.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_ONCE"));
|
|
418
|
+
assert.equal(queued.length, 1, `${deliverAs} has one durable user message`);
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
fixture.close();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
test("real AgentSession: hidden or missing reset boots refuse to move the boundary", { timeout: 15000 }, async () => {
|
|
426
|
+
let fixture;
|
|
427
|
+
fixture = await openFixture({
|
|
428
|
+
compactionEnabled: false,
|
|
429
|
+
script: () => assistant(fixture, [{ type: "text", text: "scripted acknowledgement" }]),
|
|
430
|
+
});
|
|
431
|
+
try {
|
|
432
|
+
await fixture.session.prompt("ROOT_BEFORE_HIDDEN_BOOT");
|
|
433
|
+
await fixture.session.waitForIdle();
|
|
434
|
+
await fixture.session.prompt("/wipe-memory");
|
|
435
|
+
await fixture.session.waitForIdle();
|
|
436
|
+
await fixture.session.prompt("VALID_POST_MARKER_WORK");
|
|
437
|
+
await fixture.session.waitForIdle();
|
|
438
|
+
const marker = resetMarkers(fixture)[0];
|
|
439
|
+
assert.ok(marker && marker.type === "custom");
|
|
440
|
+
const windowId = marker.data.windowId;
|
|
441
|
+
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);
|
|
442
|
+
assert.ok(boot && boot.type === "custom_message");
|
|
443
|
+
fixture.sessionManager.appendContextEdit(boot.id, null);
|
|
444
|
+
const bootCount = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details && typeof entry.details === "object" && entry.details.windowId === windowId).length;
|
|
445
|
+
assert.equal(bootCount(), 1);
|
|
446
|
+
await fixture.session.reload();
|
|
447
|
+
assert.equal(bootCount(), 1, "a raw boot hidden by context_edit is not replaced at the tail");
|
|
448
|
+
const requestsBeforeRefusal = fixture.requests.length;
|
|
449
|
+
await fixture.session.prompt("MISSING_PROJECTED_BOOT_SENTINEL");
|
|
450
|
+
await fixture.session.waitForIdle();
|
|
451
|
+
assert.equal(bootCount(), 1, "a refused active window never receives a replacement boot");
|
|
452
|
+
assert.ok(fixture.requests.length <= requestsBeforeRefusal, "the normal provider request is not generated from a shortened window");
|
|
453
|
+
const safeContext = fixture.streamContexts.at(-1);
|
|
454
|
+
assert.ok(safeContext, "the SDK may still invoke the stream function with an aborted signal");
|
|
455
|
+
assert.ok(safeContext.messages.every((message) => message.role === "system"), "a refused request receives system-only safe context");
|
|
456
|
+
assert.equal(fixture.streamSignals.at(-1), true, "the refused request is aborted before provider work");
|
|
457
|
+
assert.ok(JSON.stringify(fixture.sessionManager.getBranch()).includes("VALID_POST_MARKER_WORK"), "raw post-marker work remains durable");
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
fixture.close();
|
|
461
|
+
}
|
|
462
|
+
let missingRaw;
|
|
463
|
+
missingRaw = await openFixture({
|
|
464
|
+
compactionEnabled: false,
|
|
465
|
+
seed: (sessionManager) => {
|
|
466
|
+
sessionManager.appendCustomEntry(RESET_MARKER_TYPE, { windowId: "pcw:missing:boot" });
|
|
467
|
+
sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "RAW_POST_MARKER_WORK" }], timestamp: Date.now() });
|
|
468
|
+
},
|
|
469
|
+
script: () => assistant(missingRaw, [{ type: "text", text: "must not be called normally" }]),
|
|
470
|
+
});
|
|
471
|
+
try {
|
|
472
|
+
assert.equal(missingRaw.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE).length, 0, "post-marker conversation blocks startup repair");
|
|
473
|
+
await missingRaw.session.prompt("MISSING_RAW_BOOT_SENTINEL");
|
|
474
|
+
await missingRaw.session.waitForIdle();
|
|
475
|
+
assert.equal(missingRaw.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE).length, 0, "later conversation without a boot is refused, not repaired");
|
|
476
|
+
assert.equal(missingRaw.streamSignals.at(-1), true);
|
|
477
|
+
assert.ok(missingRaw.streamContexts.at(-1)?.messages.every((message) => message.role === "system"));
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
missingRaw.close();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
test("real AgentSession: the complete system message and a changed tool loadout survive the reset projection", async () => {
|
|
484
|
+
let fixture;
|
|
485
|
+
fixture = await openFixture({
|
|
486
|
+
compactionEnabled: false,
|
|
487
|
+
systemPrompt: ["SYSTEM_SECTION_ALPHA", "SYSTEM_SECTION_BETA", "SYSTEM_TOOLS_SECTION", "SYSTEM_LOADOUT_SECTION"].join("\n"),
|
|
488
|
+
tools: ["wipe_memory"],
|
|
489
|
+
script: (request, context) => {
|
|
490
|
+
const systemText = JSON.stringify(context.messages.filter((message) => message.role === "system"));
|
|
491
|
+
const currentSystem = getCurrentSystemMessage(context.messages);
|
|
492
|
+
const effectiveToolNames = (currentSystem?.toolsAdded ?? []).map((tool) => tool.name).sort();
|
|
493
|
+
assert.ok(systemText.includes("SYSTEM_SECTION_ALPHA"));
|
|
494
|
+
assert.ok(systemText.includes("SYSTEM_SECTION_BETA"));
|
|
495
|
+
assert.ok(systemText.includes("SYSTEM_TOOLS_SECTION"));
|
|
496
|
+
assert.ok(systemText.includes("SYSTEM_LOADOUT_SECTION"));
|
|
497
|
+
if (request === 1) {
|
|
498
|
+
assert.deepEqual(effectiveToolNames, ["wipe_memory"]);
|
|
499
|
+
fixture.session.setActiveToolsByName(["wipe_memory", "get_context_remaining"]);
|
|
500
|
+
return assistant(fixture, [{ type: "toolCall", id: "wipe-system", name: "wipe_memory", arguments: {} }], "toolUse");
|
|
190
501
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
502
|
+
assert.deepEqual(effectiveToolNames, ["get_context_remaining", "wipe_memory"]);
|
|
503
|
+
assert.ok(JSON.stringify(context.messages).includes(CONTEXT_WINDOW_OPEN_TAG));
|
|
504
|
+
return assistant(fixture, [{ type: "text", text: "all system sections survived" }]);
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
try {
|
|
508
|
+
await fixture.session.prompt("SYSTEM_LOADOUT_SENTINEL");
|
|
509
|
+
await fixture.session.waitForIdle();
|
|
510
|
+
assert.equal(fixture.requests.length, 2);
|
|
511
|
+
assertFreshRequest(fixture, 1, "SYSTEM_LOADOUT_SENTINEL");
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
fixture.close();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
test("real AgentSession: overflow and recoverable length reset and retry once; repeated failure is bounded", { timeout: 15000 }, async () => {
|
|
518
|
+
for (const failure of ["overflow", "length"]) {
|
|
519
|
+
let fixture;
|
|
520
|
+
fixture = await openFixture({
|
|
521
|
+
script: (request) => {
|
|
522
|
+
if (request === 1 && failure === "overflow")
|
|
523
|
+
return assistant(fixture, [{ type: "text", text: "overflow response" }], "error", { errorMessage: "Prompt too long: context exceeds maximum context length" });
|
|
524
|
+
if (request === 1)
|
|
525
|
+
return assistant(fixture, [{ type: "text", text: "truncated" }], "length", { usage: usage(99_000, 1) });
|
|
526
|
+
return assistant(fixture, [{ type: "text", text: "recovered" }]);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
try {
|
|
530
|
+
await fixture.session.prompt(`RECOVER_${failure}_SENTINEL`);
|
|
531
|
+
await fixture.session.waitForIdle();
|
|
532
|
+
assert.equal(resetMarkers(fixture).length, 1, `${failure} performs one reset`);
|
|
533
|
+
assert.equal(fixture.requests.length, 2, `${failure} resumes once`);
|
|
534
|
+
assertFreshRequest(fixture, 1, `RECOVER_${failure}_SENTINEL`);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
fixture.close();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
let repeated;
|
|
541
|
+
repeated = await openFixture({
|
|
542
|
+
script: (request) => assistant(repeated, [{ type: "text", text: `overflow-${request}` }], "error", { errorMessage: "Prompt too long: context exceeds maximum context length" }),
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
await repeated.session.prompt("REPEATED_OVERFLOW_SENTINEL");
|
|
546
|
+
await repeated.session.waitForIdle();
|
|
547
|
+
assert.equal(resetMarkers(repeated).length, 1, "repeated overflow does not create repeated windows");
|
|
548
|
+
assert.equal(repeated.requests.length, 2, "repeated overflow stops after one recovery request");
|
|
549
|
+
assert.ok(repeated.events.some((event) => event.type === "compaction_end" && typeof event.errorMessage === "string"));
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
repeated.close();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
test("real AgentSession: concurrent trusted projects keep reserve and automatic policy isolated", { timeout: 20000 }, async () => {
|
|
556
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-context-shared-agent-"));
|
|
557
|
+
const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-shared-notes-"));
|
|
558
|
+
const cwdAutomatic = mkdtempSync(join(tmpdir(), "pi-context-project-automatic-"));
|
|
559
|
+
const cwdModel = mkdtempSync(join(tmpdir(), "pi-context-project-model-"));
|
|
560
|
+
const cwdSession = mkdtempSync(join(tmpdir(), "pi-context-project-session-"));
|
|
561
|
+
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({
|
|
562
|
+
compaction: { enabled: true, reserveTokens: 70_000, keepRecentTokens: 200 },
|
|
563
|
+
retry: { enabled: false },
|
|
564
|
+
}));
|
|
565
|
+
let automatic;
|
|
566
|
+
let modelInvalidated;
|
|
567
|
+
let sessionInvalidated;
|
|
568
|
+
const script = (fixture, highUsage) => () => {
|
|
569
|
+
const response = assistant(fixture(), [{ type: "text", text: "policy isolation response" }], "stop", { usage: usage(highUsage.next ? 85_000 : 100) });
|
|
570
|
+
highUsage.next = false;
|
|
571
|
+
return response;
|
|
572
|
+
};
|
|
573
|
+
const automaticUsage = { next: true };
|
|
574
|
+
const modelUsage = { next: true };
|
|
575
|
+
const sessionUsage = { next: true };
|
|
576
|
+
try {
|
|
577
|
+
// All three sessions share one stable global settings root. Only their trusted
|
|
578
|
+
// project settings differ, so this cannot pass by rotating PI_CODING_AGENT_DIR.
|
|
579
|
+
automatic = await openFixture({
|
|
580
|
+
contextWindow: 100_000,
|
|
581
|
+
cwd: cwdAutomatic,
|
|
582
|
+
agentDir,
|
|
583
|
+
notesRoot,
|
|
584
|
+
writeSettings: false,
|
|
585
|
+
projectSettings: { compaction: { enabled: true, reserveTokens: 20_000 } },
|
|
586
|
+
script: script(() => automatic, automaticUsage),
|
|
587
|
+
});
|
|
588
|
+
modelInvalidated = await openFixture({
|
|
589
|
+
contextWindow: 100_000,
|
|
590
|
+
cwd: cwdModel,
|
|
591
|
+
agentDir,
|
|
592
|
+
notesRoot,
|
|
593
|
+
writeSettings: false,
|
|
594
|
+
projectSettings: { compaction: { enabled: false, reserveTokens: 80_000 } },
|
|
595
|
+
script: script(() => modelInvalidated, modelUsage),
|
|
596
|
+
});
|
|
597
|
+
sessionInvalidated = await openFixture({
|
|
598
|
+
contextWindow: 100_000,
|
|
599
|
+
cwd: cwdSession,
|
|
600
|
+
agentDir,
|
|
601
|
+
notesRoot,
|
|
602
|
+
writeSettings: false,
|
|
603
|
+
projectSettings: { compaction: { enabled: false, reserveTokens: 80_000 } },
|
|
604
|
+
script: script(() => sessionInvalidated, sessionUsage),
|
|
605
|
+
});
|
|
606
|
+
await Promise.all([
|
|
607
|
+
automatic.session.prompt("AUTOMATIC_POLICY_PROJECT"),
|
|
608
|
+
modelInvalidated.session.prompt("MODEL_POLICY_PROJECT"),
|
|
609
|
+
sessionInvalidated.session.prompt("SESSION_POLICY_PROJECT"),
|
|
610
|
+
]);
|
|
611
|
+
await Promise.all([automatic.session.waitForIdle(), modelInvalidated.session.waitForIdle(), sessionInvalidated.session.waitForIdle()]);
|
|
612
|
+
assert.equal(resetMarkers(automatic).length, 1, "the low reserve and enabled project resets automatically");
|
|
613
|
+
assert.equal(resetMarkers(modelInvalidated).length, 0, "the high reserve and disabled project does not reset");
|
|
614
|
+
assert.equal(resetMarkers(sessionInvalidated).length, 0, "the second high reserve and disabled session does not reset");
|
|
615
|
+
writeFileSync(join(cwdModel, ".pi", "settings.json"), JSON.stringify({ compaction: { enabled: true, reserveTokens: 20_000 } }));
|
|
616
|
+
modelUsage.next = true;
|
|
617
|
+
const changedModel = { ...modelInvalidated.model, id: `${modelInvalidated.model.id}-changed` };
|
|
618
|
+
modelInvalidated.model = changedModel;
|
|
619
|
+
await modelInvalidated.session.setModel(changedModel, { persist: false });
|
|
620
|
+
await modelInvalidated.session.prompt("MODEL_POLICY_AFTER_INVALIDATION");
|
|
621
|
+
await modelInvalidated.session.waitForIdle();
|
|
622
|
+
assert.equal(resetMarkers(modelInvalidated).length, 1, "model_select invalidates the instance policy cache");
|
|
623
|
+
writeFileSync(join(cwdSession, ".pi", "settings.json"), JSON.stringify({ compaction: { enabled: true, reserveTokens: 20_000 } }));
|
|
624
|
+
sessionUsage.next = true;
|
|
625
|
+
await sessionInvalidated.session.reload();
|
|
626
|
+
await sessionInvalidated.session.prompt("SESSION_POLICY_AFTER_INVALIDATION");
|
|
627
|
+
await sessionInvalidated.session.waitForIdle();
|
|
628
|
+
assert.equal(resetMarkers(sessionInvalidated).length, 1, "session reload invalidates the instance policy cache");
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
sessionInvalidated?.close();
|
|
632
|
+
modelInvalidated?.close();
|
|
633
|
+
automatic?.close();
|
|
634
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
635
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
636
|
+
rmSync(cwdAutomatic, { recursive: true, force: true });
|
|
637
|
+
rmSync(cwdModel, { recursive: true, force: true });
|
|
638
|
+
rmSync(cwdSession, { recursive: true, force: true });
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
test("real AgentSession: injected settings managers own live policy and ignore conflicting default files", { timeout: 20000 }, async () => {
|
|
642
|
+
const firstManager = SettingsManager.inMemory({ compaction: { enabled: true, reserveTokens: 80_000 } });
|
|
643
|
+
const secondManager = SettingsManager.inMemory({ compaction: { enabled: false, reserveTokens: 20_000 } });
|
|
644
|
+
const defaultAgentDir = mkdtempSync(join(tmpdir(), "pi-context-injected-default-agent-"));
|
|
645
|
+
const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-injected-notes-"));
|
|
646
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
647
|
+
const previousNotesRoot = process.env.PI_NOTES_HOME;
|
|
648
|
+
writeFileSync(join(defaultAgentDir, "settings.json"), JSON.stringify({ compaction: { enabled: false, reserveTokens: 1_000 } }));
|
|
649
|
+
process.env.PI_CODING_AGENT_DIR = defaultAgentDir;
|
|
650
|
+
process.env.PI_NOTES_HOME = notesRoot;
|
|
651
|
+
let first;
|
|
652
|
+
let second;
|
|
653
|
+
const firstHighUsage = { next: false };
|
|
654
|
+
const secondHighUsage = { next: false };
|
|
655
|
+
const script = (getFixture, highUsage) => (request) => {
|
|
656
|
+
const fixture = getFixture();
|
|
657
|
+
if (request === 1) {
|
|
658
|
+
highUsage.next = false;
|
|
659
|
+
return assistant(fixture, [{ type: "toolCall", id: "remaining", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(20_000) });
|
|
660
|
+
}
|
|
661
|
+
const input = highUsage.next ? 85_000 : 100;
|
|
662
|
+
highUsage.next = false;
|
|
663
|
+
return assistant(fixture, [{ type: "text", text: "scripted policy response" }], "stop", { usage: usage(input) });
|
|
664
|
+
};
|
|
665
|
+
try {
|
|
666
|
+
first = await openFixture({
|
|
667
|
+
contextWindow: 100_000,
|
|
668
|
+
agentDir: defaultAgentDir,
|
|
669
|
+
notesRoot,
|
|
670
|
+
writeSettings: false,
|
|
671
|
+
defaultCompactionEnabled: false,
|
|
672
|
+
defaultReserveTokens: 1_000,
|
|
673
|
+
settingsManager: firstManager,
|
|
674
|
+
manageEnvironment: false,
|
|
675
|
+
tools: ["get_context_remaining"],
|
|
676
|
+
script: script(() => first, firstHighUsage),
|
|
677
|
+
});
|
|
678
|
+
second = await openFixture({
|
|
679
|
+
contextWindow: 100_000,
|
|
680
|
+
agentDir: defaultAgentDir,
|
|
681
|
+
notesRoot,
|
|
682
|
+
writeSettings: false,
|
|
683
|
+
defaultCompactionEnabled: true,
|
|
684
|
+
defaultReserveTokens: 95_000,
|
|
685
|
+
settingsManager: secondManager,
|
|
686
|
+
manageEnvironment: false,
|
|
687
|
+
tools: ["get_context_remaining"],
|
|
688
|
+
script: script(() => second, secondHighUsage),
|
|
689
|
+
});
|
|
690
|
+
await Promise.all([first.session.prompt("INJECTED_FIRST"), second.session.prompt("INJECTED_SECOND")]);
|
|
691
|
+
await Promise.all([first.session.waitForIdle(), second.session.waitForIdle()]);
|
|
692
|
+
assert.deepEqual(contextRemainingResults(first), [0], "the injected high reserve reaches the warning line in the real tool result");
|
|
693
|
+
assert.ok((contextRemainingResults(second)[0] ?? 0) > 0, "the second injected reserve produces a distinct real tool result");
|
|
694
|
+
assert.equal(resetMarkers(first).length, 1, "enabled injected policy resets at the first session's reserve");
|
|
695
|
+
assert.equal(resetMarkers(second).length, 0, "disabled injected policy does not inherit the first session's reset decision");
|
|
696
|
+
firstHighUsage.next = true;
|
|
697
|
+
firstManager.setCompactionEnabled(false);
|
|
698
|
+
firstManager.applyOverrides({ compaction: { reserveTokens: 90_000 } });
|
|
699
|
+
await first.session.prompt("INJECTED_LIVE_DISABLE");
|
|
700
|
+
await first.session.waitForIdle();
|
|
701
|
+
assert.equal(resetMarkers(first).length, 1, "a public setter disables the next turn without reload");
|
|
702
|
+
assert.equal(resetMarkers(second).length, 0, "the second live manager remains unchanged");
|
|
703
|
+
firstHighUsage.next = true;
|
|
704
|
+
firstManager.setCompactionEnabled(true);
|
|
705
|
+
firstManager.applyOverrides({ compaction: { reserveTokens: 1_000 } });
|
|
706
|
+
await first.session.prompt("INJECTED_LIVE_ENABLE_LOW_RESERVE");
|
|
707
|
+
await first.session.waitForIdle();
|
|
708
|
+
assert.equal(resetMarkers(first).length, 1, "live enablement and a lower reserve apply on the next turn");
|
|
709
|
+
firstHighUsage.next = true;
|
|
710
|
+
firstManager.applyOverrides({ compaction: { reserveTokens: 90_000 } });
|
|
711
|
+
await first.session.prompt("INJECTED_LIVE_HIGH_RESERVE");
|
|
712
|
+
await first.session.waitForIdle();
|
|
713
|
+
assert.equal(resetMarkers(first).length, 2, "a live reserve increase drives the next reset decision");
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
second?.close();
|
|
717
|
+
first?.close();
|
|
718
|
+
if (previousAgentDir === undefined)
|
|
719
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
720
|
+
else
|
|
721
|
+
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
722
|
+
if (previousNotesRoot === undefined)
|
|
723
|
+
delete process.env.PI_NOTES_HOME;
|
|
724
|
+
else
|
|
725
|
+
process.env.PI_NOTES_HOME = previousNotesRoot;
|
|
726
|
+
rmSync(defaultAgentDir, { recursive: true, force: true });
|
|
727
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
test("real AgentSession: injected model overrides select the active model's reserve", { timeout: 20000 }, async () => {
|
|
731
|
+
let settingsManager;
|
|
732
|
+
let modelBId = "";
|
|
733
|
+
const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-model-override-notes-"));
|
|
734
|
+
const previousNotesRoot = process.env.PI_NOTES_HOME;
|
|
735
|
+
process.env.PI_NOTES_HOME = notesRoot;
|
|
736
|
+
let fixture;
|
|
737
|
+
fixture = await openFixture({
|
|
738
|
+
contextWindow: 100_000,
|
|
739
|
+
notesRoot,
|
|
740
|
+
settingsManager: (model) => {
|
|
741
|
+
modelBId = `${model.id}-changed`;
|
|
742
|
+
settingsManager = SettingsManager.inMemory({
|
|
743
|
+
compaction: {
|
|
744
|
+
enabled: false,
|
|
745
|
+
reserveTokens: 10_000,
|
|
746
|
+
modelOverrides: {
|
|
747
|
+
[`${model.provider}/${model.id}`]: { reserveTokens: 70_000 },
|
|
748
|
+
[`${model.provider}/${modelBId}`]: { reserveTokens: 1_000 },
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
return settingsManager;
|
|
753
|
+
},
|
|
754
|
+
manageEnvironment: false,
|
|
755
|
+
tools: ["get_context_remaining"],
|
|
756
|
+
script: (request) => request === 1 || request === 3
|
|
757
|
+
? assistant(fixture, [{ type: "toolCall", id: `remaining-${request}`, name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(30_000) })
|
|
758
|
+
: assistant(fixture, [{ type: "text", text: "scripted model override response" }], "stop", { usage: usage(1_000) }),
|
|
759
|
+
});
|
|
760
|
+
try {
|
|
761
|
+
await fixture.session.prompt("MODEL_OVERRIDE_A");
|
|
762
|
+
await fixture.session.waitForIdle();
|
|
763
|
+
assert.deepEqual(contextRemainingResults(fixture), [0], "model-a's high reserve reaches zero in the real tool result");
|
|
764
|
+
const changedModel = { ...fixture.model, id: modelBId };
|
|
765
|
+
fixture.model = changedModel;
|
|
766
|
+
await fixture.session.setModel(changedModel, { persist: false });
|
|
767
|
+
await fixture.session.prompt("MODEL_OVERRIDE_B");
|
|
768
|
+
await fixture.session.waitForIdle();
|
|
769
|
+
const results = contextRemainingResults(fixture);
|
|
770
|
+
assert.equal(results.length, 2);
|
|
771
|
+
assert.ok((results[1] ?? 0) > 0, "model-b's lower reserve is selected for the next real tool result");
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
fixture.close();
|
|
775
|
+
if (previousNotesRoot === undefined)
|
|
776
|
+
delete process.env.PI_NOTES_HOME;
|
|
777
|
+
else
|
|
778
|
+
process.env.PI_NOTES_HOME = previousNotesRoot;
|
|
779
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
test("real AgentSession: warning precedes a durable checkpoint, including failed writes and an ignored warning", { timeout: 20000 }, async () => {
|
|
783
|
+
for (const scenario of ["checkpoint", "write-error", "ignored-warning"]) {
|
|
784
|
+
let fixture;
|
|
785
|
+
fixture = await openFixture({
|
|
786
|
+
compactionEnabled: true,
|
|
787
|
+
script: (request, context) => {
|
|
788
|
+
if (request === 3)
|
|
789
|
+
assert.ok(text(context).includes(GUIDANCE_OPEN_TAG), "the critical warning reaches the provider before checkpoint choice");
|
|
790
|
+
if (request === 1)
|
|
791
|
+
return assistant(fixture, [{ type: "toolCall", id: "budget-probe-1", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(50_000) });
|
|
792
|
+
if (request === 2)
|
|
793
|
+
return assistant(fixture, [{ type: "toolCall", id: "budget-probe-2", name: "get_context_remaining", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
794
|
+
if (request === 3 && scenario !== "ignored-warning")
|
|
795
|
+
return assistant(fixture, [{ type: "toolCall", id: "checkpoint", name: "notes_write", arguments: {
|
|
796
|
+
address: scenario === "write-error" ? "../invalid.md" : "checkpoint.md",
|
|
797
|
+
content: "CHECKPOINT_SENTINEL",
|
|
798
|
+
} }], "toolUse", { usage: usage(60_000) });
|
|
799
|
+
if (request === 4 && scenario !== "ignored-warning")
|
|
800
|
+
return assistant(fixture, [{ type: "toolCall", id: "wipe-after-checkpoint", name: "wipe_memory", arguments: {} }], "toolUse", { usage: usage(60_000) });
|
|
801
|
+
return assistant(fixture, [{ type: "text", text: scenario === "ignored-warning" ? "warning intentionally ignored" : "checkpoint and wipe complete" }], "stop", { usage: usage(60_000) });
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
try {
|
|
805
|
+
await fixture.session.prompt(`WARNING_${scenario.toUpperCase()}_SENTINEL`);
|
|
806
|
+
await fixture.session.waitForIdle();
|
|
807
|
+
const branch = fixture.sessionManager.getBranch();
|
|
808
|
+
assert.equal(branch.filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE).length, 1, "one early reminder is durable");
|
|
809
|
+
assert.equal(branch.filter((entry) => entry.type === "custom_message" && entry.customType === WARNING_TYPE).length, 1, "one critical warning is durable");
|
|
810
|
+
const noteFile = join(fixture.notesRoot, "pi", "session", fixture.sessionManager.getSessionId(), "checkpoint.md");
|
|
811
|
+
if (scenario === "checkpoint") {
|
|
812
|
+
assert.equal(resetMarkers(fixture).length, 1);
|
|
813
|
+
assert.equal(existsSync(noteFile), true, "a successful checkpoint reaches the notes store before reset");
|
|
814
|
+
assert.ok(text(fixture.requests.at(-1)).includes("checkpoint.md"), "the fresh boot indexes the successful checkpoint");
|
|
194
815
|
}
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
await session.prompt("SECOND_WINDOW_SENTINEL: " + "Additional work. ".repeat(100));
|
|
200
|
-
await nextFinished;
|
|
201
|
-
await session.waitForIdle();
|
|
202
|
-
assert.equal(resets, 2);
|
|
203
|
-
assert.ok(!requests.at(-1).includes("SECOND_WINDOW_SENTINEL"));
|
|
204
|
-
const boundaries = sm.getBranch().filter((entry) => entry.type === "compaction");
|
|
205
|
-
assert.equal(new Set(boundaries.map((entry) => JSON.stringify(entry.details))).size, 2);
|
|
816
|
+
else if (scenario === "write-error") {
|
|
817
|
+
assert.equal(resetMarkers(fixture).length, 1);
|
|
818
|
+
assert.equal(existsSync(noteFile), false, "a failed checkpoint does not create a note");
|
|
819
|
+
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");
|
|
206
820
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
assert.ok(JSON.stringify(session.sessionManager.getBranch()).includes("OLD_CONTEXT_SENTINEL"));
|
|
211
|
-
if (mode === "golden") {
|
|
212
|
-
await session.prompt("Keep working in the new window until its reminder threshold.");
|
|
213
|
-
assert.equal(resets, 1);
|
|
214
|
-
const branch = sm.getBranch();
|
|
215
|
-
const boundary = branch.findIndex((entry) => entry.type === "compaction");
|
|
216
|
-
const reminders = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE ? [i] : []);
|
|
217
|
-
assert.equal(reminders.length, 2, "the next window gets its own reminder");
|
|
218
|
-
assert.ok(reminders[1] > boundary, "old messages cannot suppress a new window's reminder");
|
|
219
|
-
const warnings = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === WARNING_TYPE ? [i] : []);
|
|
220
|
-
assert.equal(warnings.length, 1, "the next window has no warning yet");
|
|
821
|
+
else {
|
|
822
|
+
assert.equal(resetMarkers(fixture).length, 0, "ignoring the warning does not reset automatically");
|
|
823
|
+
assert.equal(existsSync(noteFile), false);
|
|
221
824
|
}
|
|
222
825
|
}
|
|
223
826
|
finally {
|
|
224
|
-
|
|
225
|
-
session?.dispose();
|
|
226
|
-
if (previousDir === undefined)
|
|
227
|
-
delete process.env.PI_CODING_AGENT_DIR;
|
|
228
|
-
else
|
|
229
|
-
process.env.PI_CODING_AGENT_DIR = previousDir;
|
|
230
|
-
if (previousNotesRoot === undefined)
|
|
231
|
-
delete process.env.PI_NOTES_HOME;
|
|
232
|
-
else
|
|
233
|
-
process.env.PI_NOTES_HOME = previousNotesRoot;
|
|
234
|
-
rmSync(dir, { recursive: true, force: true });
|
|
235
|
-
rmSync(notesRoot, { recursive: true, force: true });
|
|
827
|
+
fixture.close();
|
|
236
828
|
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
829
|
+
}
|
|
830
|
+
});
|