@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,448 @@
|
|
|
1
|
+
import { describe, it, afterEach } 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 { isolatedTestEnv } from '../helpers/normalize.js';
|
|
18
|
+
|
|
19
|
+
const backgroundExtensionPath = resolve('extensions/background-tasks.ts');
|
|
20
|
+
const scriptedProviderPath = resolve('tests/scripted-provider/scripted-provider-extension.ts');
|
|
21
|
+
const roots: string[] = [];
|
|
22
|
+
|
|
23
|
+
type Scenario =
|
|
24
|
+
| 'bg-run-follow-up'
|
|
25
|
+
| 'notify-false'
|
|
26
|
+
| 'wake-false'
|
|
27
|
+
| 'failed-follow-up'
|
|
28
|
+
| 'display-only-bg';
|
|
29
|
+
|
|
30
|
+
async function harness(scenario: Scenario) {
|
|
31
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-bg-agent-loop-'));
|
|
32
|
+
roots.push(root);
|
|
33
|
+
const cwd = join(root, 'project');
|
|
34
|
+
const agentDir = join(root, 'agent');
|
|
35
|
+
const eventsPath = join(root, 'provider-events.jsonl');
|
|
36
|
+
await mkdir(cwd, { recursive: true });
|
|
37
|
+
await mkdir(agentDir, { recursive: true });
|
|
38
|
+
const previousScenario = process.env['UNIPI_BG_SCRIPTED_SCENARIO'];
|
|
39
|
+
const previousEvents = process.env['UNIPI_BG_SCRIPTED_EVENTS'];
|
|
40
|
+
const previousApiKey = process.env['UNIPI_BG_SCRIPTED_API_KEY'];
|
|
41
|
+
Object.assign(process.env, isolatedTestEnv, {
|
|
42
|
+
UNIPI_BG_SCRIPTED_SCENARIO: scenario,
|
|
43
|
+
UNIPI_BG_SCRIPTED_EVENTS: eventsPath,
|
|
44
|
+
UNIPI_BG_SCRIPTED_API_KEY: 'scripted-api-key',
|
|
45
|
+
NPM_CONFIG_CACHE: join(tmpdir(), 'pi-npm-cache'),
|
|
46
|
+
});
|
|
47
|
+
const settingsManager = SettingsManager.inMemory({
|
|
48
|
+
defaultProvider: 'pi-bg-scripted',
|
|
49
|
+
defaultModel: 'scripted-model',
|
|
50
|
+
});
|
|
51
|
+
const loader = new DefaultResourceLoader({
|
|
52
|
+
cwd,
|
|
53
|
+
agentDir,
|
|
54
|
+
settingsManager,
|
|
55
|
+
additionalExtensionPaths: [scriptedProviderPath, backgroundExtensionPath],
|
|
56
|
+
noExtensions: true,
|
|
57
|
+
noSkills: true,
|
|
58
|
+
noPromptTemplates: true,
|
|
59
|
+
noContextFiles: true,
|
|
60
|
+
noThemes: true,
|
|
61
|
+
});
|
|
62
|
+
await loader.reload();
|
|
63
|
+
const authStorage = AuthStorage.create(join(agentDir, 'auth.json'));
|
|
64
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
65
|
+
const { session } = await createAgentSession({
|
|
66
|
+
cwd,
|
|
67
|
+
agentDir,
|
|
68
|
+
resourceLoader: loader,
|
|
69
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
70
|
+
settingsManager,
|
|
71
|
+
authStorage,
|
|
72
|
+
modelRegistry,
|
|
73
|
+
noTools: 'builtin',
|
|
74
|
+
});
|
|
75
|
+
const scriptedModel = modelRegistry.find('pi-bg-scripted', 'scripted-model');
|
|
76
|
+
assert.ok(scriptedModel, 'scripted provider model should be registered');
|
|
77
|
+
await session.setModel(scriptedModel);
|
|
78
|
+
const restoreEnv = () => {
|
|
79
|
+
restoreEnvValue('UNIPI_BG_SCRIPTED_SCENARIO', previousScenario);
|
|
80
|
+
restoreEnvValue('UNIPI_BG_SCRIPTED_EVENTS', previousEvents);
|
|
81
|
+
restoreEnvValue('UNIPI_BG_SCRIPTED_API_KEY', previousApiKey);
|
|
82
|
+
};
|
|
83
|
+
return { session, cwd, root, eventsPath, restoreEnv };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
88
|
+
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
type JsonObject = Record<PropertyKey, unknown>;
|
|
92
|
+
|
|
93
|
+
interface CustomNotificationEntry {
|
|
94
|
+
type: 'custom_message';
|
|
95
|
+
customType: string;
|
|
96
|
+
content: string;
|
|
97
|
+
details: JsonObject;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface EventDrivenContractCheck extends JsonObject {
|
|
101
|
+
systemPrompt: boolean;
|
|
102
|
+
toolDescriptions: boolean;
|
|
103
|
+
launchReceipt: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ProviderEvent extends JsonObject {
|
|
107
|
+
callCount?: number;
|
|
108
|
+
summaries?: string[];
|
|
109
|
+
eventDrivenContract?: EventDrivenContractCheck;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function restoreEnvValue(key: string, value: string | undefined): void {
|
|
113
|
+
if (value === undefined) {
|
|
114
|
+
Reflect.deleteProperty(process.env, key);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
process.env[key] = value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isJsonObject(value: unknown): value is JsonObject {
|
|
121
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isStringArray(value: unknown): value is string[] {
|
|
125
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isEventDrivenContractCheck(value: unknown): value is EventDrivenContractCheck {
|
|
129
|
+
return (
|
|
130
|
+
isJsonObject(value) &&
|
|
131
|
+
typeof value['systemPrompt'] === 'boolean' &&
|
|
132
|
+
typeof value['toolDescriptions'] === 'boolean' &&
|
|
133
|
+
typeof value['launchReceipt'] === 'boolean'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isProviderEvent(value: unknown): value is ProviderEvent {
|
|
138
|
+
return (
|
|
139
|
+
isJsonObject(value) &&
|
|
140
|
+
(value['callCount'] === undefined || typeof value['callCount'] === 'number') &&
|
|
141
|
+
(value['summaries'] === undefined || isStringArray(value['summaries'])) &&
|
|
142
|
+
(value['eventDrivenContract'] === undefined ||
|
|
143
|
+
isEventDrivenContractCheck(value['eventDrivenContract']))
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseProviderEvent(line: string): ProviderEvent {
|
|
148
|
+
const parsed = parseJsonText(line);
|
|
149
|
+
assert.ok(
|
|
150
|
+
isProviderEvent(parsed),
|
|
151
|
+
'scripted provider event must match the provider-event contract',
|
|
152
|
+
);
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isCustomNotificationEntry(value: unknown): value is CustomNotificationEntry {
|
|
157
|
+
return (
|
|
158
|
+
isJsonObject(value) &&
|
|
159
|
+
value['type'] === 'custom_message' &&
|
|
160
|
+
value['customType'] === 'background-task-notification' &&
|
|
161
|
+
typeof value['content'] === 'string' &&
|
|
162
|
+
isJsonObject(value['details'])
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function customNotifications(session: AgentSession): CustomNotificationEntry[] {
|
|
167
|
+
const entries: readonly unknown[] = session.sessionManager.getEntries();
|
|
168
|
+
return entries.filter(isCustomNotificationEntry);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requiredAt<T>(values: readonly T[], index: number, message: string): T {
|
|
172
|
+
const value = values[index];
|
|
173
|
+
assert.ok(value, message);
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requiredString(value: unknown, message: string): string {
|
|
178
|
+
if (typeof value !== 'string') throw new Error(message);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const ENTRY_TYPE_KEY = 'type';
|
|
183
|
+
const ENTRY_MESSAGE_KEY = 'message';
|
|
184
|
+
const ENTRY_ROLE_KEY = 'role';
|
|
185
|
+
const ENTRY_CONTENT_KEY = 'content';
|
|
186
|
+
const ENTRY_TEXT_KEY = 'text';
|
|
187
|
+
|
|
188
|
+
function assistantContentParts(session: AgentSession): JsonObject[] {
|
|
189
|
+
return session.sessionManager.getEntries().flatMap((entry) => {
|
|
190
|
+
if (
|
|
191
|
+
!isJsonObject(entry) ||
|
|
192
|
+
entry[ENTRY_TYPE_KEY] !== 'message' ||
|
|
193
|
+
!isJsonObject(entry[ENTRY_MESSAGE_KEY]) ||
|
|
194
|
+
entry[ENTRY_MESSAGE_KEY][ENTRY_ROLE_KEY] !== 'assistant'
|
|
195
|
+
)
|
|
196
|
+
return [];
|
|
197
|
+
const content: unknown = entry[ENTRY_MESSAGE_KEY][ENTRY_CONTENT_KEY];
|
|
198
|
+
if (!Array.isArray(content)) return [];
|
|
199
|
+
const parts: readonly unknown[] = content;
|
|
200
|
+
return parts.filter(isJsonObject);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function assistantTexts(session: AgentSession): string[] {
|
|
205
|
+
return assistantContentParts(session).flatMap((part) =>
|
|
206
|
+
part[ENTRY_TYPE_KEY] === 'text' && typeof part[ENTRY_TEXT_KEY] === 'string'
|
|
207
|
+
? [part[ENTRY_TEXT_KEY]]
|
|
208
|
+
: [],
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assistantToolNames(session: AgentSession): string[] {
|
|
213
|
+
return assistantContentParts(session).flatMap((part) =>
|
|
214
|
+
part[ENTRY_TYPE_KEY] === 'toolCall' && typeof part['name'] === 'string' ? [part['name']] : [],
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function providerEvents(path: string): Promise<ProviderEvent[]> {
|
|
219
|
+
if (!existsSync(path)) return [];
|
|
220
|
+
const raw = await readFile(path, 'utf8');
|
|
221
|
+
return raw.trim() ? raw.trim().split('\n').map(parseProviderEvent) : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function waitFor(
|
|
225
|
+
predicate: () => boolean | Promise<boolean>,
|
|
226
|
+
message: string,
|
|
227
|
+
timeoutMs = 5000,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
const start = Date.now();
|
|
230
|
+
while (Date.now() - start < timeoutMs) {
|
|
231
|
+
if (await predicate()) return;
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
233
|
+
}
|
|
234
|
+
throw new Error(`Timed out waiting for ${message}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function disposeHarness(h: Awaited<ReturnType<typeof harness>>) {
|
|
238
|
+
try {
|
|
239
|
+
await h.session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' });
|
|
240
|
+
} finally {
|
|
241
|
+
h.session.dispose();
|
|
242
|
+
h.restoreEnv();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
void describe('scripted-provider completion follow-up behavior', { concurrency: false }, () => {
|
|
247
|
+
void it(
|
|
248
|
+
'BUG-181 bg_run yields without polling and its completion event triggers one real follow-up turn',
|
|
249
|
+
{ timeout: 15_000 },
|
|
250
|
+
async () => {
|
|
251
|
+
const h = await harness('bg-run-follow-up');
|
|
252
|
+
try {
|
|
253
|
+
await h.session.prompt('Start the scripted background task.');
|
|
254
|
+
await waitFor(() => customNotifications(h.session).length === 1, 'background notification');
|
|
255
|
+
await waitFor(
|
|
256
|
+
async () => (await providerEvents(h.eventsPath)).length >= 3,
|
|
257
|
+
'third provider call from follow-up',
|
|
258
|
+
);
|
|
259
|
+
await h.session.agent.waitForIdle();
|
|
260
|
+
|
|
261
|
+
const events = await providerEvents(h.eventsPath);
|
|
262
|
+
assert.equal(events.length, 3);
|
|
263
|
+
const launchEvent = requiredAt(events, 0, 'launch provider event should be recorded');
|
|
264
|
+
const launchContract = launchEvent.eventDrivenContract;
|
|
265
|
+
assert.ok(launchContract, 'launch event should record the effective prompt contract');
|
|
266
|
+
assert.equal(launchContract.systemPrompt, true);
|
|
267
|
+
assert.equal(launchContract.toolDescriptions, true);
|
|
268
|
+
const postToolEvent = requiredAt(events, 1, 'post-tool provider event should be recorded');
|
|
269
|
+
assert.equal(postToolEvent.eventDrivenContract?.launchReceipt, true);
|
|
270
|
+
assert.deepEqual(
|
|
271
|
+
assistantToolNames(h.session),
|
|
272
|
+
['bg_run'],
|
|
273
|
+
'ordinary event-driven waiting must not issue bg_status, bg_logs, or a sleep tool',
|
|
274
|
+
);
|
|
275
|
+
const followUpEvent = requiredAt(events, 2, 'follow-up provider event should be recorded');
|
|
276
|
+
assert.equal(followUpEvent.callCount, 3);
|
|
277
|
+
assert.match(
|
|
278
|
+
(followUpEvent.summaries ?? []).join('\n'),
|
|
279
|
+
/background-task-notification|Scripted Wakeup/,
|
|
280
|
+
);
|
|
281
|
+
assert.ok(
|
|
282
|
+
assistantTexts(h.session).some((text) =>
|
|
283
|
+
text.includes('Follow-up turn observed background-task-notification'),
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const note = requiredAt(
|
|
288
|
+
customNotifications(h.session),
|
|
289
|
+
0,
|
|
290
|
+
'completion notification should be recorded',
|
|
291
|
+
);
|
|
292
|
+
assert.match(note.content, /<task-name>Scripted Wakeup<\/task-name>/);
|
|
293
|
+
assert.match(note.content, /<status>completed<\/status>/);
|
|
294
|
+
assert.match(note.content, /<guidance>Terminal state and output metadata are durable\./);
|
|
295
|
+
assert.match(note.content, /Do not call bg_status to reconfirm/);
|
|
296
|
+
assert.equal(note.details['triggerOnCompletion'], true);
|
|
297
|
+
assert.equal(note.details['notified'], true);
|
|
298
|
+
} finally {
|
|
299
|
+
await disposeHarness(h);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
void it(
|
|
305
|
+
'notifyOnCompletion:false suppresses notification and prevents completion wakeup',
|
|
306
|
+
{ timeout: 15_000 },
|
|
307
|
+
async () => {
|
|
308
|
+
const h = await harness('notify-false');
|
|
309
|
+
try {
|
|
310
|
+
await h.session.prompt('Start the no-notify scripted background task.');
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
312
|
+
await h.session.agent.waitForIdle();
|
|
313
|
+
assert.equal(customNotifications(h.session).length, 0);
|
|
314
|
+
const events = await providerEvents(h.eventsPath);
|
|
315
|
+
assert.equal(events.length, 2);
|
|
316
|
+
assert.ok(
|
|
317
|
+
assistantTexts(h.session).some((text) =>
|
|
318
|
+
text.includes('No-notify initial turn finished'),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
} finally {
|
|
322
|
+
await disposeHarness(h);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
void it(
|
|
328
|
+
'notifyOnCompletion:true with triggerOnCompletion:false notifies without a provider wakeup',
|
|
329
|
+
{ timeout: 15_000 },
|
|
330
|
+
async () => {
|
|
331
|
+
const h = await harness('wake-false');
|
|
332
|
+
try {
|
|
333
|
+
await h.session.prompt('Start the notification-only scripted background task.');
|
|
334
|
+
await waitFor(() => customNotifications(h.session).length === 1, 'notification-only event');
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
336
|
+
await h.session.agent.waitForIdle();
|
|
337
|
+
|
|
338
|
+
const events = await providerEvents(h.eventsPath);
|
|
339
|
+
assert.equal(events.length, 2);
|
|
340
|
+
assert.equal(events[1]?.eventDrivenContract?.launchReceipt, false);
|
|
341
|
+
assert.deepEqual(assistantToolNames(h.session), ['bg_run']);
|
|
342
|
+
const note = requiredAt(
|
|
343
|
+
customNotifications(h.session),
|
|
344
|
+
0,
|
|
345
|
+
'notification-only completion should be recorded',
|
|
346
|
+
);
|
|
347
|
+
assert.match(note.content, /<task-name>No Wake Scripted<\/task-name>/);
|
|
348
|
+
assert.match(note.content, /<status>completed<\/status>/);
|
|
349
|
+
assert.equal(note.details['triggerOnCompletion'], false);
|
|
350
|
+
assert.ok(
|
|
351
|
+
assistantTexts(h.session).some((text) =>
|
|
352
|
+
text.includes('Notification-only initial turn finished'),
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
} finally {
|
|
356
|
+
await disposeHarness(h);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
void it(
|
|
362
|
+
'failed background tasks include error fields and still wake a follow-up turn',
|
|
363
|
+
{ timeout: 15_000 },
|
|
364
|
+
async () => {
|
|
365
|
+
const h = await harness('failed-follow-up');
|
|
366
|
+
try {
|
|
367
|
+
await h.session.prompt('Start the failing scripted background task.');
|
|
368
|
+
await waitFor(
|
|
369
|
+
() => customNotifications(h.session).length === 1,
|
|
370
|
+
'failed background notification',
|
|
371
|
+
);
|
|
372
|
+
await waitFor(
|
|
373
|
+
async () => (await providerEvents(h.eventsPath)).length >= 3,
|
|
374
|
+
'failed-task follow-up provider call',
|
|
375
|
+
);
|
|
376
|
+
await h.session.agent.waitForIdle();
|
|
377
|
+
|
|
378
|
+
const note = requiredAt(
|
|
379
|
+
customNotifications(h.session),
|
|
380
|
+
0,
|
|
381
|
+
'failed-task notification should be recorded',
|
|
382
|
+
);
|
|
383
|
+
assert.match(note.content, /<task-name>Failing Scripted<\/task-name>/);
|
|
384
|
+
assert.match(note.content, /<status>failed<\/status>/);
|
|
385
|
+
assert.match(note.content, /<exit-code>7<\/exit-code>/);
|
|
386
|
+
assert.match(note.content, /<error>Exited with code 7<\/error>/);
|
|
387
|
+
assert.equal(note.details['status'], 'failed');
|
|
388
|
+
assert.equal(note.details['exitCode'], 7);
|
|
389
|
+
assert.match(
|
|
390
|
+
requiredString(
|
|
391
|
+
note.details['error'],
|
|
392
|
+
'failed notification should include an error string',
|
|
393
|
+
),
|
|
394
|
+
/Exited with code 7/,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const events = await providerEvents(h.eventsPath);
|
|
398
|
+
assert.equal(events.length, 3);
|
|
399
|
+
const failedFollowUpEvent = requiredAt(
|
|
400
|
+
events,
|
|
401
|
+
2,
|
|
402
|
+
'failed-task follow-up provider event should be recorded',
|
|
403
|
+
);
|
|
404
|
+
assert.match(
|
|
405
|
+
(failedFollowUpEvent.summaries ?? []).join('\n'),
|
|
406
|
+
/background-task-notification|Failing Scripted/,
|
|
407
|
+
);
|
|
408
|
+
assert.ok(
|
|
409
|
+
assistantTexts(h.session).some((text) =>
|
|
410
|
+
text.includes('Follow-up turn observed failed background task notification'),
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
} finally {
|
|
414
|
+
await disposeHarness(h);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
void it(
|
|
420
|
+
'/bg remains display-only: it notifies but does not trigger a provider follow-up',
|
|
421
|
+
{ timeout: 15_000 },
|
|
422
|
+
async () => {
|
|
423
|
+
const h = await harness('display-only-bg');
|
|
424
|
+
try {
|
|
425
|
+
await h.session.prompt(
|
|
426
|
+
'/bg --name "Display Only Scripted" node -e "setTimeout(() => { console.log(\'display done\'); }, 80);"',
|
|
427
|
+
);
|
|
428
|
+
await waitFor(
|
|
429
|
+
() => customNotifications(h.session).length === 1,
|
|
430
|
+
'display-only notification',
|
|
431
|
+
);
|
|
432
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
433
|
+
await h.session.agent.waitForIdle();
|
|
434
|
+
const note = requiredAt(
|
|
435
|
+
customNotifications(h.session),
|
|
436
|
+
0,
|
|
437
|
+
'display-only notification should be recorded',
|
|
438
|
+
);
|
|
439
|
+
assert.match(note.content, /<task-name>Display Only Scripted<\/task-name>/);
|
|
440
|
+
assert.match(note.content, /<status>completed<\/status>/);
|
|
441
|
+
assert.equal(note.details['triggerOnCompletion'], false);
|
|
442
|
+
assert.equal((await providerEvents(h.eventsPath)).length, 0);
|
|
443
|
+
} finally {
|
|
444
|
+
await disposeHarness(h);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { isJsonObject, parseJsonText } from '../../types.js';
|
|
8
|
+
import { FUSION_CANDIDATE_MAX_OUTPUT_BYTES } from '../../fusion/output-contract.js';
|
|
9
|
+
import {
|
|
10
|
+
OUTPUT_RECOVERY_MODEL,
|
|
11
|
+
OUTPUT_RECOVERY_ORIGINAL,
|
|
12
|
+
OUTPUT_RECOVERY_PROVIDER,
|
|
13
|
+
OUTPUT_RECOVERY_REPLACEMENT,
|
|
14
|
+
} from './output-recovery-provider.js';
|
|
15
|
+
|
|
16
|
+
const roots: string[] = [];
|
|
17
|
+
|
|
18
|
+
interface ProcessResult {
|
|
19
|
+
code: number | null;
|
|
20
|
+
signal: NodeJS.Signals | null;
|
|
21
|
+
stdout: Buffer;
|
|
22
|
+
stderr: Buffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runPi(args: readonly string[], env: NodeJS.ProcessEnv): Promise<ProcessResult> {
|
|
26
|
+
return new Promise((resolveRun, reject) => {
|
|
27
|
+
const child = spawn(resolve('node_modules/.bin/pi'), args, {
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
env,
|
|
30
|
+
shell: false,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
});
|
|
33
|
+
const stdout: Buffer[] = [];
|
|
34
|
+
const stderr: Buffer[] = [];
|
|
35
|
+
child.stdout.on('data', (chunk: Buffer | string) =>
|
|
36
|
+
stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')),
|
|
37
|
+
);
|
|
38
|
+
child.stderr.on('data', (chunk: Buffer | string) =>
|
|
39
|
+
stderr.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')),
|
|
40
|
+
);
|
|
41
|
+
child.once('error', reject);
|
|
42
|
+
child.once('close', (code, signal) => {
|
|
43
|
+
resolveRun({ code, signal, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) });
|
|
44
|
+
});
|
|
45
|
+
child.stdin.end('produce the candidate answer');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
void describe('Fusion same-session candidate output recovery', { concurrency: false }, () => {
|
|
54
|
+
void it(
|
|
55
|
+
'keeps print mode alive for one no-tool continuation and settles only after replacement',
|
|
56
|
+
{ timeout: 30_000 },
|
|
57
|
+
async () => {
|
|
58
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-bg-fusion-output-recovery-'));
|
|
59
|
+
roots.push(root);
|
|
60
|
+
const logPath = join(root, 'provider.jsonl');
|
|
61
|
+
const recoveryPath = join(root, 'candidate.response.oversized.md');
|
|
62
|
+
const providerPath = resolve('tests/scripted-provider/output-recovery-provider.ts');
|
|
63
|
+
const result = await runPi(
|
|
64
|
+
[
|
|
65
|
+
'--mode',
|
|
66
|
+
'text',
|
|
67
|
+
'--no-session',
|
|
68
|
+
'--no-builtin-tools',
|
|
69
|
+
'--tools',
|
|
70
|
+
'recovery_probe',
|
|
71
|
+
'--no-extensions',
|
|
72
|
+
'--no-skills',
|
|
73
|
+
'--no-prompt-templates',
|
|
74
|
+
'--no-themes',
|
|
75
|
+
'--no-context-files',
|
|
76
|
+
'--extension',
|
|
77
|
+
providerPath,
|
|
78
|
+
'--provider',
|
|
79
|
+
OUTPUT_RECOVERY_PROVIDER,
|
|
80
|
+
'--model',
|
|
81
|
+
OUTPUT_RECOVERY_MODEL,
|
|
82
|
+
'--thinking',
|
|
83
|
+
'off',
|
|
84
|
+
'--system-prompt',
|
|
85
|
+
'output recovery characterization',
|
|
86
|
+
],
|
|
87
|
+
{
|
|
88
|
+
...process.env,
|
|
89
|
+
PI_OFFLINE: '1',
|
|
90
|
+
PI_SKIP_VERSION_CHECK: '1',
|
|
91
|
+
PI_TELEMETRY: '0',
|
|
92
|
+
UNIPI_BG_OUTPUT_RECOVERY_API_KEY: 'test-key',
|
|
93
|
+
UNIPI_BG_OUTPUT_RECOVERY_LOG: logPath,
|
|
94
|
+
UNIPI_BG_OUTPUT_RECOVERY_ARTIFACT: recoveryPath,
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
assert.equal(result.code, 0, result.stderr.toString('utf8'));
|
|
99
|
+
assert.equal(result.signal, null);
|
|
100
|
+
assert.equal(result.stdout.toString('utf8'), `${OUTPUT_RECOVERY_REPLACEMENT}\n`);
|
|
101
|
+
assert.equal(await readFile(recoveryPath, 'utf8'), OUTPUT_RECOVERY_ORIGINAL);
|
|
102
|
+
assert.equal(
|
|
103
|
+
Buffer.byteLength(JSON.stringify(OUTPUT_RECOVERY_ORIGINAL), 'utf8'),
|
|
104
|
+
FUSION_CANDIDATE_MAX_OUTPUT_BYTES + 2,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const rows = (await readFile(logPath, 'utf8'))
|
|
108
|
+
.trim()
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line) => {
|
|
111
|
+
const parsed = parseJsonText(line);
|
|
112
|
+
assert.ok(isJsonObject(parsed));
|
|
113
|
+
return parsed;
|
|
114
|
+
});
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
rows.map((row) => row['event']),
|
|
117
|
+
['provider_call', 'provider_call', 'agent_settled'],
|
|
118
|
+
'agent_settled must wait until the queued continuation completes',
|
|
119
|
+
);
|
|
120
|
+
const first = rows[0];
|
|
121
|
+
const second = rows[1];
|
|
122
|
+
assert.ok(first);
|
|
123
|
+
assert.ok(second);
|
|
124
|
+
assert.equal(first['pid'], second['pid'], 'both turns must use one child process');
|
|
125
|
+
assert.deepEqual(first['tools'], ['recovery_probe']);
|
|
126
|
+
assert.deepEqual(second['tools'], []);
|
|
127
|
+
assert.equal(second['saw_original'], true, 'turn two must retain turn-one context');
|
|
128
|
+
assert.equal(second['saw_compression_prompt'], true);
|
|
129
|
+
assert.deepEqual(second['roles'], ['user', 'assistant', 'user']);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
});
|