@pi-unipi/background-tasks 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,310 @@
1
+ import { afterEach, describe, it, type TestContext } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import {
8
+ AuthStorage,
9
+ createAgentSession,
10
+ DefaultResourceLoader,
11
+ ModelRegistry,
12
+ SessionManager,
13
+ SettingsManager,
14
+ type AgentSession,
15
+ } from '@earendil-works/pi-coding-agent';
16
+ import { parseJsonText } from '../../types.js';
17
+ import { installFusionFakePi } from '../helpers/fusion-fake-pi.js';
18
+ import { isolatedTestEnv } from '../helpers/normalize.js';
19
+
20
+ const backgroundTasksExtensionPath = resolve('extensions/background-tasks.ts');
21
+ const scriptedProviderPath = resolve('tests/scripted-provider/scripted-provider-extension.ts');
22
+ const roots: string[] = [];
23
+ const savedEnv = new Map<string, string | undefined>();
24
+ const envKeys = [
25
+ 'PATH',
26
+ 'PI_CODING_AGENT_DIR',
27
+ 'UNIPI_BG_SCRIPTED_SCENARIO',
28
+ 'UNIPI_BG_SCRIPTED_EVENTS',
29
+ 'UNIPI_BG_SCRIPTED_API_KEY',
30
+ ] as const;
31
+
32
+ type JsonRecord = Record<string, unknown>;
33
+
34
+ interface ProviderEvent extends JsonRecord {
35
+ callCount?: number;
36
+ summaries?: string[];
37
+ }
38
+
39
+ interface Harness {
40
+ session: AgentSession;
41
+ eventsPath: string;
42
+ fakeLogPath: string;
43
+ }
44
+
45
+ function isRecord(value: unknown): value is JsonRecord {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ function rememberEnv(): void {
50
+ if (savedEnv.size > 0) return;
51
+ for (const key of envKeys) savedEnv.set(key, process.env[key]);
52
+ }
53
+
54
+ function restoreEnvValue(key: string, value: string | undefined): void {
55
+ if (value === undefined) {
56
+ Reflect.deleteProperty(process.env, key);
57
+ return;
58
+ }
59
+ process.env[key] = value;
60
+ }
61
+
62
+ function restoreEnv(): void {
63
+ for (const key of envKeys) restoreEnvValue(key, savedEnv.get(key));
64
+ savedEnv.clear();
65
+ }
66
+
67
+ function stringArray(value: unknown): string[] {
68
+ assert.ok(Array.isArray(value), 'value must be an array');
69
+ return value.map((entry) => {
70
+ if (typeof entry !== 'string') throw new Error('entry must be a string');
71
+ return entry;
72
+ });
73
+ }
74
+
75
+ function parseProviderEvent(line: string): ProviderEvent {
76
+ const parsed = parseJsonText(line);
77
+ assert.ok(isRecord(parsed), 'provider event must be an object');
78
+ const event: ProviderEvent = { ...parsed };
79
+ if (parsed['summaries'] !== undefined) event.summaries = stringArray(parsed['summaries']);
80
+ if (typeof parsed['callCount'] === 'number') event.callCount = parsed['callCount'];
81
+ return event;
82
+ }
83
+
84
+ async function providerEvents(path: string): Promise<ProviderEvent[]> {
85
+ if (!existsSync(path)) return [];
86
+ const raw = await readFile(path, 'utf8');
87
+ return raw.trim() ? raw.trim().split('\n').map(parseProviderEvent) : [];
88
+ }
89
+
90
+ async function fakeCallCount(path: string): Promise<number> {
91
+ if (!existsSync(path)) return 0;
92
+ const raw = await readFile(path, 'utf8');
93
+ return raw.trim() ? raw.trim().split('\n').length : 0;
94
+ }
95
+
96
+ async function fakeStages(path: string): Promise<string[]> {
97
+ if (!existsSync(path)) return [];
98
+ const raw = await readFile(path, 'utf8');
99
+ if (!raw.trim()) return [];
100
+ return raw
101
+ .trim()
102
+ .split('\n')
103
+ .map((line) => {
104
+ const parsed = parseJsonText(line);
105
+ assert.ok(isRecord(parsed), 'fake invocation must be an object');
106
+ const stage = parsed['stage'];
107
+ if (typeof stage !== 'string') throw new Error('fake invocation stage must be a string');
108
+ return stage;
109
+ });
110
+ }
111
+
112
+ function assistantTexts(session: AgentSession): string[] {
113
+ return session.sessionManager.getEntries().flatMap((entry) => {
114
+ if (!isRecord(entry) || !isRecord(entry['message']) || entry['message']['role'] !== 'assistant')
115
+ return [];
116
+ const content = entry['message']['content'];
117
+ if (!Array.isArray(content)) return [];
118
+ return content.flatMap((part) =>
119
+ isRecord(part) && part['type'] === 'text' && typeof part['text'] === 'string'
120
+ ? [part['text']]
121
+ : [],
122
+ );
123
+ });
124
+ }
125
+
126
+ function toolResults(session: AgentSession, toolName: string): JsonRecord[] {
127
+ return session.sessionManager.getEntries().flatMap((entry) => {
128
+ if (!isRecord(entry) || !isRecord(entry['message'])) return [];
129
+ const message = entry['message'];
130
+ if (message['role'] === 'toolResult' && message['toolName'] === toolName) return [message];
131
+ return [];
132
+ });
133
+ }
134
+
135
+ async function waitFor(
136
+ predicate: () => boolean | Promise<boolean>,
137
+ label: string,
138
+ timeoutMs = 8000,
139
+ ): Promise<void> {
140
+ const start = Date.now();
141
+ while (Date.now() - start < timeoutMs) {
142
+ if (await predicate()) return;
143
+ await new Promise((resolve) => setTimeout(resolve, 50));
144
+ }
145
+ throw new Error(`Timed out waiting for ${label}`);
146
+ }
147
+
148
+ async function harness(): Promise<Harness> {
149
+ rememberEnv();
150
+ const root = await mkdtemp(join(tmpdir(), 'pi-bg-fusion-scripted-'));
151
+ roots.push(root);
152
+ const cwd = join(root, 'project');
153
+ const agentDir = join(root, 'agent');
154
+ const eventsPath = join(root, 'provider-events.jsonl');
155
+ await mkdir(cwd, { recursive: true });
156
+ await mkdir(agentDir, { recursive: true });
157
+ const fake = await installFusionFakePi(root, {
158
+ mergedText: 'Scripted fused answer.',
159
+ invalidFirstEvaluation: true,
160
+ });
161
+ Object.assign(process.env, isolatedTestEnv, {
162
+ PATH: fake.env['PATH'],
163
+ PI_CODING_AGENT_DIR: agentDir,
164
+ UNIPI_BG_SCRIPTED_SCENARIO: 'fusion-reason',
165
+ UNIPI_BG_SCRIPTED_EVENTS: eventsPath,
166
+ UNIPI_BG_SCRIPTED_API_KEY: 'scripted-api-key',
167
+ NPM_CONFIG_CACHE: join(tmpdir(), 'pi-npm-cache'),
168
+ });
169
+ const settingsManager = SettingsManager.inMemory({
170
+ defaultProvider: 'pi-bg-scripted',
171
+ defaultModel: 'scripted-model',
172
+ });
173
+ const loader = new DefaultResourceLoader({
174
+ cwd,
175
+ agentDir,
176
+ settingsManager,
177
+ additionalExtensionPaths: [scriptedProviderPath, backgroundTasksExtensionPath],
178
+ noExtensions: true,
179
+ noSkills: true,
180
+ noPromptTemplates: true,
181
+ noContextFiles: true,
182
+ noThemes: true,
183
+ });
184
+ await loader.reload();
185
+ const authStorage = AuthStorage.create(join(agentDir, 'auth.json'));
186
+ const modelRegistry = ModelRegistry.create(authStorage);
187
+ const { session } = await createAgentSession({
188
+ cwd,
189
+ agentDir,
190
+ resourceLoader: loader,
191
+ sessionManager: SessionManager.inMemory(cwd),
192
+ settingsManager,
193
+ authStorage,
194
+ modelRegistry,
195
+ noTools: 'builtin',
196
+ });
197
+ const model = modelRegistry.find('pi-bg-scripted', 'scripted-model');
198
+ assert.ok(model, 'scripted model should be registered');
199
+ await session.setModel(model);
200
+ await session.extensionRunner.emit({ type: 'session_start', reason: 'startup' });
201
+ return { session, eventsPath, fakeLogPath: fake.logPath };
202
+ }
203
+
204
+ async function disposeHarness(h: Harness): Promise<void> {
205
+ try {
206
+ await h.session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' });
207
+ } finally {
208
+ h.session.dispose();
209
+ }
210
+ }
211
+
212
+ afterEach(async () => {
213
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
214
+ restoreEnv();
215
+ });
216
+
217
+ void describe('scripted provider fusion_reason integration', { concurrency: false }, () => {
218
+ void it(
219
+ 'lets the parent launch Fusion, avoid polling, and consume the verified bg_result after wake',
220
+ { timeout: 15_000 },
221
+ async (t: TestContext) => {
222
+ // This case intercepts the Pi child by placing a fake `pi` on PATH. That
223
+ // cannot work on win32, where production deliberately resolves the Pi
224
+ // package and launches it through Node rather than consulting PATH, and
225
+ // the extension layer exposes no launch seam to redirect it. The same
226
+ // constraint host-gates the equivalent cases in tests/sdk/fusion-sdk.test.ts.
227
+ // Fusion orchestration itself stays covered on Windows by the injected
228
+ // childRunner cases in tests/unit/fusion-orchestrator.test.ts.
229
+ if (process.platform === 'win32') {
230
+ t.skip(
231
+ 'fake Pi PATH interception is not applicable on win32 because production resolves the Pi package instead of PATH by design',
232
+ );
233
+ return;
234
+ }
235
+ const h = await harness();
236
+ try {
237
+ await h.session.prompt('Use fusion for this scripted task.');
238
+ await waitFor(
239
+ async () => (await providerEvents(h.eventsPath)).length >= 4,
240
+ 'fourth parent provider call after background completion and retrieval',
241
+ 15_000,
242
+ );
243
+ await h.session.agent.waitForIdle();
244
+ assert.equal(await fakeCallCount(h.fakeLogPath), 6);
245
+ assert.deepEqual(
246
+ (await fakeStages(h.fakeLogPath)).sort(),
247
+ [
248
+ 'candidate',
249
+ 'candidate',
250
+ 'candidate',
251
+ 'evaluation',
252
+ 'evaluation-repair',
253
+ 'merge',
254
+ ].sort(),
255
+ );
256
+
257
+ const events = await providerEvents(h.eventsPath);
258
+ assert.equal(events.length, 4);
259
+ assert.match((events[0]?.summaries ?? []).join('\n'), /user:Use fusion/);
260
+ assert.match(
261
+ (events[1]?.summaries ?? []).join('\n'),
262
+ /toolResult:fusion_reason:Started fusion reason in the background/,
263
+ );
264
+ assert.doesNotMatch(
265
+ (events[1]?.summaries ?? []).join('\n'),
266
+ /toolResult:bg_status|toolResult:bg_logs/,
267
+ );
268
+ assert.match(
269
+ (events[2]?.summaries ?? []).join('\n'),
270
+ /background-task-notification|<task-id>reason-/,
271
+ );
272
+ assert.match(
273
+ (events[3]?.summaries ?? []).join('\n'),
274
+ /toolResult:bg_result:.*Scripted fused answer/s,
275
+ );
276
+ assert.ok(
277
+ assistantTexts(h.session).some((text) =>
278
+ text.includes('Parent observed verified Fusion result'),
279
+ ),
280
+ );
281
+
282
+ const launchResults = toolResults(h.session, 'fusion_reason');
283
+ assert.equal(launchResults.length, 1);
284
+ const launchResult = launchResults[0];
285
+ assert.ok(launchResult, 'Fusion launch result should be persisted');
286
+ const launchDetails = launchResult['details'];
287
+ assert.ok(isRecord(launchDetails));
288
+ assert.equal(launchDetails['schema_version'], 'pi-background-tasks.fusion-launch.v1');
289
+ assert.equal(Reflect.get(launchResult, 'usage'), undefined);
290
+
291
+ const retrievedResults = toolResults(h.session, 'bg_result');
292
+ assert.equal(retrievedResults.length, 1);
293
+ const retrieved = retrievedResults[0];
294
+ assert.ok(retrieved);
295
+ const content = retrieved['content'];
296
+ assert.ok(Array.isArray(content));
297
+ assert.match(
298
+ String(isRecord(content[0]) ? content[0]['text'] : ''),
299
+ /Scripted fused answer/,
300
+ );
301
+ const details = retrieved['details'];
302
+ assert.ok(isRecord(details));
303
+ assert.equal(details['schema_version'], 'pi-background-tasks.fusion-result-view.v1');
304
+ assert.equal(isRecord(details) ? details['usage_delivered'] : undefined, true);
305
+ } finally {
306
+ await disposeHarness(h);
307
+ }
308
+ },
309
+ );
310
+ });
@@ -0,0 +1,163 @@
1
+ import { afterEach, describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer, type Server } from 'node:http';
4
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import {
8
+ AuthStorage,
9
+ createAgentSession,
10
+ DefaultResourceLoader,
11
+ ModelRegistry,
12
+ SessionManager,
13
+ SettingsManager,
14
+ } from '@earendil-works/pi-coding-agent';
15
+ import { isolatedTestEnv } from '../helpers/normalize.js';
16
+ import { RUNTIME_GUARD_MODEL, RUNTIME_GUARD_PROVIDER } from './runtime-guard-provider.js';
17
+
18
+ const providerPath = resolve('tests/scripted-provider/runtime-guard-provider.ts');
19
+ const governorPath = resolve('tests/scripted-provider/runtime-guard-probe.ts');
20
+ const roots: string[] = [];
21
+ const servers: Server[] = [];
22
+
23
+ function restoreEnvValue(key: string, value: string | undefined): void {
24
+ if (value === undefined) delete process.env[key];
25
+ else process.env[key] = value;
26
+ }
27
+
28
+ async function listen(server: Server): Promise<number> {
29
+ await new Promise<void>((resolveListen, reject) => {
30
+ server.once('error', reject);
31
+ server.listen(0, '127.0.0.1', () => {
32
+ server.off('error', reject);
33
+ resolveListen();
34
+ });
35
+ });
36
+ const address = server.address();
37
+ assert.ok(address && typeof address === 'object');
38
+ return address.port;
39
+ }
40
+
41
+ async function close(server: Server): Promise<void> {
42
+ await new Promise<void>((resolveClose, reject) => {
43
+ server.close((error) => {
44
+ if (error) reject(error);
45
+ else resolveClose();
46
+ });
47
+ });
48
+ }
49
+
50
+ afterEach(async () => {
51
+ for (const server of servers.splice(0)) await close(server);
52
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
53
+ });
54
+
55
+ void describe('Fusion final provider-request guard contract', { concurrency: false }, () => {
56
+ void it(
57
+ 'observes the final transformed payload and aborts before network transport',
58
+ { timeout: 20_000 },
59
+ async () => {
60
+ let httpRequests = 0;
61
+ const server = createServer((_request, response) => {
62
+ httpRequests += 1;
63
+ response.writeHead(500, { 'content-type': 'application/json' });
64
+ response.end(JSON.stringify({ error: { message: 'the guard failed to block transport' } }));
65
+ });
66
+ servers.push(server);
67
+ const port = await listen(server);
68
+
69
+ const root = await mkdtemp(join(tmpdir(), 'pi-bg-fusion-runtime-guard-'));
70
+ roots.push(root);
71
+ const cwd = join(root, 'project');
72
+ const agentDir = join(root, 'agent');
73
+ const logPath = join(root, 'guard.jsonl');
74
+ await mkdir(cwd, { recursive: true });
75
+ await mkdir(agentDir, { recursive: true });
76
+ await writeFile(logPath, '', 'utf8');
77
+
78
+ const previous = {
79
+ baseUrl: process.env['UNIPI_BG_RUNTIME_GUARD_BASE_URL'],
80
+ log: process.env['UNIPI_BG_RUNTIME_GUARD_LOG'],
81
+ apiKey: process.env['UNIPI_BG_RUNTIME_GUARD_API_KEY'],
82
+ };
83
+ const codexToken = [
84
+ Buffer.from('{}', 'utf8').toString('base64url'),
85
+ Buffer.from(
86
+ JSON.stringify({
87
+ 'https://api.openai.com/auth': { chatgpt_account_id: 'runtime-guard-test-account' },
88
+ }),
89
+ 'utf8',
90
+ ).toString('base64url'),
91
+ 'test-signature',
92
+ ].join('.');
93
+ Object.assign(process.env, isolatedTestEnv, {
94
+ UNIPI_BG_RUNTIME_GUARD_BASE_URL: `http://127.0.0.1:${String(port)}/v1`,
95
+ UNIPI_BG_RUNTIME_GUARD_LOG: logPath,
96
+ UNIPI_BG_RUNTIME_GUARD_API_KEY: codexToken,
97
+ });
98
+
99
+ let session: Awaited<ReturnType<typeof createAgentSession>>['session'] | undefined;
100
+ try {
101
+ const settingsManager = SettingsManager.inMemory({
102
+ defaultProvider: RUNTIME_GUARD_PROVIDER,
103
+ defaultModel: RUNTIME_GUARD_MODEL,
104
+ });
105
+ const loader = new DefaultResourceLoader({
106
+ cwd,
107
+ agentDir,
108
+ settingsManager,
109
+ // The provider mutator must load before the governor.
110
+ additionalExtensionPaths: [providerPath, governorPath],
111
+ noExtensions: true,
112
+ noSkills: true,
113
+ noPromptTemplates: true,
114
+ noContextFiles: true,
115
+ noThemes: true,
116
+ });
117
+ await loader.reload();
118
+ const authStorage = AuthStorage.create(join(agentDir, 'auth.json'));
119
+ const registry = ModelRegistry.create(authStorage);
120
+ ({ session } = await createAgentSession({
121
+ cwd,
122
+ agentDir,
123
+ resourceLoader: loader,
124
+ sessionManager: SessionManager.inMemory(cwd),
125
+ settingsManager,
126
+ authStorage,
127
+ modelRegistry: registry,
128
+ noTools: 'builtin',
129
+ }));
130
+ const model = registry.find(RUNTIME_GUARD_PROVIDER, RUNTIME_GUARD_MODEL);
131
+ assert.ok(model);
132
+ await session.setModel(model);
133
+ await session.prompt('prove the final request guard blocks transport');
134
+ await session.agent.waitForIdle();
135
+
136
+ const rows = (await readFile(logPath, 'utf8'))
137
+ .trim()
138
+ .split('\n')
139
+ .filter(Boolean)
140
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
141
+ assert.deepEqual(
142
+ rows.map((row) => row['hook']),
143
+ ['mutator', 'governor'],
144
+ `provider-request handlers must run exactly once in extension load order; session=${JSON.stringify(session.sessionManager.getEntries())}`,
145
+ );
146
+ assert.equal(rows[1]?.['marker_seen'], true, 'the governor must observe prior transforms');
147
+ assert.equal(rows[1]?.['provider'], RUNTIME_GUARD_PROVIDER);
148
+ assert.equal(rows[1]?.['model'], RUNTIME_GUARD_MODEL);
149
+ assert.equal(typeof rows[1]?.['payload_bytes'], 'number');
150
+ assert.match(String(rows[1]?.['payload_sha256']), /^[0-9a-f]{64}$/);
151
+ assert.equal(httpRequests, 0, 'ctx.abort() must prevent the HTTP request from being sent');
152
+ } finally {
153
+ if (session) {
154
+ await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' });
155
+ session.dispose();
156
+ }
157
+ restoreEnvValue('UNIPI_BG_RUNTIME_GUARD_BASE_URL', previous.baseUrl);
158
+ restoreEnvValue('UNIPI_BG_RUNTIME_GUARD_LOG', previous.log);
159
+ restoreEnvValue('UNIPI_BG_RUNTIME_GUARD_API_KEY', previous.apiKey);
160
+ }
161
+ },
162
+ );
163
+ });
@@ -0,0 +1,179 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
+ import {
4
+ createAssistantMessageEventStream,
5
+ type Api,
6
+ type AssistantMessage,
7
+ type AssistantMessageEventStream,
8
+ type Context,
9
+ type Message,
10
+ type Model,
11
+ type TextContent,
12
+ type ToolCall,
13
+ } from '@earendil-works/pi-ai';
14
+
15
+ /**
16
+ * Deterministic provider used only by the Pi hook-contract characterisation
17
+ * gate. It records the exact LLM context it was handed for every call so the
18
+ * test can prove whether a `context` handler's returned messages actually reach
19
+ * the provider, and whether a call was dispatched at all.
20
+ */
21
+ const PROVIDER = 'pi-bg-hook-contract';
22
+ const MODEL_ID = 'hook-contract-model';
23
+ const API = 'pi-bg-hook-contract-api';
24
+
25
+ const USAGE = {
26
+ input: 7,
27
+ output: 3,
28
+ cacheRead: 0,
29
+ cacheWrite: 0,
30
+ totalTokens: 10,
31
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
32
+ };
33
+
34
+ type JsonObject = Record<PropertyKey, unknown>;
35
+
36
+ interface ScriptedToolCall extends Omit<ToolCall, 'arguments'> {
37
+ arguments: JsonObject;
38
+ }
39
+
40
+ type ScriptedBlock = TextContent | ScriptedToolCall;
41
+
42
+ interface ScriptedAssistantMessage extends Omit<AssistantMessage, 'content' | 'stopReason'> {
43
+ content: ScriptedBlock[];
44
+ stopReason: 'stop' | 'toolUse';
45
+ }
46
+
47
+ function record(event: JsonObject): void {
48
+ const path = process.env['UNIPI_BG_HOOK_PROBE_LOG'];
49
+ if (!path) return;
50
+ appendFileSync(path, `${JSON.stringify(event)}\n`, 'utf8');
51
+ }
52
+
53
+ function messageText(message: Message): string {
54
+ if (typeof message.content === 'string') return message.content;
55
+ if (!Array.isArray(message.content)) return '';
56
+ return message.content.map((part) => ('text' in part ? part.text : `<${part.type}>`)).join('');
57
+ }
58
+
59
+ function assistant(
60
+ content: ScriptedBlock[],
61
+ stopReason: 'stop' | 'toolUse',
62
+ ): ScriptedAssistantMessage {
63
+ return {
64
+ role: 'assistant',
65
+ content,
66
+ api: API,
67
+ provider: PROVIDER,
68
+ model: MODEL_ID,
69
+ usage: USAGE,
70
+ stopReason,
71
+ timestamp: Date.now(),
72
+ };
73
+ }
74
+
75
+ function pushMessage(stream: AssistantMessageEventStream, message: ScriptedAssistantMessage): void {
76
+ const partial: AssistantMessage = { ...message, content: [] };
77
+ stream.push({ type: 'start', partial: { ...partial } });
78
+ message.content.forEach((block, contentIndex) => {
79
+ if (block.type === 'text') {
80
+ const partialText: TextContent = { type: 'text', text: '' };
81
+ partial.content = [...partial.content, partialText];
82
+ stream.push({ type: 'text_start', contentIndex, partial: { ...partial } });
83
+ partialText.text = block.text;
84
+ stream.push({ type: 'text_delta', contentIndex, delta: block.text, partial: { ...partial } });
85
+ stream.push({ type: 'text_end', contentIndex, content: block.text, partial: { ...partial } });
86
+ return;
87
+ }
88
+ const partialToolCall: ToolCall = {
89
+ type: 'toolCall',
90
+ id: block.id,
91
+ name: block.name,
92
+ arguments: {},
93
+ };
94
+ partial.content = [...partial.content, partialToolCall];
95
+ stream.push({ type: 'toolcall_start', contentIndex, partial: { ...partial } });
96
+ stream.push({
97
+ type: 'toolcall_delta',
98
+ contentIndex,
99
+ delta: JSON.stringify(block.arguments),
100
+ partial: { ...partial },
101
+ });
102
+ partialToolCall.arguments = block.arguments;
103
+ stream.push({ type: 'toolcall_end', contentIndex, toolCall: block, partial: { ...partial } });
104
+ });
105
+ stream.push({ type: 'done', reason: message.stopReason, message });
106
+ stream.end(message);
107
+ }
108
+
109
+ const ProbeEchoParams = {
110
+ type: 'object',
111
+ properties: { value: { type: 'string' } },
112
+ required: ['value'],
113
+ additionalProperties: false,
114
+ } as const;
115
+
116
+ export default function hookContractProviderExtension(pi: ExtensionAPI): void {
117
+ let providerCalls = 0;
118
+
119
+ pi.registerTool<typeof ProbeEchoParams, { value: string }>({
120
+ name: 'probe_echo',
121
+ label: 'Probe Echo',
122
+ description: 'Hook-contract characterisation echo tool.',
123
+ parameters: ProbeEchoParams,
124
+ execute(_toolCallId, input) {
125
+ return Promise.resolve({
126
+ content: [{ type: 'text' as const, text: `ORIGINAL_TOOL_PAYLOAD:${input.value}` }],
127
+ details: { value: input.value },
128
+ });
129
+ },
130
+ });
131
+
132
+ pi.registerProvider(PROVIDER, {
133
+ name: 'Pi Background Tasks Hook Contract Provider',
134
+ baseUrl: 'http://localhost:0',
135
+ apiKey: 'UNIPI_BG_HOOK_CONTRACT_API_KEY',
136
+ api: API,
137
+ models: [
138
+ {
139
+ id: MODEL_ID,
140
+ name: 'Hook Contract Model',
141
+ reasoning: false,
142
+ input: ['text'],
143
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
144
+ contextWindow: 200_000,
145
+ maxTokens: 4096,
146
+ },
147
+ ],
148
+ streamSimple(
149
+ _model: Model<Api>,
150
+ context: Context,
151
+ options?: { signal?: AbortSignal | undefined },
152
+ ): AssistantMessageEventStream {
153
+ providerCalls += 1;
154
+ record({
155
+ hook: 'provider_call',
156
+ providerCalls,
157
+ // Records whether Pi handed this call an already-aborted signal. A
158
+ // conforming provider must not send the request in that state.
159
+ signalAborted: options?.signal?.aborted === true,
160
+ roles: context.messages.map((message) => message.role),
161
+ texts: context.messages.map(messageText),
162
+ });
163
+ const stream = createAssistantMessageEventStream();
164
+ const wantsTool = providerCalls === 1 && process.env['UNIPI_BG_HOOK_PROBE_TOOL'] === '1';
165
+ queueMicrotask(() => {
166
+ pushMessage(
167
+ stream,
168
+ wantsTool
169
+ ? assistant(
170
+ [{ type: 'toolCall', id: 'probe-call-1', name: 'probe_echo', arguments: { value: 'seed' } }],
171
+ 'toolUse',
172
+ )
173
+ : assistant([{ type: 'text', text: `probe answer ${String(providerCalls)}` }], 'stop'),
174
+ );
175
+ });
176
+ return stream;
177
+ },
178
+ });
179
+ }
@@ -0,0 +1,3 @@
1
+ import { createHookProbeExtension } from './hook-probe-extension.js';
2
+
3
+ export default createHookProbeExtension('probe-a');
@@ -0,0 +1,3 @@
1
+ import { createHookProbeExtension } from './hook-probe-extension.js';
2
+
3
+ export default createHookProbeExtension('probe-b');