@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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import piContext, { createPiContext } from "../../src/index.js";
|
|
7
|
+
import { loadNotesSnapshot } from "../../src/notes/notes-snapshot.js";
|
|
8
|
+
import { renderBootBlock } from "../../src/context/prompts.js";
|
|
9
|
+
import { agentSlug, modelSlug } from "../../src/notes/paths.js";
|
|
10
|
+
import { rootWindowId } from "../../src/context/context-window.js";
|
|
11
|
+
import { TOOL_OUTPUT_MAX_BYTES } from "../../src/tool-output.js";
|
|
12
|
+
let defaultCwd = "/private/tmp/pi-context-test-cwd";
|
|
13
|
+
let registerTempPath;
|
|
14
|
+
/**
|
|
15
|
+
* Install the process-level settings and notes roots needed by one test file.
|
|
16
|
+
* The caller owns the lifecycle hooks; this helper never registers tests or hooks.
|
|
17
|
+
*/
|
|
18
|
+
export function installExtensionTestEnvironment(prefix = "pi-context-test") {
|
|
19
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
20
|
+
const previousNotesHome = process.env.PI_NOTES_HOME;
|
|
21
|
+
const defaultAgentDir = mkdtempSync(join(tmpdir(), `${prefix}-agent-`));
|
|
22
|
+
const cwd = mkdtempSync(join(tmpdir(), `${prefix}-cwd-`));
|
|
23
|
+
defaultCwd = cwd;
|
|
24
|
+
const notesRoots = new Set();
|
|
25
|
+
const tempPaths = new Set();
|
|
26
|
+
registerTempPath = (path) => { tempPaths.add(path); };
|
|
27
|
+
const newNotesRoot = () => {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), `${prefix}-notes-`));
|
|
29
|
+
notesRoots.add(root);
|
|
30
|
+
process.env.PI_NOTES_HOME = root;
|
|
31
|
+
return root;
|
|
32
|
+
};
|
|
33
|
+
const beforeEach = () => {
|
|
34
|
+
process.env.PI_CODING_AGENT_DIR = defaultAgentDir;
|
|
35
|
+
newNotesRoot();
|
|
36
|
+
};
|
|
37
|
+
const afterEach = () => {
|
|
38
|
+
process.env.PI_CODING_AGENT_DIR = defaultAgentDir;
|
|
39
|
+
for (const root of notesRoots)
|
|
40
|
+
rmSync(root, { recursive: true, force: true });
|
|
41
|
+
notesRoots.clear();
|
|
42
|
+
for (const path of tempPaths)
|
|
43
|
+
rmSync(path, { recursive: true, force: true });
|
|
44
|
+
tempPaths.clear();
|
|
45
|
+
if (previousNotesHome === undefined)
|
|
46
|
+
delete process.env.PI_NOTES_HOME;
|
|
47
|
+
else
|
|
48
|
+
process.env.PI_NOTES_HOME = previousNotesHome;
|
|
49
|
+
};
|
|
50
|
+
const dispose = () => {
|
|
51
|
+
afterEach();
|
|
52
|
+
for (const root of notesRoots)
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
rmSync(defaultAgentDir, { recursive: true, force: true });
|
|
55
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
56
|
+
registerTempPath = undefined;
|
|
57
|
+
if (previousAgentDir === undefined)
|
|
58
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
59
|
+
else
|
|
60
|
+
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
61
|
+
if (previousNotesHome === undefined)
|
|
62
|
+
delete process.env.PI_NOTES_HOME;
|
|
63
|
+
else
|
|
64
|
+
process.env.PI_NOTES_HOME = previousNotesHome;
|
|
65
|
+
};
|
|
66
|
+
return { cwd, agentDir: defaultAgentDir, beforeEach, afterEach, newNotesRoot, dispose };
|
|
67
|
+
}
|
|
68
|
+
export function writeJson(path, value) {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
writeFileSync(path, JSON.stringify(value, null, 2));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Materialize global (agentDir/settings.json) and project (cwd/.pi/settings.json)
|
|
74
|
+
* settings in temp directories, then read them back through the same public
|
|
75
|
+
* SettingsManager.create the extension uses. Never touches the real ~/.pi.
|
|
76
|
+
*/
|
|
77
|
+
export function settingsFixture(options = {}) {
|
|
78
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-context-cwd-"));
|
|
79
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-context-agent-"));
|
|
80
|
+
const global = { ...(options.global ?? {}) };
|
|
81
|
+
if (options.reserveTokens !== undefined)
|
|
82
|
+
global.compaction = { reserveTokens: options.reserveTokens };
|
|
83
|
+
writeJson(join(agentDir, "settings.json"), global);
|
|
84
|
+
if (options.project)
|
|
85
|
+
writeJson(join(cwd, ".pi", "settings.json"), options.project);
|
|
86
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
87
|
+
registerTempPath?.(cwd);
|
|
88
|
+
registerTempPath?.(agentDir);
|
|
89
|
+
const fixtureManager = SettingsManager.create(cwd, agentDir, { projectTrusted: true });
|
|
90
|
+
const projectReserve = options.project?.compaction?.reserveTokens;
|
|
91
|
+
assert.equal(fixtureManager.getCompactionSettings().reserveTokens, projectReserve ?? options.reserveTokens ?? 16_384, "fixture reserve reads back");
|
|
92
|
+
return { cwd, agentDir };
|
|
93
|
+
}
|
|
94
|
+
/** TypeBox's TSchema does not expose `type`/`required` statically; read them structurally. */
|
|
95
|
+
export function objectSchema(tool) {
|
|
96
|
+
return tool?.parameters;
|
|
97
|
+
}
|
|
98
|
+
export function manager(persisted = false) {
|
|
99
|
+
if (!persisted)
|
|
100
|
+
return SessionManager.inMemory("/private/tmp/pi-context-test");
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-context-session-"));
|
|
102
|
+
return SessionManager.create("/private/tmp/pi-context-test", dir);
|
|
103
|
+
}
|
|
104
|
+
export function makeExtension(sessionManager, settingsManager) {
|
|
105
|
+
const captured = { tools: new Map(), handlers: new Map(), commands: new Map(), sent: [], contextMessages: [], flags: [] };
|
|
106
|
+
const api = {
|
|
107
|
+
registerFlag(name) {
|
|
108
|
+
captured.flags.push(name);
|
|
109
|
+
},
|
|
110
|
+
registerTool(tool) {
|
|
111
|
+
captured.tools.set(tool.name, tool);
|
|
112
|
+
},
|
|
113
|
+
registerCommand(name, options) {
|
|
114
|
+
captured.commands.set(name, options);
|
|
115
|
+
},
|
|
116
|
+
on(name, handler) {
|
|
117
|
+
const handlers = captured.handlers.get(name) ?? [];
|
|
118
|
+
handlers.push(handler);
|
|
119
|
+
captured.handlers.set(name, handlers);
|
|
120
|
+
},
|
|
121
|
+
appendEntry(customType, data) {
|
|
122
|
+
sessionManager.appendCustomEntry(customType, data);
|
|
123
|
+
},
|
|
124
|
+
sendMessage(message, options) {
|
|
125
|
+
captured.sent.push({ message, options });
|
|
126
|
+
sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
// The harness implements only the ExtensionAPI members this extension uses.
|
|
130
|
+
(settingsManager ? createPiContext({ settingsManager }) : piContext)(api);
|
|
131
|
+
return captured;
|
|
132
|
+
}
|
|
133
|
+
export function explicitBoot(ctx, currentWindowId, previousWindowId, resetLine) {
|
|
134
|
+
return renderBootBlock({
|
|
135
|
+
agentName: agentSlug(ctx),
|
|
136
|
+
modelName: modelSlug(ctx),
|
|
137
|
+
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
138
|
+
currentWindowId,
|
|
139
|
+
previousWindowId,
|
|
140
|
+
resetLine,
|
|
141
|
+
notes: loadNotesSnapshot(ctx),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export function context(sessionManager, compact, usage, idle = true, cwd = defaultCwd, projectTrusted = true, model) {
|
|
145
|
+
const notices = [];
|
|
146
|
+
const compactionRequests = [];
|
|
147
|
+
const fake = {
|
|
148
|
+
sessionManager,
|
|
149
|
+
model: typeof model === "string" ? { id: model } : model,
|
|
150
|
+
getContextUsage: () => usage,
|
|
151
|
+
compact: (options) => { compactionRequests.push(options); compact?.(options); },
|
|
152
|
+
isIdle: () => idle,
|
|
153
|
+
hasPendingMessages: () => false,
|
|
154
|
+
cwd,
|
|
155
|
+
isProjectTrusted: () => projectTrusted,
|
|
156
|
+
ui: { notify: (message, type) => notices.push({ message, type }) },
|
|
157
|
+
};
|
|
158
|
+
// Only the members the extension reads; the rest of the ExtensionContext surface is unused.
|
|
159
|
+
return Object.assign(fake, { notices, compactionRequests });
|
|
160
|
+
}
|
|
161
|
+
export function noticesOf(ctx) {
|
|
162
|
+
return ctx.notices;
|
|
163
|
+
}
|
|
164
|
+
export function sentOf(captured, customType) {
|
|
165
|
+
return captured.sent.filter((sent) => sent.message.customType === customType);
|
|
166
|
+
}
|
|
167
|
+
export async function call(captured, name, params, ctx) {
|
|
168
|
+
const tool = captured.tools.get(name);
|
|
169
|
+
assert.ok(tool, `registered ${name}`);
|
|
170
|
+
// Most pre-redesign coverage names session notes by their bare address. Keep these old
|
|
171
|
+
// fixture call sites readable while routing the direct tool invocation through its new
|
|
172
|
+
// address-shaped input; contract-specific tests below pass address themselves.
|
|
173
|
+
const noteCall = name === "notes_write" || name === "notes_edit" || name === "notes_read";
|
|
174
|
+
if (noteCall && "path" in params && !("address" in params)) {
|
|
175
|
+
const { path, scope, ...rest } = params;
|
|
176
|
+
assert.equal(typeof path, "string", "legacy note fixture path is a string");
|
|
177
|
+
const address = scope === "project" ? `@project/${path}` : scope === "human" ? `@human/${path}` : path;
|
|
178
|
+
return tool.execute("call-1", { ...rest, address }, new AbortController().signal, () => { }, ctx);
|
|
179
|
+
}
|
|
180
|
+
if ((name === "notes_list" || name === "notes_search") && params.scope === "human") {
|
|
181
|
+
const { scope: _scope, pattern, ...rest } = params;
|
|
182
|
+
return tool.execute("call-1", { ...rest, pattern: `@human/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
|
|
183
|
+
}
|
|
184
|
+
if ((name === "notes_list" || name === "notes_search") && params.scope === "session") {
|
|
185
|
+
const { scope: _scope, pattern, ...rest } = params;
|
|
186
|
+
return tool.execute("call-1", { ...rest, pattern: typeof pattern === "string" ? pattern : "*.md" }, new AbortController().signal, () => { }, ctx);
|
|
187
|
+
}
|
|
188
|
+
return tool.execute("call-1", params, new AbortController().signal, () => { }, ctx);
|
|
189
|
+
}
|
|
190
|
+
export function resultJson(result) {
|
|
191
|
+
const text = result.content[0];
|
|
192
|
+
assert.ok(text && text.type === "text", "tool result carries text");
|
|
193
|
+
const value = JSON.parse(text.text);
|
|
194
|
+
const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@human/") ? address.slice("@human/".length) : address;
|
|
195
|
+
const legacyPath = (row) => {
|
|
196
|
+
if (typeof row.address === "string" && row.path === undefined)
|
|
197
|
+
Object.defineProperty(row, "path", { value: suffix(row.address), enumerable: false });
|
|
198
|
+
};
|
|
199
|
+
legacyPath(value);
|
|
200
|
+
if (Array.isArray(value.files))
|
|
201
|
+
for (const file of value.files)
|
|
202
|
+
if (file && typeof file === "object")
|
|
203
|
+
legacyPath(file);
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
/** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
|
|
207
|
+
export function assertWithinBudget(result, message) {
|
|
208
|
+
const text = result.content[0];
|
|
209
|
+
const bytes = text && text.type === "text" ? Buffer.byteLength(text.text, "utf8") : 0;
|
|
210
|
+
assert.ok(bytes <= TOOL_OUTPUT_MAX_BYTES, `${message}: ${bytes} bytes over the ${TOOL_OUTPUT_MAX_BYTES}-byte budget`);
|
|
211
|
+
}
|
|
212
|
+
/** Decode either raw read without including its shared metadata block in the payload. */
|
|
213
|
+
export function resultRead(result) {
|
|
214
|
+
const text = result.content[0];
|
|
215
|
+
assert.ok(text && text.type === "text", "read result carries text");
|
|
216
|
+
const block = /^(--- READ WINDOW ---\n(?:[a-z_]+: [^\n]*\n)+chars: \[(\d+),(\d+)\) of (\d+)\nnext_offset_chars: (null|\d+)\n)\n/.exec(text.text);
|
|
217
|
+
assert.ok(block, "raw read carries one READ WINDOW block followed by exactly one blank line");
|
|
218
|
+
const header = block[1];
|
|
219
|
+
const content = text.text.slice(block[0].length);
|
|
220
|
+
const offset_chars = Number(block[2]);
|
|
221
|
+
const end = Number(block[3]);
|
|
222
|
+
const total_chars = Number(block[4]);
|
|
223
|
+
const next_offset_chars = block[5] === "null" ? null : Number(block[5]);
|
|
224
|
+
assert.equal(Array.from(content).length, end - offset_chars, "READ WINDOW range matches the delivered payload");
|
|
225
|
+
return { header, content, offset_chars, total_chars, next_offset_chars, details: (result.details ?? {}) };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
|
|
229
|
+
* and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
|
|
230
|
+
*/
|
|
231
|
+
export function assertLocalIso(value, epochMs, message) {
|
|
232
|
+
assert.equal(typeof value, "string", message);
|
|
233
|
+
assert.match(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/, message);
|
|
234
|
+
assert.equal(Date.parse(value), epochMs, `${message}: Date.parse restores the stored epoch ms`);
|
|
235
|
+
}
|
|
236
|
+
/** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
|
|
237
|
+
export function assertIsoTimestamp(text, message) {
|
|
238
|
+
const match = text.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}/);
|
|
239
|
+
assert.ok(match, message);
|
|
240
|
+
assert.equal(Number.isNaN(Date.parse(match[0])), false, `${message}: timestamp parses`);
|
|
241
|
+
return match[0];
|
|
242
|
+
}
|
|
243
|
+
/** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
|
|
244
|
+
export function assertTruncationOf(original, actual) {
|
|
245
|
+
const match = actual.match(/^([\s\S]*)…\[truncated \d+ chars\]…([\s\S]*)$/);
|
|
246
|
+
assert.ok(match, "truncated value carries the middle-truncation marker");
|
|
247
|
+
const head = match[1];
|
|
248
|
+
const tail = match[2];
|
|
249
|
+
assert.ok(original.startsWith(head), "truncation keeps the original head");
|
|
250
|
+
assert.ok(original.endsWith(tail), "truncation keeps the original tail");
|
|
251
|
+
assert.ok(head.length + tail.length < original.length, "truncation actually removes characters");
|
|
252
|
+
}
|
|
253
|
+
export async function runManualCompact(captured, ctx) {
|
|
254
|
+
const handler = captured.handlers.get("session_before_compact")?.[0];
|
|
255
|
+
assert.ok(handler, "session_before_compact handler registered");
|
|
256
|
+
const event = {
|
|
257
|
+
type: "session_before_compact",
|
|
258
|
+
reason: "manual",
|
|
259
|
+
willRetry: false,
|
|
260
|
+
signal: new AbortController().signal,
|
|
261
|
+
branchEntries: ctx.sessionManager.getBranch(),
|
|
262
|
+
preparation: {
|
|
263
|
+
firstKeptEntryId: "",
|
|
264
|
+
messagesToSummarize: [],
|
|
265
|
+
turnPrefixMessages: [],
|
|
266
|
+
isSplitTurn: false,
|
|
267
|
+
tokensBefore: 0,
|
|
268
|
+
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
|
269
|
+
settings: { enabled: true, reserveTokens: 0, keepRecentTokens: 0 },
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
return (await handler(event, ctx));
|
|
273
|
+
}
|
|
274
|
+
export function runHandlers(captured, name, event, ctx) {
|
|
275
|
+
const isIdle = ctx.isIdle;
|
|
276
|
+
if (name === "agent_settled")
|
|
277
|
+
ctx.isIdle = () => true;
|
|
278
|
+
try {
|
|
279
|
+
for (const handler of captured.handlers.get(name) ?? [])
|
|
280
|
+
handler(event, ctx);
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
ctx.isIdle = isIdle;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function runHandlersAsync(captured, name, event, ctx) {
|
|
287
|
+
const results = [];
|
|
288
|
+
for (const handler of captured.handlers.get(name) ?? [])
|
|
289
|
+
results.push(await handler(event, ctx));
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
export function completeRequestedCompaction(ctx) {
|
|
293
|
+
const requests = ctx.compactionRequests;
|
|
294
|
+
const options = requests.shift();
|
|
295
|
+
assert.ok(options?.onComplete, "a reset request has a completion callback");
|
|
296
|
+
const isIdle = ctx.isIdle;
|
|
297
|
+
ctx.isIdle = () => true;
|
|
298
|
+
try {
|
|
299
|
+
options.onComplete({});
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
ctx.isIdle = isIdle;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export async function runCommand(captured, name, args, ctx) {
|
|
306
|
+
const command = captured.commands.get(name);
|
|
307
|
+
assert.ok(command, `${name} command registered`);
|
|
308
|
+
const notices = [];
|
|
309
|
+
const cmdCtx = Object.assign({}, ctx, {
|
|
310
|
+
waitForIdle: async () => { },
|
|
311
|
+
ui: { notify: (message, type) => notices.push({ message, type }) },
|
|
312
|
+
});
|
|
313
|
+
await command.handler(args, cmdCtx);
|
|
314
|
+
return notices;
|
|
315
|
+
}
|
|
316
|
+
export async function runContextHook(captured, ctx, eventOverride = {}) {
|
|
317
|
+
const handlers = captured.handlers.get("context") ?? [];
|
|
318
|
+
assert.ok(handlers.length > 0, "context handler registered");
|
|
319
|
+
// Pi invokes every registered context handler in order; budget and warning each own one.
|
|
320
|
+
let result;
|
|
321
|
+
for (const handler of handlers) {
|
|
322
|
+
const returned = (await handler({ type: "context", messages: [], ...eventOverride }, ctx));
|
|
323
|
+
if (returned !== undefined) {
|
|
324
|
+
captured.contextMessages.push(...returned.messages);
|
|
325
|
+
result = result ? { messages: [...result.messages, ...returned.messages] } : returned;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
export async function runContextWithSystemHook(captured, ctx, messages) {
|
|
331
|
+
const handlers = captured.handlers.get("context_with_system") ?? [];
|
|
332
|
+
assert.ok(handlers.length > 0, "context_with_system handler registered");
|
|
333
|
+
let result;
|
|
334
|
+
for (const handler of handlers) {
|
|
335
|
+
const returned = (await handler({ type: "context_with_system", messages }, ctx));
|
|
336
|
+
if (returned !== undefined) {
|
|
337
|
+
captured.contextMessages.push(...returned.messages);
|
|
338
|
+
result = result ? { messages: [...result.messages, ...returned.messages] } : returned;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
export async function commitTurnEndBoundary(captured, sessionManager, ctx) {
|
|
344
|
+
let entries = [];
|
|
345
|
+
let shouldContinue = false;
|
|
346
|
+
const event = {
|
|
347
|
+
type: "turn_end",
|
|
348
|
+
entries,
|
|
349
|
+
continue: false,
|
|
350
|
+
context: { contextEntries: [], contextMessages: [], llmMessages: [], pendingMessages: [], canContinue: true },
|
|
351
|
+
outcome: "completed",
|
|
352
|
+
turnIndex: 0,
|
|
353
|
+
message: { role: "assistant", content: [], stopReason: "stop", timestamp: Date.now() },
|
|
354
|
+
toolResults: [],
|
|
355
|
+
messageEntryId: "assistant-entry",
|
|
356
|
+
toolResultEntryIds: [],
|
|
357
|
+
};
|
|
358
|
+
for (const handler of captured.handlers.get("turn_end") ?? []) {
|
|
359
|
+
event.entries = entries;
|
|
360
|
+
const result = (await handler(event, ctx));
|
|
361
|
+
if (result?.entries !== undefined)
|
|
362
|
+
entries = result.entries;
|
|
363
|
+
if (result?.continue !== undefined)
|
|
364
|
+
shouldContinue = result.continue;
|
|
365
|
+
}
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
switch (entry.type) {
|
|
368
|
+
case "custom":
|
|
369
|
+
sessionManager.appendCustomEntry(entry.customType, entry.data);
|
|
370
|
+
break;
|
|
371
|
+
case "custom_message":
|
|
372
|
+
sessionManager.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
373
|
+
break;
|
|
374
|
+
case "compaction":
|
|
375
|
+
sessionManager.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
376
|
+
break;
|
|
377
|
+
case "context_edit":
|
|
378
|
+
sessionManager.appendContextEdit(entry.targetId, entry.replacement);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { entries, continue: shouldContinue };
|
|
383
|
+
}
|
|
384
|
+
export function appendText(sessionManager, role, text, toolName = "bash") {
|
|
385
|
+
const base = {
|
|
386
|
+
role,
|
|
387
|
+
content: [{ type: "text", text }],
|
|
388
|
+
timestamp: Date.now(),
|
|
389
|
+
...(role === "assistant" ? { stopReason: "stop" } : {}),
|
|
390
|
+
...(role === "toolResult" ? { toolCallId: "call-1", toolName, isError: false } : {}),
|
|
391
|
+
};
|
|
392
|
+
return sessionManager.appendMessage(base);
|
|
393
|
+
}
|