@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.
- package/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
});
|