@pi-unipi/background-tasks 2.6.2 → 2.6.4

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 (59) hide show
  1. package/package.json +3 -2
  2. package/src/tools.ts +3 -1
  3. package/src/__tests__/anthropic-attribution.test.ts +0 -195
  4. package/src/__tests__/config.test.ts +0 -137
  5. package/src/__tests__/core.test.ts +0 -493
  6. package/src/__tests__/delegate-artifacts.test.ts +0 -528
  7. package/src/__tests__/delegate-budget.test.ts +0 -456
  8. package/src/__tests__/delegate-launch.test.ts +0 -676
  9. package/src/__tests__/delegate-result-package.test.ts +0 -350
  10. package/src/__tests__/delegate-seed.test.ts +0 -392
  11. package/src/__tests__/durable-fs.test.ts +0 -559
  12. package/src/__tests__/extension-api.test.ts +0 -579
  13. package/src/__tests__/fusion-artifacts.test.ts +0 -1039
  14. package/src/__tests__/fusion-budget.test.ts +0 -1356
  15. package/src/__tests__/fusion-claude-cache.test.ts +0 -320
  16. package/src/__tests__/fusion-config.test.ts +0 -335
  17. package/src/__tests__/fusion-context-prompts.test.ts +0 -670
  18. package/src/__tests__/fusion-evaluation.test.ts +0 -315
  19. package/src/__tests__/fusion-extraction-equivalence.test.ts +0 -58
  20. package/src/__tests__/fusion-golden-bytes.test.ts +0 -35
  21. package/src/__tests__/fusion-high-cardinality.test.ts +0 -192
  22. package/src/__tests__/fusion-model-selector.test.ts +0 -205
  23. package/src/__tests__/fusion-orchestrator.test.ts +0 -1194
  24. package/src/__tests__/fusion-rpc.test.ts +0 -369
  25. package/src/__tests__/fusion-sdk.test.ts +0 -1226
  26. package/src/__tests__/fusion-v5-core.test.ts +0 -219
  27. package/src/__tests__/fusion-validate-orchestrator.test.ts +0 -240
  28. package/src/__tests__/fusion-web-fetch.test.ts +0 -485
  29. package/src/__tests__/fusion-workflows.test.ts +0 -59
  30. package/src/__tests__/helpers/delegate-deterministic-seed.ts +0 -109
  31. package/src/__tests__/helpers/delegate-seed-subprocess.ts +0 -10
  32. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +0 -21
  33. package/src/__tests__/helpers/fusion-canonical.ts +0 -140
  34. package/src/__tests__/helpers/fusion-fake-pi.ts +0 -279
  35. package/src/__tests__/helpers/fusion-golden-corpus.ts +0 -500
  36. package/src/__tests__/helpers/fusion-high-cardinality.ts +0 -140
  37. package/src/__tests__/helpers/normalize.ts +0 -22
  38. package/src/__tests__/helpers/pi-hook-contract-evidence.json +0 -18
  39. package/src/__tests__/pi-launch.test.ts +0 -202
  40. package/src/__tests__/registry.test.ts +0 -1580
  41. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +0 -130
  42. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +0 -631
  43. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +0 -403
  44. package/src/__tests__/scripted-provider/follow-up.test.ts +0 -448
  45. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +0 -132
  46. package/src/__tests__/scripted-provider/fusion-reason.test.ts +0 -310
  47. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +0 -163
  48. package/src/__tests__/scripted-provider/hook-contract-provider.ts +0 -179
  49. package/src/__tests__/scripted-provider/hook-probe-a.ts +0 -3
  50. package/src/__tests__/scripted-provider/hook-probe-b.ts +0 -3
  51. package/src/__tests__/scripted-provider/hook-probe-extension.ts +0 -126
  52. package/src/__tests__/scripted-provider/output-recovery-provider.ts +0 -153
  53. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +0 -18
  54. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +0 -477
  55. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +0 -28
  56. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +0 -49
  57. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +0 -408
  58. package/src/__tests__/task-manager.test.ts +0 -479
  59. package/src/__tests__/windows-taskkill.test.ts +0 -161
