@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
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @pi-unipi/background-tasks
|
|
2
|
+
|
|
3
|
+
Background tasks for UniPi — a full adoption of
|
|
4
|
+
[pi-background-tasks](https://github.com/ismailsaleekh/pi-background-tasks) into the
|
|
5
|
+
unipi conventions: durable background shell jobs, read-only delegated agents,
|
|
6
|
+
attested Pi runs, fixed-purpose multi-model Fusion workflows, and package-owned
|
|
7
|
+
Anthropic subscription attribution.
|
|
8
|
+
|
|
9
|
+
## Master toggle
|
|
10
|
+
|
|
11
|
+
One config key disables the entire module — no tools, no commands, no hooks, no UI:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
// ~/.unipi/config/background-tasks.json (global)
|
|
15
|
+
// <workspace>/.unipi/config/background-tasks.json (override; workspace wins)
|
|
16
|
+
{
|
|
17
|
+
"enabled": true
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open `/unipi:bg-settings` for the interactive settings overlay (master toggle,
|
|
22
|
+
defaults, output caps, delegate/fusion defaults).
|
|
23
|
+
|
|
24
|
+
## Surfaces
|
|
25
|
+
|
|
26
|
+
### Tools (reference names kept)
|
|
27
|
+
|
|
28
|
+
| Tool | Purpose |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `bg_run` | Start a named long-running shell command; terminal notification wakes a follow-up turn by default |
|
|
31
|
+
| `bg_status` / `bg_logs` / `bg_kill` | Point-in-time inspection, bounded log reads, stop — never polling primitives |
|
|
32
|
+
| `bg_delegate` + `bg_result` | One read-only child Pi agent seeded with a frozen projection of this conversation; hash-verified answer retrieval |
|
|
33
|
+
| `bg_run_pi_attested` | Evidence-oriented direct Pi spawn with local hashes and route attestation |
|
|
34
|
+
| `fusion_reason` | 3 candidates → blind evaluator → merger on the session projection |
|
|
35
|
+
| `fusion_investigate` / `fusion_research` / `fusion_validate` | Fixed-purpose multi-model workflows on clean-task input (inspect tools / caller-URL research / advisory review) |
|
|
36
|
+
|
|
37
|
+
### Commands (our namespace)
|
|
38
|
+
|
|
39
|
+
`/unipi:bg`, `/unipi:bg-clear`, `/unipi:bg-tasks`, `/unipi:bg-update`,
|
|
40
|
+
`/unipi:tasks`, `/unipi:jobs`, `/unipi:kill`, `/unipi:logs`,
|
|
41
|
+
`/unipi:bg-settings`, `/unipi:fusion`, `/unipi:fusion-models`,
|
|
42
|
+
`/unipi:claude-cache`.
|
|
43
|
+
|
|
44
|
+
Shortcuts: `Shift↓` opens the task manager dock; `Ctrl+Alt+C` clears finished notices.
|
|
45
|
+
|
|
46
|
+
## Storage layout (ours — never `.pi/`)
|
|
47
|
+
|
|
48
|
+
- Runtime artifacts (task output/metadata): `$TMPDIR/unipi-bg-tasks/<session>-<pid>-<nonce>/`
|
|
49
|
+
- Durable delegate artifacts: `<workspace>/.unipi/delegate/<session>-<pid>/<task-id>/`
|
|
50
|
+
- Durable fusion artifacts: `<workspace>/.unipi/fusion/<session>-<pid>/<run-id>/`
|
|
51
|
+
- Config: `~/.unipi/config/background-tasks.json` + workspace override
|
|
52
|
+
- Fusion model config: five-slot selector persisted through `/unipi:fusion-models`
|
|
53
|
+
|
|
54
|
+
## Environment
|
|
55
|
+
|
|
56
|
+
`UNIPI_BG_*` prefix (replaces the reference `PI_BG_*`):
|
|
57
|
+
`UNIPI_BG_TMP_DIR`, `UNIPI_BG_MAX_OUTPUT_BYTES`, `UNIPI_BG_SHELL`,
|
|
58
|
+
`UNIPI_BG_SHELL_PATH`, `UNIPI_BG_DISABLE_PI_TELEMETRY`, `UNIPI_BG_DELEGATE_*`.
|
|
59
|
+
|
|
60
|
+
## Completion delivery
|
|
61
|
+
|
|
62
|
+
| `notifyOnCompletion` | `triggerOnCompletion` | Mode |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| true (default) | true (bg_run default) | Durable terminal notification + automatic follow-up turn |
|
|
65
|
+
| true | false (`/unipi:bg` default) | Notification only |
|
|
66
|
+
| false | — | Manual monitoring |
|
|
67
|
+
|
|
68
|
+
Treat `<background-task-notification>` as durable terminal truth — do not poll.
|
|
69
|
+
|
|
70
|
+
## Attribution
|
|
71
|
+
|
|
72
|
+
`extensions/anthropic-attribution.ts` loads first (provider-gated): Claude Code
|
|
73
|
+
subscription OAuth attribution, exact-match system-prompt sanitization, and
|
|
74
|
+
cache-retention policy for Anthropic routes. Duplicate installed copies resolve
|
|
75
|
+
ownership through an EventBus claim; later copies go inert.
|
|
76
|
+
|
|
77
|
+
## Differences from the reference
|
|
78
|
+
|
|
79
|
+
- Commands live in the `/unipi:*` namespace; env prefix is `UNIPI_BG_*`.
|
|
80
|
+
- Runtime artifacts under the OS temp root (per-registry nonce) and durable
|
|
81
|
+
delegate/fusion artifacts under workspace `.unipi/` — never `.pi/`.
|
|
82
|
+
- The reference's `update-check` footer surface is dropped (unipi's updater
|
|
83
|
+
module owns updates).
|
|
84
|
+
- `agent_settled` / `before_provider_headers` hooks register defensively on our
|
|
85
|
+
pi SDK with an `agent_end` settlement fallback.
|
|
86
|
+
|
|
87
|
+
ISC-licensed reference: Copyright Ismail <ismailsalikhodjaev@gmail.com>.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../src/anthropic-attribution.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../src/delegate-child-extension.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../src/fusion-child-extension.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/background-tasks",
|
|
3
|
+
"version": "2.6.1",
|
|
4
|
+
"description": "Background tasks for UniPi \u2014 durable shell jobs, delegated agents, attested Pi runs, and fixed-purpose Fusion workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "npx tsx --test src/__tests__/*.test.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@earendil-works/pi-ai": "^0.80.0",
|
|
13
|
+
"@pi-unipi/core": "2.6.1",
|
|
14
|
+
"turndown": "^7.2.4"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/turndown": "^5.0.6",
|
|
18
|
+
"typescript": "^5.8.3"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
22
|
+
"@earendil-works/pi-tui": "^0.80.0",
|
|
23
|
+
"typebox": "^1.1.38"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/**/*.ts",
|
|
27
|
+
"src/**/*.json",
|
|
28
|
+
"extensions/*.ts",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extensions/anthropic-attribution.ts",
|
|
34
|
+
"./src/index.ts"
|
|
35
|
+
],
|
|
36
|
+
"skills": [],
|
|
37
|
+
"prompts": [],
|
|
38
|
+
"themes": []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import spawnAnthropicAttribution, {
|
|
4
|
+
ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL,
|
|
5
|
+
rewriteAnthropicRequestPayload,
|
|
6
|
+
type PiExtensionHost,
|
|
7
|
+
type PiContextLike,
|
|
8
|
+
} from '../anthropic-attribution.js';
|
|
9
|
+
import { isJsonObject } from '../types.js';
|
|
10
|
+
import { buildAttestedPiArgv } from '../attested-pi-run.js';
|
|
11
|
+
|
|
12
|
+
const BAD_SYSTEM_LINES = [
|
|
13
|
+
'- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)',
|
|
14
|
+
'- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)',
|
|
15
|
+
'- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
function context(provider = 'anthropic'): PiContextLike {
|
|
19
|
+
return {
|
|
20
|
+
model: {
|
|
21
|
+
provider,
|
|
22
|
+
id: provider === 'anthropic' ? 'claude-sonnet-4-5' : 'gpt-5.5',
|
|
23
|
+
maxTokens: 64_000,
|
|
24
|
+
reasoning: true,
|
|
25
|
+
},
|
|
26
|
+
sessionManager: {
|
|
27
|
+
getSessionId: () => '11111111-2222-4333-8444-555555555555',
|
|
28
|
+
getBranch: () => [],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class SynchronousTestBus {
|
|
34
|
+
private readonly handlers = new Map<string, Array<(data: unknown) => void>>();
|
|
35
|
+
|
|
36
|
+
emit(channel: string, data: unknown): void {
|
|
37
|
+
for (const handler of this.handlers.get(channel) ?? []) handler(data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
41
|
+
const handlers = this.handlers.get(channel) ?? [];
|
|
42
|
+
handlers.push(handler);
|
|
43
|
+
this.handlers.set(channel, handlers);
|
|
44
|
+
return () => {
|
|
45
|
+
this.handlers.set(
|
|
46
|
+
channel,
|
|
47
|
+
(this.handlers.get(channel) ?? []).filter((candidate) => candidate !== handler),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
listenerCount(channel: string): number {
|
|
53
|
+
return this.handlers.get(channel)?.length ?? 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recordingHost(bus: SynchronousTestBus): {
|
|
58
|
+
host: PiExtensionHost;
|
|
59
|
+
registrations: { commands: number; handlers: number; providers: number; entries: number };
|
|
60
|
+
} {
|
|
61
|
+
const registrations = { commands: 0, handlers: 0, providers: 0, entries: 0 };
|
|
62
|
+
const on: PiExtensionHost['on'] = () => {
|
|
63
|
+
registrations.handlers += 1;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
host: {
|
|
67
|
+
events: bus,
|
|
68
|
+
on,
|
|
69
|
+
registerCommand: () => {
|
|
70
|
+
registrations.commands += 1;
|
|
71
|
+
},
|
|
72
|
+
registerProvider: () => {
|
|
73
|
+
registrations.providers += 1;
|
|
74
|
+
},
|
|
75
|
+
appendEntry: () => {
|
|
76
|
+
registrations.entries += 1;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
registrations,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void describe('global Anthropic attribution extension', () => {
|
|
84
|
+
void it('matches all SPS exact-line variants while preserving unrelated blocks and cache controls', () => {
|
|
85
|
+
const original = {
|
|
86
|
+
model: 'claude-sonnet-4-5',
|
|
87
|
+
max_tokens: 64_000,
|
|
88
|
+
system: [
|
|
89
|
+
{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: ['keep before', ...BAD_SYSTEM_LINES, 'keep after'].join('\n'),
|
|
92
|
+
cache_control: { type: 'ephemeral', ttl: '1h' },
|
|
93
|
+
custom_field: 'preserved',
|
|
94
|
+
},
|
|
95
|
+
{ type: 'custom', payload: 'unchanged' },
|
|
96
|
+
],
|
|
97
|
+
messages: [{ role: 'user', content: 'hello' }],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const rewritten = rewriteAnthropicRequestPayload({
|
|
101
|
+
payload: original,
|
|
102
|
+
ctx: context(),
|
|
103
|
+
account: {
|
|
104
|
+
deviceId: 'd'.repeat(64),
|
|
105
|
+
accountUuid: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
assert.ok(isJsonObject(rewritten));
|
|
109
|
+
const system: unknown = rewritten['system'];
|
|
110
|
+
assert.ok(Array.isArray(system));
|
|
111
|
+
const retained = system.find(
|
|
112
|
+
(block) => isJsonObject(block) && block['custom_field'] === 'preserved',
|
|
113
|
+
);
|
|
114
|
+
assert.ok(isJsonObject(retained));
|
|
115
|
+
assert.equal(retained['text'], 'keep before\nkeep after');
|
|
116
|
+
assert.deepEqual(retained['cache_control'], { type: 'ephemeral', ttl: '1h' });
|
|
117
|
+
assert.equal(retained['custom_field'], 'preserved');
|
|
118
|
+
assert.deepEqual(system.at(-1), { type: 'custom', payload: 'unchanged' });
|
|
119
|
+
assert.deepEqual(original.system[0]?.cache_control, { type: 'ephemeral', ttl: '1h' });
|
|
120
|
+
for (const rejected of BAD_SYSTEM_LINES) {
|
|
121
|
+
assert.equal(JSON.stringify(rewritten).includes(rejected), false);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
void it('leaves non-Anthropic payloads untouched', () => {
|
|
126
|
+
const payload = { model: 'gpt-5.5', metadata: { untouched: true } };
|
|
127
|
+
assert.equal(
|
|
128
|
+
rewriteAnthropicRequestPayload({
|
|
129
|
+
payload,
|
|
130
|
+
ctx: context('openai-codex'),
|
|
131
|
+
account: { deviceId: 'd', accountUuid: 'a' },
|
|
132
|
+
}),
|
|
133
|
+
undefined,
|
|
134
|
+
);
|
|
135
|
+
assert.deepEqual(payload, { model: 'gpt-5.5', metadata: { untouched: true } });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
void it('adds package attribution to attested Anthropic argv only', () => {
|
|
139
|
+
const base = {
|
|
140
|
+
name: 'Attested child',
|
|
141
|
+
model: 'model',
|
|
142
|
+
prompt: 'write report.md',
|
|
143
|
+
reportPath: 'report.md',
|
|
144
|
+
};
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
buildAttestedPiArgv(
|
|
147
|
+
{ ...base, provider: 'anthropic' },
|
|
148
|
+
'/pkg/extensions/anthropic-attribution.ts',
|
|
149
|
+
),
|
|
150
|
+
[
|
|
151
|
+
'pi',
|
|
152
|
+
'--mode',
|
|
153
|
+
'json',
|
|
154
|
+
'--provider',
|
|
155
|
+
'anthropic',
|
|
156
|
+
'--model',
|
|
157
|
+
'model',
|
|
158
|
+
'--extension',
|
|
159
|
+
'/pkg/extensions/anthropic-attribution.ts',
|
|
160
|
+
'write report.md',
|
|
161
|
+
],
|
|
162
|
+
);
|
|
163
|
+
assert.deepEqual(buildAttestedPiArgv({ ...base, provider: 'openai-codex' }), [
|
|
164
|
+
'pi',
|
|
165
|
+
'--mode',
|
|
166
|
+
'json',
|
|
167
|
+
'--provider',
|
|
168
|
+
'openai-codex',
|
|
169
|
+
'--model',
|
|
170
|
+
'model',
|
|
171
|
+
'write report.md',
|
|
172
|
+
]);
|
|
173
|
+
assert.throws(
|
|
174
|
+
() => buildAttestedPiArgv({ ...base, provider: 'anthropic' }),
|
|
175
|
+
/require the package attribution extension/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
void it('allows exactly one independently loaded copy to own global registration', () => {
|
|
180
|
+
const bus = new SynchronousTestBus();
|
|
181
|
+
const first = recordingHost(bus);
|
|
182
|
+
const second = recordingHost(bus);
|
|
183
|
+
|
|
184
|
+
spawnAnthropicAttribution(first.host);
|
|
185
|
+
spawnAnthropicAttribution(second.host);
|
|
186
|
+
|
|
187
|
+
assert.equal(first.registrations.commands, 1);
|
|
188
|
+
assert.equal(first.registrations.handlers, 4);
|
|
189
|
+
assert.equal(first.registrations.providers, 1);
|
|
190
|
+
assert.equal(second.registrations.commands, 0);
|
|
191
|
+
assert.equal(second.registrations.handlers, 0);
|
|
192
|
+
assert.equal(second.registrations.providers, 0);
|
|
193
|
+
assert.equal(bus.listenerCount(ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL), 1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config layering + validation tests for @pi-unipi/background-tasks.
|
|
3
|
+
* Adapted from reference config/operations specs to OUR paths and layering.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_CONFIG,
|
|
13
|
+
loadBackgroundTasksConfig,
|
|
14
|
+
validateBackgroundTasksConfig,
|
|
15
|
+
} from "../config.js";
|
|
16
|
+
|
|
17
|
+
/** Run loadBackgroundTasksConfig with HOME pointed at a temp dir. */
|
|
18
|
+
function withTempHome<T>(fn: (home: string, cwd: string) => T): T {
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), "unipi-bg-cfg-home-"));
|
|
20
|
+
const cwd = mkdtempSync(join(tmpdir(), "unipi-bg-cfg-ws-"));
|
|
21
|
+
const realHome = process.env.HOME;
|
|
22
|
+
const realUserProfile = process.env.USERPROFILE;
|
|
23
|
+
process.env.HOME = home;
|
|
24
|
+
process.env.USERPROFILE = home;
|
|
25
|
+
try {
|
|
26
|
+
return fn(home, cwd);
|
|
27
|
+
} finally {
|
|
28
|
+
if (realHome === undefined) delete process.env.HOME;
|
|
29
|
+
else process.env.HOME = realHome;
|
|
30
|
+
if (realUserProfile === undefined) delete process.env.USERPROFILE;
|
|
31
|
+
else process.env.USERPROFILE = realUserProfile;
|
|
32
|
+
rmSync(home, { recursive: true, force: true });
|
|
33
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("defaults: enabled true, notify+wake on, 20MiB cap", () => {
|
|
38
|
+
assert.equal(DEFAULT_CONFIG.enabled, true);
|
|
39
|
+
assert.equal(DEFAULT_CONFIG.notifyOnCompletion, true);
|
|
40
|
+
assert.equal(DEFAULT_CONFIG.triggerOnCompletion, true);
|
|
41
|
+
assert.equal(DEFAULT_CONFIG.maxOutputBytes, 20 * 1024 * 1024);
|
|
42
|
+
assert.equal(DEFAULT_CONFIG.delegate.extensionMode, "isolated");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("first run creates the global config file", () => {
|
|
46
|
+
withTempHome((home) => {
|
|
47
|
+
const { config } = loadBackgroundTasksConfig("");
|
|
48
|
+
assert.equal(config.enabled, true);
|
|
49
|
+
const written = JSON.parse(readFileSync(join(home, ".unipi", "config", "background-tasks.json"), "utf-8"));
|
|
50
|
+
assert.equal(written.enabled, true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("workspace config wins over global", () => {
|
|
55
|
+
withTempHome((home, cwd) => {
|
|
56
|
+
const globalPath = join(home, ".unipi", "config", "background-tasks.json");
|
|
57
|
+
mkdirSync(join(globalPath, ".."), { recursive: true });
|
|
58
|
+
writeFileSync(globalPath, JSON.stringify({ maxFinishedTasks: 10 }));
|
|
59
|
+
const wsPath = join(cwd, ".unipi", "config", "background-tasks.json");
|
|
60
|
+
mkdirSync(join(wsPath, ".."), { recursive: true });
|
|
61
|
+
writeFileSync(wsPath, JSON.stringify({ maxFinishedTasks: 7, triggerOnCompletion: false }));
|
|
62
|
+
|
|
63
|
+
const { config } = loadBackgroundTasksConfig(cwd);
|
|
64
|
+
assert.equal(config.maxFinishedTasks, 7);
|
|
65
|
+
assert.equal(config.triggerOnCompletion, false);
|
|
66
|
+
assert.equal(config.enabled, true); // untouched default
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("corrupt layers are skipped with warnings, not crashes", () => {
|
|
71
|
+
withTempHome((home, cwd) => {
|
|
72
|
+
const globalPath = join(home, ".unipi", "config", "background-tasks.json");
|
|
73
|
+
mkdirSync(join(globalPath, ".."), { recursive: true });
|
|
74
|
+
writeFileSync(globalPath, "{not json");
|
|
75
|
+
const wsPath = join(cwd, ".unipi", "config", "background-tasks.json");
|
|
76
|
+
mkdirSync(join(wsPath, ".."), { recursive: true });
|
|
77
|
+
writeFileSync(wsPath, "[1,2,3]");
|
|
78
|
+
|
|
79
|
+
const { config, warnings } = loadBackgroundTasksConfig(cwd);
|
|
80
|
+
assert.equal(config.enabled, true);
|
|
81
|
+
assert.equal(config.maxFinishedTasks, DEFAULT_CONFIG.maxFinishedTasks);
|
|
82
|
+
assert.ok(warnings.length >= 1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("validation rejects bad values with visible messages", () => {
|
|
87
|
+
const problems = validateBackgroundTasksConfig({
|
|
88
|
+
enabled: "yes",
|
|
89
|
+
maxOutputBytes: 10,
|
|
90
|
+
delegate: { extensionMode: "wild" },
|
|
91
|
+
fusion: { candidates: ["a"] },
|
|
92
|
+
});
|
|
93
|
+
assert.ok(problems.some((p) => p.includes("enabled")));
|
|
94
|
+
assert.ok(problems.some((p) => p.includes("maxOutputBytes")));
|
|
95
|
+
assert.ok(problems.some((p) => p.includes("extensionMode")));
|
|
96
|
+
assert.ok(problems.some((p) => p.includes("candidates")));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("validation accepts a full valid config", () => {
|
|
100
|
+
const problems = validateBackgroundTasksConfig({
|
|
101
|
+
enabled: false,
|
|
102
|
+
notifyOnCompletion: false,
|
|
103
|
+
triggerOnCompletion: false,
|
|
104
|
+
defaultTimeoutSeconds: 600,
|
|
105
|
+
maxFinishedTasks: 5,
|
|
106
|
+
maxOutputBytes: 1024 * 1024,
|
|
107
|
+
delegate: { extensionMode: "ambient", autoDeliver: "always", maxTurns: 10, maxToolCalls: 20, timeoutSeconds: 60 },
|
|
108
|
+
fusion: { candidates: ["m1", "m2", "m3"], evaluator: "e", merger: "g" },
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(problems, []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("invalid values sanitize to defaults instead of crashing the loader", () => {
|
|
114
|
+
withTempHome((home) => {
|
|
115
|
+
const globalPath = join(home, ".unipi", "config", "background-tasks.json");
|
|
116
|
+
mkdirSync(join(globalPath, ".."), { recursive: true });
|
|
117
|
+
writeFileSync(
|
|
118
|
+
globalPath,
|
|
119
|
+
JSON.stringify({ enabled: "nope", maxFinishedTasks: -1, delegate: { extensionMode: "wild" } }),
|
|
120
|
+
);
|
|
121
|
+
const { config, warnings } = loadBackgroundTasksConfig("");
|
|
122
|
+
assert.equal(config.enabled, true);
|
|
123
|
+
assert.equal(config.maxFinishedTasks, DEFAULT_CONFIG.maxFinishedTasks);
|
|
124
|
+
assert.equal(config.delegate.extensionMode, "isolated");
|
|
125
|
+
assert.ok(warnings.length >= 1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("master toggle off is preserved through load", () => {
|
|
130
|
+
withTempHome((home) => {
|
|
131
|
+
const globalPath = join(home, ".unipi", "config", "background-tasks.json");
|
|
132
|
+
mkdirSync(join(globalPath, ".."), { recursive: true });
|
|
133
|
+
writeFileSync(globalPath, JSON.stringify({ enabled: false }));
|
|
134
|
+
const { config } = loadBackgroundTasksConfig("");
|
|
135
|
+
assert.equal(config.enabled, false);
|
|
136
|
+
});
|
|
137
|
+
});
|