@@ -1,369 +0,0 @@
1
- import { describe, it, type TestContext } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
4
- import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5
- import { existsSync } from 'node:fs';
6
- import { delimiter, join, resolve } from 'node:path';
7
- import { tmpdir } from 'node:os';
8
- import { parseJsonText } from '../types.js';
9
- import { installFusionFakePi, resolveRealPiCli } from './helpers/fusion-fake-pi.js';
10
- import { piLaunchArgv, resolvePiLaunch } from '../pi-launch.js';
11
-
12
- // npm installs `pi` as a pi.cmd shim on Windows, and a shell-less spawn does not
13
- // consult PATHEXT, so spawning the bare name fails with ENOENT. Production resolves
14
- // the Pi package bin and launches it through Node; reusing that resolver keeps this
15
- // harness aligned with real launch behaviour on every platform.
16
- const piLaunch = resolvePiLaunch();
17
- import { isolatedTestEnv } from './helpers/normalize.js';
18
-
19
- const backgroundTasksExtensionPath = resolve('src/index.ts');
20
- const scriptedProviderPath = resolve('src/__tests__/scripted-provider/scripted-provider-extension.ts');
21
-
22
- type JsonRecord = Record<string, unknown>;
23
-
24
- interface Pending {
25
- resolve: (event: JsonRecord) => void;
26
- reject: (error: Error) => void;
27
- timer: NodeJS.Timeout;
28
- }
29
-
30
- interface FusionFakeInvocation {
31
- stage: string;
32
- stdin: string;
33
- args: string[];
34
- }
35
-
36
- function skipWin32FusionRpcPiPathFixture(t: TestContext): boolean {
37
- if (process.platform !== 'win32') return false;
38
- t.skip(
39
- 'RPC fake Pi PATH interception is not applicable on win32 because production resolves fusion children through the Pi package instead of PATH by design',
40
- );
41
- return true;
42
- }
43
-
44
- function isRecord(value: unknown): value is JsonRecord {
45
- return typeof value === 'object' && value !== null && !Array.isArray(value);
46
- }
47
-
48
- function field(value: JsonRecord, key: string): unknown {
49
- return value[key];
50
- }
51
-
52
- function requireString(value: unknown, label: string): string {
53
- if (typeof value !== 'string') throw new Error(`${label} must be a string`);
54
- return value;
55
- }
56
-
57
- function parseJsonRecord(text: string): JsonRecord {
58
- const parsed = parseJsonText(text);
59
- assert.ok(isRecord(parsed), 'JSONL record must be an object');
60
- return parsed;
61
- }
62
-
63
- function commandNames(event: JsonRecord): string[] {
64
- const data = field(event, 'data');
65
- assert.ok(isRecord(data), 'response data must be an object');
66
- const commands = data['commands'];
67
- assert.ok(Array.isArray(commands), 'commands must be an array');
68
- return commands.map((command) => {
69
- assert.ok(isRecord(command), 'command must be an object');
70
- return requireString(command['name'], 'command name');
71
- });
72
- }
73
-
74
- function parseInvocation(line: string): FusionFakeInvocation {
75
- const parsed = parseJsonRecord(line);
76
- const args = parsed['args'];
77
- assert.ok(Array.isArray(args), 'invocation args must be an array');
78
- return {
79
- stage: requireString(parsed['stage'], 'stage'),
80
- stdin: requireString(parsed['stdin'], 'stdin'),
81
- args: args.map((value) => requireString(value, 'arg')),
82
- };
83
- }
84
-
85
- async function readInvocations(path: string): Promise<FusionFakeInvocation[]> {
86
- if (!existsSync(path)) return [];
87
- const raw = await readFile(path, 'utf8');
88
- return raw.trim() ? raw.trim().split('\n').map(parseInvocation) : [];
89
- }
90
-
91
- class FusionRpc {
92
- readonly events: JsonRecord[] = [];
93
- readonly pending = new Map<string, Pending>();
94
- private buffer = '';
95
- private seq = 0;
96
- private stderr = '';
97
- readonly proc: ChildProcessWithoutNullStreams;
98
-
99
- constructor(cwd: string, env: NodeJS.ProcessEnv) {
100
- this.proc = spawn(
101
- piLaunch.executable,
102
- piLaunchArgv(piLaunch, [
103
- '--mode',
104
- 'rpc',
105
- '--no-session',
106
- '--offline',
107
- '--no-extensions',
108
- '-e',
109
- scriptedProviderPath,
110
- '-e',
111
- backgroundTasksExtensionPath,
112
- '--no-skills',
113
- '--no-prompt-templates',
114
- '--no-context-files',
115
- '--no-tools',
116
- '--model',
117
- 'pi-bg-scripted/scripted-model',
118
- ]),
119
- { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
120
- );
121
- this.proc.stdout.on('data', (chunk: Buffer) => {
122
- this.onData(chunk.toString('utf8'));
123
- });
124
- this.proc.stderr.on('data', (chunk: Buffer) => {
125
- this.stderr += chunk.toString('utf8');
126
- });
127
- }
128
-
129
- send(command: JsonRecord): Promise<JsonRecord> {
130
- this.seq += 1;
131
- const id = `fusion-rpc-${String(this.seq)}`;
132
- return new Promise((resolve, reject) => {
133
- const timer = setTimeout(() => {
134
- reject(new Error(this.stderr || `RPC timeout for ${JSON.stringify(command)}`));
135
- }, 20_000);
136
- this.pending.set(id, { resolve, reject, timer });
137
- this.proc.stdin.write(`${JSON.stringify({ ...command, id })}\n`);
138
- });
139
- }
140
-
141
- sendUiResponse(id: string, value: string): void {
142
- this.proc.stdin.write(`${JSON.stringify({ type: 'extension_ui_response', id, value })}\n`);
143
- }
144
-
145
- async wait(predicate: (event: JsonRecord) => boolean, timeoutMs = 20_000): Promise<JsonRecord> {
146
- const start = Date.now();
147
- while (Date.now() - start < timeoutMs) {
148
- const found = this.events.find(predicate);
149
- if (found !== undefined) return found;
150
- await new Promise((resolve) => setTimeout(resolve, 50));
151
- }
152
- throw new Error(
153
- `timeout ${this.stderr}\nEvents: ${JSON.stringify(this.events.slice(-12), null, 2)}`,
154
- );
155
- }
156
-
157
- stop(): Promise<void> {
158
- this.proc.kill('SIGTERM');
159
- return Promise.resolve();
160
- }
161
-
162
- private onData(chunk: string): void {
163
- this.buffer += chunk;
164
- let newline = this.buffer.indexOf('\n');
165
- while (newline >= 0) {
166
- const line = this.buffer.slice(0, newline);
167
- this.buffer = this.buffer.slice(newline + 1);
168
- if (line.length > 0) this.onLine(line.endsWith('\r') ? line.slice(0, -1) : line);
169
- newline = this.buffer.indexOf('\n');
170
- }
171
- }
172
-
173
- private onLine(line: string): void {
174
- const event = parseJsonRecord(line);
175
- this.events.push(event);
176
- if (event['type'] !== 'response' || typeof event['id'] !== 'string') return;
177
- const pending = this.pending.get(event['id']);
178
- if (pending === undefined) return;
179
- this.pending.delete(event['id']);
180
- clearTimeout(pending.timer);
181
- pending.resolve(event);
182
- }
183
- }
184
-
185
- interface FusionRpcHarnessOptions {
186
- configText?: string | undefined;
187
- failStage?: 'candidate' | 'evaluation' | 'evaluation-repair' | 'merge' | undefined;
188
- }
189
-
190
- async function withRpc(
191
- fn: (rpc: FusionRpc, fakeLogPath: string) => Promise<void>,
192
- options: FusionRpcHarnessOptions = {},
193
- ): Promise<void> {
194
- const realPi = resolveRealPiCli();
195
- if (realPi === undefined) {
196
- const check = spawnSync('bash', ['-lc', 'command -v pi'], { encoding: 'utf8' });
197
- assert.fail(`pi CLI is required for fusion RPC tests: ${check.stderr || check.stdout}`);
198
- }
199
- const root = await mkdtemp(join(tmpdir(), 'pi-bg-fusion-rpc-'));
200
- const cwd = join(root, 'project');
201
- const agentDir = join(root, 'agent');
202
- await mkdir(cwd, { recursive: true });
203
- await mkdir(agentDir, { recursive: true });
204
- if (options.configText !== undefined) {
205
- await mkdir(agentDir, { recursive: true });
206
- await writeFile(join(agentDir, 'fusion-models.json'), options.configText, 'utf8');
207
- }
208
- const fake = await installFusionFakePi(root, {
209
- delegatePi: realPi,
210
- mergedText: 'RPC fused answer.',
211
- failStage: options.failStage,
212
- });
213
- const env: NodeJS.ProcessEnv = {
214
- ...process.env,
215
- ...isolatedTestEnv,
216
- PATH: `${fake.binDir}${delimiter}${process.env['PATH'] ?? ''}`,
217
- PI_CODING_AGENT_DIR: agentDir,
218
- UNIPI_BG_SCRIPTED_API_KEY: 'scripted-api-key',
219
- UNIPI_BG_SCRIPTED_SCENARIO: 'display-only-bg',
220
- NPM_CONFIG_CACHE: '/tmp/pi-npm-cache',
221
- };
222
- const rpc = new FusionRpc(cwd, env);
223
- try {
224
- await fn(rpc, fake.logPath);
225
- } finally {
226
- await rpc.stop();
227
- await rm(root, { recursive: true, force: true });
228
- }
229
- }
230
-
231
- function fusionTerminalMessage(status: 'completed' | 'failed'): (event: JsonRecord) => boolean {
232
- return (event) => {
233
- if (event['type'] !== 'message_end') return false;
234
- const message = event['message'];
235
- if (!isRecord(message) || message['customType'] !== 'background-task-notification')
236
- return false;
237
- const details = message['details'];
238
- return isRecord(details) && isRecord(details['fusion']) && details['status'] === status;
239
- };
240
- }
241
-
242
- function notifyWith(text: RegExp): (event: JsonRecord) => boolean {
243
- return (event) => {
244
- if (event['type'] !== 'extension_ui_request' || event['method'] !== 'notify') return false;
245
- return text.test(String(event['message'] ?? ''));
246
- };
247
- }
248
-
249
- void describe('fusion RPC integration', () => {
250
- void it('discovers commands and runs /fusion through deterministic child Pi without parent rewrite', async (t) => {
251
- if (skipWin32FusionRpcPiPathFixture(t)) return;
252
- await withRpc(async (rpc, fakeLogPath) => {
253
- const commands = await rpc.send({ type: 'get_commands' });
254
- assert.equal(commands['success'], true);
255
- const names = commandNames(commands);
256
- assert.ok(names.includes('unipi:fusion'));
257
- assert.ok(names.includes('unipi:fusion-models'));
258
-
259
- const promptText = '/unipi:fusion rpc prompt with separators \u2028 and \u2029 kept';
260
- const response = await rpc.send({ type: 'prompt', message: promptText });
261
- assert.equal(response['success'], true);
262
- await rpc.wait(fusionTerminalMessage('completed'));
263
- assert.equal(
264
- rpc.events.some((event) => event['type'] === 'agent_start'),
265
- false,
266
- );
267
- const calls = await readInvocations(fakeLogPath);
268
- assert.equal(calls.length, 5);
269
- const candidate = calls.find((call) => call.stage === 'candidate');
270
- assert.ok(candidate, 'candidate call should be logged');
271
- const input = parseJsonRecord(candidate.stdin);
272
- const request = input['request'];
273
- assert.ok(isRecord(request), 'canonical request must be an object');
274
- assert.equal(request['text'], 'rpc prompt with separators \u2028 and \u2029 kept');
275
- assert.equal(request['authority'], 'directive_over_projected_conversation');
276
- assert.ok(candidate.args.includes('--no-tools'));
277
- assert.equal(candidate.args.includes('--no-builtin-tools'), false);
278
- for (const flag of [
279
- '--no-extensions',
280
- '--no-skills',
281
- '--no-prompt-templates',
282
- '--no-context-files',
283
- '--no-session',
284
- ]) {
285
- assert.ok(candidate.args.includes(flag), flag);
286
- }
287
- for (const call of calls) {
288
- assert.ok(call.args.includes('--no-tools'), `${call.stage} must run with --no-tools`);
289
- }
290
- });
291
- });
292
-
293
- void it('uses the RPC multiline editor for no-argument /fusion and rejects /fusion-models outside TUI mode', async (t) => {
294
- if (skipWin32FusionRpcPiPathFixture(t)) return;
295
- await withRpc(async (rpc, fakeLogPath) => {
296
- const pendingPrompt = rpc.send({ type: 'prompt', message: '/unipi:fusion' });
297
- const editor = await rpc.wait(
298
- (event) => event['type'] === 'extension_ui_request' && event['method'] === 'editor',
299
- );
300
- rpc.sendUiResponse(requireString(editor['id'], 'editor id'), 'rpc editor prompt');
301
- const response = await pendingPrompt;
302
- assert.equal(response['success'], true);
303
- await rpc.wait(fusionTerminalMessage('completed'));
304
- const calls = await readInvocations(fakeLogPath);
305
- assert.equal(calls.length, 5);
306
-
307
- const modelResponse = await rpc.send({ type: 'prompt', message: '/unipi:fusion-models' });
308
- assert.equal(modelResponse['success'], true);
309
- await rpc.wait(notifyWith(/requires Pi TUI mode/));
310
- });
311
- });
312
-
313
- void it('reports malformed config before child spawn and child failures without fallback output', async (t) => {
314
- if (skipWin32FusionRpcPiPathFixture(t)) return;
315
- await withRpc(
316
- async (rpc, fakeLogPath) => {
317
- const response = await rpc.send({ type: 'prompt', message: '/unipi:fusion blocked by config' });
318
- assert.equal(response['success'], true);
319
- await rpc.wait(
320
- notifyWith(
321
- /Fusion failed:.*schema_version|Fusion failed:.*unknown key|Fusion failed:.*missing key/s,
322
- ),
323
- );
324
- assert.equal((await readInvocations(fakeLogPath)).length, 0);
325
- },
326
- { configText: '{"bad":true}\n' },
327
- );
328
-
329
- await withRpc(
330
- async (rpc, fakeLogPath) => {
331
- const response = await rpc.send({ type: 'prompt', message: '/unipi:fusion child failure' });
332
- assert.equal(response['success'], true);
333
- const terminal = await rpc.wait(fusionTerminalMessage('failed'));
334
- const message = terminal['message'];
335
- assert.ok(isRecord(message));
336
- assert.match(
337
- String(message['content'] ?? ''),
338
- /Fusion failed(?: \([^)]*\))?:.*exited with code 42/s,
339
- );
340
- const calls = await readInvocations(fakeLogPath);
341
- // The candidate wave launches three children, but the first failure
342
- // aborts its siblings. A sibling that is signalled while still blocked
343
- // reading its prompt from stdin exits before it can append its
344
- // invocation line, so the observed count is bounded rather than exact.
345
- // Pinning it to exactly three encodes a timing assumption that
346
- // cancellation deliberately breaks. The exact-count contract is proven
347
- // deterministically, without real processes, by the
348
- // 'cancels sibling candidates on first failure and never degrades to
349
- // evaluation' case in tests/unit/fusion-orchestrator.test.ts.
350
- const candidateCalls = calls.filter((call) => call.stage === 'candidate').length;
351
- assert.ok(
352
- candidateCalls >= 1,
353
- `the candidate wave must launch at least one child, saw ${String(candidateCalls)}`,
354
- );
355
- assert.ok(
356
- candidateCalls <= 3,
357
- `the candidate wave must not exceed three children, saw ${String(candidateCalls)}`,
358
- );
359
- // The safety property: a failed candidate wave must never degrade into
360
- // a later stage. This stays exact and must not be relaxed.
361
- assert.equal(
362
- calls.some((call) => call.stage === 'evaluation' || call.stage === 'merge'),
363
- false,
364
- );
365
- },
366
- { failStage: 'candidate' },
367
- );
368
- });
369
- });