@selesai/code 0.3.0 → 0.3.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/dist/core/diagnostics.d.ts +1 -0
- package/dist/core/diagnostics.d.ts.map +1 -1
- package/dist/core/diagnostics.js.map +1 -1
- package/dist/core/package-manager.d.ts +10 -0
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +93 -8
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/package-manager.test.d.ts +2 -0
- package/dist/core/package-manager.test.d.ts.map +1 -0
- package/dist/core/package-manager.test.js +20 -0
- package/dist/core/package-manager.test.js.map +1 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +10 -0
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/extensions/caveman/caveman-instructions.cjs +39 -0
- package/dist/extensions/caveman/index.js +116 -0
- package/dist/extensions/caveman/package.json +8 -0
- package/dist/extensions/caveman/test/extension.test.js +185 -0
- package/dist/extensions/caveman/test/helpers.test.js +67 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +2 -3
- package/dist/extensions/pi-intercom/broker/paths.ts +10 -5
- package/dist/extensions/pi-intercom/broker/spawn.ts +2 -3
- package/dist/extensions/pi-intercom/config.ts +2 -2
- package/dist/extensions/pi-intercom/index.ts +2 -2
- package/dist/extensions/pi-intercom/package.json +2 -2
- package/dist/extensions/pi-intercom/ui/compose.ts +3 -3
- package/dist/extensions/pi-intercom/ui/inline-message.ts +3 -3
- package/dist/extensions/pi-intercom/ui/session-list.ts +3 -3
- package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +2 -1
- package/dist/extensions/pi-powerline-footer/index.ts +9 -16
- package/dist/extensions/pi-powerline-footer/welcome.ts +23 -16
- package/dist/extensions/pi-powerline-footer/working-vibes.ts +2 -9
- package/dist/extensions/pi-rewind-hook/index.ts +1 -1
- package/dist/extensions/pi-rewind-hook/package.json +1 -1
- package/dist/extensions/pi-subagents/README.md +224 -52
- package/dist/extensions/pi-subagents/install.mjs +93 -0
- package/dist/extensions/pi-subagents/package.json +4 -5
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +76 -13
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +179 -2
- package/dist/extensions/pi-subagents/src/agents/agent-memory.ts +254 -0
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +11 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +193 -19
- package/dist/extensions/pi-subagents/src/agents/chain-serializer.ts +27 -2
- package/dist/extensions/pi-subagents/src/extension/config.ts +27 -4
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +1 -7
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -2
- package/dist/extensions/pi-subagents/src/extension/index.ts +73 -71
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +369 -0
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +54 -10
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +200 -0
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +21 -253
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +519 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +195 -105
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +88 -2
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +67 -10
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +34 -4
- package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +166 -0
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +156 -1
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +515 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +161 -44
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +1 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +3 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +167 -6
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +514 -0
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +28 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +873 -204
- package/dist/extensions/pi-subagents/src/runs/background/wait.ts +353 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +123 -27
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +174 -70
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +569 -81
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +45 -22
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +172 -21
- package/dist/extensions/pi-subagents/src/runs/shared/model-scope.ts +128 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +89 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +50 -1
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +35 -4
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +63 -45
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +125 -5
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +74 -0
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +52 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +28 -5
- package/dist/extensions/pi-subagents/src/shared/artifacts.ts +16 -1
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +15 -2
- package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +212 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +133 -22
- package/dist/extensions/pi-subagents/src/shared/settings.ts +3 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +197 -4
- package/dist/extensions/pi-subagents/src/shared/utils.ts +100 -15
- package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +330 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +135 -4
- package/dist/extensions/pi-subagents/src/tui/render.ts +32 -12
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +2772 -0
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +1107 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +463 -0
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +274 -0
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +1535 -0
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +242 -0
- package/dist/extensions/pi-subagents/test/integration/doctor-executor.test.ts +128 -0
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +252 -0
- package/dist/extensions/pi-subagents/test/integration/foreground-result-size.test.ts +188 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +1306 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +1033 -0
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +397 -0
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +583 -0
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +691 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +892 -0
- package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1824 -0
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1284 -0
- package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +127 -0
- package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +299 -0
- package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +37 -0
- package/dist/extensions/pi-subagents/test/support/helpers.ts +189 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +335 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +129 -0
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +184 -0
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +285 -0
- package/dist/extensions/pi-subagents/test/support/register-loader.mjs +15 -0
- package/dist/extensions/pi-subagents/test/support/ts-loader.mjs +108 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +421 -0
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +343 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +1199 -0
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +556 -0
- package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +420 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +579 -0
- package/dist/extensions/pi-subagents/test/unit/agent-scope.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/unit/agent-selection.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +24 -0
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +221 -0
- package/dist/extensions/pi-subagents/test/unit/async-permission-session.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +285 -0
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +112 -0
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +206 -0
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +159 -0
- package/dist/extensions/pi-subagents/test/unit/chain-serializer.test.ts +229 -0
- package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +225 -0
- package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +101 -0
- package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +228 -0
- package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +152 -0
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +72 -0
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +327 -0
- package/dist/extensions/pi-subagents/test/unit/control-notices.test.ts +161 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +159 -0
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +217 -0
- package/dist/extensions/pi-subagents/test/unit/extra-agent-dirs.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/file-coalescer.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/foreground-tool-call-compaction.test.ts +78 -0
- package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +415 -0
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +126 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +271 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +173 -0
- package/dist/extensions/pi-subagents/test/unit/jsonl-writer.test.ts +119 -0
- package/dist/extensions/pi-subagents/test/unit/live-async-resume.test.ts +170 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +346 -0
- package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/unit/model-scope-settings.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +145 -0
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +162 -0
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +470 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +329 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +319 -0
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +70 -0
- package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +247 -0
- package/dist/extensions/pi-subagents/test/unit/path-handling.test.ts +113 -0
- package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +803 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +238 -0
- package/dist/extensions/pi-subagents/test/unit/proactive-skills.test.ts +118 -0
- package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +299 -0
- package/dist/extensions/pi-subagents/test/unit/prompt-template-bridge.test.ts +408 -0
- package/dist/extensions/pi-subagents/test/unit/prompt-workflows.test.ts +134 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +257 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +226 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +294 -0
- package/dist/extensions/pi-subagents/test/unit/run-id-resolver.test.ts +147 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +997 -0
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +478 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +562 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +438 -0
- package/dist/extensions/pi-subagents/test/unit/slash-bridge.test.ts +54 -0
- package/dist/extensions/pi-subagents/test/unit/slash-chain-groups.test.ts +464 -0
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +255 -0
- package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +19 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +245 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +517 -0
- package/dist/extensions/pi-subagents/test/unit/temp-paths.test.ts +72 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +237 -0
- package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +60 -0
- package/dist/extensions/pi-subagents/test/unit/ts-loader.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +202 -0
- package/dist/extensions/pi-subagents/test/unit/types-fork-preamble.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/wait.test.ts +398 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +26 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-graph.test.ts +134 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +479 -0
- package/dist/extensions/pi-web-agent/src/changelog-notice.ts +5 -2
- package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +6 -4
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/first-time-setup.d.ts +1 -1
- package/dist/modes/interactive/components/first-time-setup.d.ts.map +1 -1
- package/dist/modes/interactive/components/first-time-setup.js +19 -5
- package/dist/modes/interactive/components/first-time-setup.js.map +1 -1
- package/dist/skills/caveman/SKILL.md +49 -0
- package/dist/skills/handoff-text/SKILL.md +13 -0
- package/docs/docs.json +4 -0
- package/docs/extensions.md +2 -0
- package/docs/settings.md +15 -0
- package/docs/shared-host-extensions.md +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
ASYNC_RESUME_INTERRUPT_SIGNAL,
|
|
8
|
+
interruptLiveAsyncResumeTarget,
|
|
9
|
+
resolveAsyncResumeTarget,
|
|
10
|
+
} from "../../src/runs/background/async-resume.ts";
|
|
11
|
+
|
|
12
|
+
function writeJson(filePath: string, value: object): void {
|
|
13
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
14
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("live async resume interrupt", () => {
|
|
18
|
+
it("interrupts a resolved live async child before the caller sends a follow-up", () => {
|
|
19
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-live-async-resume-"));
|
|
20
|
+
try {
|
|
21
|
+
const asyncRoot = path.join(root, "runs");
|
|
22
|
+
const resultsDir = path.join(root, "results");
|
|
23
|
+
const asyncDir = path.join(asyncRoot, "run-live");
|
|
24
|
+
writeJson(path.join(asyncDir, "status.json"), {
|
|
25
|
+
runId: "run-live",
|
|
26
|
+
mode: "single",
|
|
27
|
+
state: "running",
|
|
28
|
+
pid: process.pid,
|
|
29
|
+
cwd: root,
|
|
30
|
+
startedAt: 100,
|
|
31
|
+
lastUpdate: Date.now(),
|
|
32
|
+
steps: [{ agent: "worker", status: "running", startedAt: 100 }],
|
|
33
|
+
});
|
|
34
|
+
const target = resolveAsyncResumeTarget({ id: "run-live" }, { asyncDirRoot: asyncRoot, resultsDir });
|
|
35
|
+
assert.equal(target.kind, "live");
|
|
36
|
+
|
|
37
|
+
const kills: Array<{ pid: number; signal?: NodeJS.Signals | 0 }> = [];
|
|
38
|
+
const state = {
|
|
39
|
+
asyncJobs: new Map([["run-live", {
|
|
40
|
+
asyncId: "run-live",
|
|
41
|
+
asyncDir,
|
|
42
|
+
status: "running" as const,
|
|
43
|
+
pid: process.pid,
|
|
44
|
+
activityState: "needs_attention" as const,
|
|
45
|
+
updatedAt: 100,
|
|
46
|
+
}]]),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = interruptLiveAsyncResumeTarget({
|
|
50
|
+
target,
|
|
51
|
+
state,
|
|
52
|
+
now: () => 1234,
|
|
53
|
+
resultsDir,
|
|
54
|
+
kill: (pid, signal) => {
|
|
55
|
+
kills.push({ pid, signal });
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(result, { ok: true, asyncId: "run-live" });
|
|
61
|
+
assert.deepEqual(kills, [{ pid: process.pid, signal: 0 }, { pid: process.pid, signal: ASYNC_RESUME_INTERRUPT_SIGNAL }]);
|
|
62
|
+
assert.equal(state.asyncJobs.get("run-live")?.activityState, undefined);
|
|
63
|
+
assert.equal(state.asyncJobs.get("run-live")?.updatedAt, 1234);
|
|
64
|
+
// The portable control request is dropped regardless of the signal path.
|
|
65
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "interrupt.json")), true);
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("still interrupts a live async child when the OS signal is unavailable (ENOSYS on Windows)", () => {
|
|
72
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-live-async-resume-enosys-"));
|
|
73
|
+
try {
|
|
74
|
+
const asyncRoot = path.join(root, "runs");
|
|
75
|
+
const resultsDir = path.join(root, "results");
|
|
76
|
+
const asyncDir = path.join(asyncRoot, "run-live");
|
|
77
|
+
writeJson(path.join(asyncDir, "status.json"), {
|
|
78
|
+
runId: "run-live",
|
|
79
|
+
mode: "single",
|
|
80
|
+
state: "running",
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
cwd: root,
|
|
83
|
+
startedAt: 100,
|
|
84
|
+
lastUpdate: Date.now(),
|
|
85
|
+
steps: [{ agent: "worker", status: "running", startedAt: 100 }],
|
|
86
|
+
});
|
|
87
|
+
const target = resolveAsyncResumeTarget({ id: "run-live" }, { asyncDirRoot: asyncRoot, resultsDir });
|
|
88
|
+
assert.equal(target.kind, "live");
|
|
89
|
+
|
|
90
|
+
const state = {
|
|
91
|
+
asyncJobs: new Map([["run-live", {
|
|
92
|
+
asyncId: "run-live",
|
|
93
|
+
asyncDir,
|
|
94
|
+
status: "running" as const,
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
activityState: "needs_attention" as const,
|
|
97
|
+
updatedAt: 100,
|
|
98
|
+
}]]),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = interruptLiveAsyncResumeTarget({
|
|
102
|
+
target,
|
|
103
|
+
state,
|
|
104
|
+
now: () => 7,
|
|
105
|
+
resultsDir,
|
|
106
|
+
kill: () => {
|
|
107
|
+
const error = new Error("kill ENOSYS") as NodeJS.ErrnoException;
|
|
108
|
+
error.code = "ENOSYS";
|
|
109
|
+
throw error;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The signal failed, but the file-based control inbox makes the interrupt succeed.
|
|
114
|
+
assert.deepEqual(result, { ok: true, asyncId: "run-live" });
|
|
115
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "interrupt.json")), true);
|
|
116
|
+
assert.equal(state.asyncJobs.get("run-live")?.activityState, undefined);
|
|
117
|
+
assert.equal(state.asyncJobs.get("run-live")?.updatedAt, 7);
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not report success or leave a stale request when the runner pid is gone", () => {
|
|
124
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-live-async-resume-esrch-"));
|
|
125
|
+
try {
|
|
126
|
+
const asyncRoot = path.join(root, "runs");
|
|
127
|
+
const resultsDir = path.join(root, "results");
|
|
128
|
+
const asyncDir = path.join(asyncRoot, "run-live");
|
|
129
|
+
writeJson(path.join(asyncDir, "status.json"), {
|
|
130
|
+
runId: "run-live",
|
|
131
|
+
mode: "single",
|
|
132
|
+
state: "running",
|
|
133
|
+
pid: 12345,
|
|
134
|
+
cwd: root,
|
|
135
|
+
startedAt: 100,
|
|
136
|
+
lastUpdate: Date.now(),
|
|
137
|
+
steps: [{ agent: "worker", status: "running", startedAt: 100 }],
|
|
138
|
+
});
|
|
139
|
+
const target = resolveAsyncResumeTarget({ id: "run-live" }, {
|
|
140
|
+
asyncDirRoot: asyncRoot,
|
|
141
|
+
resultsDir,
|
|
142
|
+
kill: (_pid, signal) => {
|
|
143
|
+
if (signal === 0) return true;
|
|
144
|
+
const error = new Error("missing process") as NodeJS.ErrnoException;
|
|
145
|
+
error.code = "ESRCH";
|
|
146
|
+
throw error;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
assert.equal(target.kind, "live");
|
|
150
|
+
|
|
151
|
+
const result = interruptLiveAsyncResumeTarget({
|
|
152
|
+
target,
|
|
153
|
+
resultsDir,
|
|
154
|
+
kill: () => {
|
|
155
|
+
const error = new Error("missing process") as NodeJS.ErrnoException;
|
|
156
|
+
error.code = "ESRCH";
|
|
157
|
+
throw error;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(result, { ok: false, message: "Async run run-live is live but no interrupt-capable runner pid was found." });
|
|
162
|
+
assert.equal(fs.existsSync(path.join(asyncDir, "control", "interrupt.json")), false);
|
|
163
|
+
const repairedStatus = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8"));
|
|
164
|
+
assert.equal(repairedStatus.state, "failed");
|
|
165
|
+
assert.equal(fs.existsSync(path.join(resultsDir, "run-live.json")), true);
|
|
166
|
+
} finally {
|
|
167
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildModelCandidates,
|
|
5
|
+
fuzzyResolveModel,
|
|
6
|
+
isRetryableModelFailure,
|
|
7
|
+
normalizeModelSegment,
|
|
8
|
+
resolveModelCandidate,
|
|
9
|
+
resolveSubagentModelOverride,
|
|
10
|
+
} from "../../src/runs/shared/model-fallback.ts";
|
|
11
|
+
|
|
12
|
+
describe("model fallback helpers", () => {
|
|
13
|
+
const availableModels = [
|
|
14
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
15
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
it("keeps explicit provider/model ids unchanged", () => {
|
|
19
|
+
assert.equal(resolveModelCandidate("openai/gpt-5-mini", availableModels), "openai/gpt-5-mini");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("resolves a bare id when there is exactly one registry match", () => {
|
|
23
|
+
assert.equal(resolveModelCandidate("gpt-5-mini", availableModels), "openai/gpt-5-mini");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preserves thinking suffix when resolving a bare id", () => {
|
|
27
|
+
assert.equal(resolveModelCandidate("gpt-5-mini:high", availableModels), "openai/gpt-5-mini:high");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("leaves ambiguous bare ids untouched", () => {
|
|
31
|
+
const ambiguous = [
|
|
32
|
+
...availableModels,
|
|
33
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
|
|
34
|
+
];
|
|
35
|
+
assert.equal(resolveModelCandidate("gpt-5-mini", ambiguous), "gpt-5-mini");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("prefers the current provider when an ambiguous bare id exists there", () => {
|
|
39
|
+
const ambiguous = [
|
|
40
|
+
...availableModels,
|
|
41
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
|
|
42
|
+
];
|
|
43
|
+
assert.equal(resolveModelCandidate("gpt-5-mini", ambiguous, "github-copilot"), "github-copilot/gpt-5-mini");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("falls back to the unique registry match when the current provider does not offer the model", () => {
|
|
47
|
+
assert.equal(resolveModelCandidate("claude-sonnet-4", availableModels, "github-copilot"), "anthropic/claude-sonnet-4");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("builds a deduplicated ordered candidate list", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
buildModelCandidates("gpt-5-mini", ["openai/gpt-5-mini", "anthropic/claude-sonnet-4", "gpt-5-mini"], availableModels),
|
|
53
|
+
["openai/gpt-5-mini", "anthropic/claude-sonnet-4"],
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("applies the current provider preference to fallback candidates too", () => {
|
|
58
|
+
const ambiguous = [
|
|
59
|
+
...availableModels,
|
|
60
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
|
|
61
|
+
];
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
buildModelCandidates("gpt-5-mini", ["gpt-5-mini", "anthropic/claude-sonnet-4"], ambiguous, "github-copilot"),
|
|
64
|
+
["github-copilot/gpt-5-mini", "anthropic/claude-sonnet-4"],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("detects retryable provider/model failures", () => {
|
|
69
|
+
assert.equal(isRetryableModelFailure("rate limit exceeded for provider"), true);
|
|
70
|
+
assert.equal(isRetryableModelFailure("model unavailable"), true);
|
|
71
|
+
assert.equal(isRetryableModelFailure("authentication failed"), true);
|
|
72
|
+
assert.equal(isRetryableModelFailure("Subagent produced no output (possible model cold-start or empty response)."), true);
|
|
73
|
+
assert.equal(isRetryableModelFailure("model load failed"), true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not treat ordinary task/tool failures as retryable model failures", () => {
|
|
77
|
+
assert.equal(isRetryableModelFailure("bash failed (exit 1): command not found"), false);
|
|
78
|
+
assert.equal(isRetryableModelFailure("read failed (exit 1): no such file or directory"), false);
|
|
79
|
+
assert.equal(isRetryableModelFailure(undefined), false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveSubagentModelOverride (cross-session inherit, issue #266)", () => {
|
|
84
|
+
const availableModels = [
|
|
85
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
86
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
87
|
+
];
|
|
88
|
+
const parentModel = { provider: "deepseek", id: "deepseek-v4-flash" };
|
|
89
|
+
|
|
90
|
+
it("inherits the parent session model when no model is requested", () => {
|
|
91
|
+
// The crux of the bug: an undefined model must NOT collapse to `undefined`
|
|
92
|
+
// (which leaves the child to read the shared global settings.json), but
|
|
93
|
+
// must pin the parent session's in-memory provider/id.
|
|
94
|
+
assert.equal(
|
|
95
|
+
resolveSubagentModelOverride(undefined, parentModel, availableModels),
|
|
96
|
+
"deepseek/deepseek-v4-flash",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("inherits the parent session model when the model is the \"inherit\" sentinel", () => {
|
|
101
|
+
assert.equal(
|
|
102
|
+
resolveSubagentModelOverride("inherit", parentModel, availableModels),
|
|
103
|
+
"deepseek/deepseek-v4-flash",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("inherits the parent session model when the agent config sets model: false (delegate)", () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
resolveSubagentModelOverride(false, parentModel, availableModels),
|
|
110
|
+
"deepseek/deepseek-v4-flash",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("treats an empty or whitespace-only model as inherit", () => {
|
|
115
|
+
assert.equal(resolveSubagentModelOverride("", parentModel, availableModels), "deepseek/deepseek-v4-flash");
|
|
116
|
+
assert.equal(resolveSubagentModelOverride(" ", parentModel, availableModels), "deepseek/deepseek-v4-flash");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("trims surrounding whitespace from the \"inherit\" sentinel", () => {
|
|
120
|
+
assert.equal(resolveSubagentModelOverride(" inherit ", parentModel, availableModels), "deepseek/deepseek-v4-flash");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps an explicit provider/id model unchanged", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
resolveSubagentModelOverride("anthropic/claude-sonnet-4", parentModel, availableModels),
|
|
126
|
+
"anthropic/claude-sonnet-4",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("resolves an explicit bare id against the registry, not the parent", () => {
|
|
131
|
+
assert.equal(
|
|
132
|
+
resolveSubagentModelOverride("gpt-5-mini", parentModel, availableModels),
|
|
133
|
+
"openai/gpt-5-mini",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns undefined when inheriting but no parent model is known", () => {
|
|
138
|
+
// No parent session model available: fall back to the prior behavior of
|
|
139
|
+
// emitting no override rather than inventing an invalid one.
|
|
140
|
+
assert.equal(resolveSubagentModelOverride(undefined, undefined, availableModels), undefined);
|
|
141
|
+
assert.equal(resolveSubagentModelOverride("inherit", undefined, availableModels), undefined);
|
|
142
|
+
assert.equal(resolveSubagentModelOverride(false, undefined, availableModels), undefined);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("never emits the literal \"inherit\" string as a model", () => {
|
|
146
|
+
// Regression guard: the old resolveModelCandidate returned "inherit" verbatim
|
|
147
|
+
// (no registry match), which the child rejected and silently fell back to
|
|
148
|
+
// the global default.
|
|
149
|
+
assert.notEqual(resolveSubagentModelOverride("inherit", parentModel, availableModels), "inherit");
|
|
150
|
+
assert.notEqual(resolveSubagentModelOverride("inherit", undefined, availableModels), "inherit");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("fuzzyResolveModel / normalizeModelSegment", () => {
|
|
155
|
+
const registry = [
|
|
156
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
157
|
+
{ provider: "anthropic", id: "claude-haiku-4-5", fullId: "anthropic/claude-haiku-4-5" },
|
|
158
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
159
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
it("normalizes dots, underscores, case, and repeated dashes", () => {
|
|
163
|
+
assert.equal(normalizeModelSegment("Claude.Sonnet_4"), "claude-sonnet-4");
|
|
164
|
+
assert.equal(normalizeModelSegment("GPT--5.Mini"), "gpt-5-mini");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("fuzzy-matches a bare id with separator/case differences", () => {
|
|
168
|
+
assert.equal(fuzzyResolveModel("Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
169
|
+
assert.equal(fuzzyResolveModel("claude.haiku.4.5", registry), "anthropic/claude-haiku-4-5");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("fuzzy-matches a bare id with an optional trailing date stamp", () => {
|
|
173
|
+
assert.equal(fuzzyResolveModel("claude-haiku-4-5-20251001", registry), "anthropic/claude-haiku-4-5");
|
|
174
|
+
assert.equal(fuzzyResolveModel("claude-haiku-4-5-2025-10-01", registry), "anthropic/claude-haiku-4-5");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not strip arbitrary trailing 8-digit numbers as date stamps", () => {
|
|
178
|
+
const numbered = [{ provider: "test", id: "model", fullId: "test/model" }];
|
|
179
|
+
assert.equal(fuzzyResolveModel("model-12345678", numbered), undefined);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fuzzy-matches an undated query against a dated registry id", () => {
|
|
183
|
+
const dated = [
|
|
184
|
+
{ provider: "anthropic", id: "claude-3-5-sonnet-20241022", fullId: "anthropic/claude-3-5-sonnet-20241022" },
|
|
185
|
+
{ provider: "openai", id: "gpt-5-2025-10-01", fullId: "openai/gpt-5-2025-10-01" },
|
|
186
|
+
];
|
|
187
|
+
assert.equal(fuzzyResolveModel("claude-3-5-sonnet", dated), "anthropic/claude-3-5-sonnet-20241022");
|
|
188
|
+
assert.equal(fuzzyResolveModel("gpt-5", dated), "openai/gpt-5-2025-10-01");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("fuzzy-matches a qualified provider/id with case/separator differences", () => {
|
|
192
|
+
assert.equal(fuzzyResolveModel("Anthropic/Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
193
|
+
assert.equal(fuzzyResolveModel("Anthropic:Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
194
|
+
assert.equal(fuzzyResolveModel("anthropic.claude.haiku.4.5", registry), "anthropic/claude-haiku-4-5");
|
|
195
|
+
assert.equal(fuzzyResolveModel("anthropic/claude.haiku.4.5", registry), "anthropic/claude-haiku-4-5");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not switch providers for a qualified query", () => {
|
|
199
|
+
// Named provider has no such model; do not fall back to another provider.
|
|
200
|
+
assert.equal(fuzzyResolveModel("openai/claude-sonnet-4", registry), undefined);
|
|
201
|
+
assert.equal(fuzzyResolveModel("github-copilot/claude-haiku-4-5", registry), undefined);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("prefers the current provider for an ambiguous bare fuzzy id", () => {
|
|
205
|
+
assert.equal(fuzzyResolveModel("GPT.5.Mini", registry, "github-copilot"), "github-copilot/gpt-5-mini");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns undefined for an ambiguous bare fuzzy id with no preferred provider", () => {
|
|
209
|
+
assert.equal(fuzzyResolveModel("gpt-5-mini", registry), undefined);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns undefined when nothing fuzzy-matches", () => {
|
|
213
|
+
assert.equal(fuzzyResolveModel("does-not-exist", registry), undefined);
|
|
214
|
+
assert.equal(fuzzyResolveModel("anthropic/does-not-exist", registry), undefined);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("resolveModelCandidate fuzzy fallback", () => {
|
|
219
|
+
const registry = [
|
|
220
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
221
|
+
{ provider: "anthropic", id: "claude-haiku-4-5", fullId: "anthropic/claude-haiku-4-5" },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
it("resolves a bare id with case/separator differences via fuzzy fallback", () => {
|
|
225
|
+
assert.equal(resolveModelCandidate("Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
226
|
+
assert.equal(resolveModelCandidate("claude.haiku.4.5", registry), "anthropic/claude-haiku-4-5");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("resolves a bare id with a trailing date stamp via fuzzy fallback", () => {
|
|
230
|
+
assert.equal(resolveModelCandidate("claude-haiku-4-5-20251001", registry), "anthropic/claude-haiku-4-5");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("resolves a qualified provider/id with case differences via fuzzy fallback", () => {
|
|
234
|
+
assert.equal(resolveModelCandidate("Anthropic/Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
235
|
+
assert.equal(resolveModelCandidate("Anthropic:Claude-Sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("preserves the thinking suffix through fuzzy resolution", () => {
|
|
239
|
+
assert.equal(resolveModelCandidate("claude.haiku.4.5:high", registry), "anthropic/claude-haiku-4-5:high");
|
|
240
|
+
assert.equal(resolveModelCandidate("anthropic:claude.sonnet.4:high", registry), "anthropic/claude-sonnet-4:high");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("still prefers exact registry matches over fuzzy", () => {
|
|
244
|
+
assert.equal(resolveModelCandidate("anthropic/claude-sonnet-4", registry), "anthropic/claude-sonnet-4");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("leaves an unknown qualified model unchanged instead of switching providers", () => {
|
|
248
|
+
assert.equal(resolveModelCandidate("openai/claude-sonnet-4", registry), "openai/claude-sonnet-4");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("leaves an unknown bare id unchanged when no fuzzy match exists", () => {
|
|
252
|
+
assert.equal(resolveModelCandidate("does-not-exist", registry), "does-not-exist");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("resolveSubagentModelOverride scope enforcement", () => {
|
|
257
|
+
const availableModels = [
|
|
258
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
259
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
260
|
+
{ provider: "deepseek", id: "deepseek-v4", fullId: "deepseek/deepseek-v4" },
|
|
261
|
+
];
|
|
262
|
+
const parentModel = { provider: "deepseek", id: "deepseek-v4" };
|
|
263
|
+
const scope = { enforce: true, allow: ["anthropic/*", "openai/gpt-5-*"] } as const;
|
|
264
|
+
|
|
265
|
+
it("is a no-op when scope is not enforced", () => {
|
|
266
|
+
assert.equal(
|
|
267
|
+
resolveSubagentModelOverride("deepseek/deepseek-v4", parentModel, availableModels, undefined, { scope: { enforce: false, allow: ["anthropic/*"] }, source: "explicit" }),
|
|
268
|
+
"deepseek/deepseek-v4",
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("throws for an explicit out-of-scope model", () => {
|
|
273
|
+
assert.throws(
|
|
274
|
+
() => resolveSubagentModelOverride("deepseek/deepseek-v4", parentModel, availableModels, undefined, { scope, source: "explicit" }),
|
|
275
|
+
/outside the configured subagent model scope/,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("warns (and still returns the model) for an inherited out-of-scope model", () => {
|
|
280
|
+
const warnings: string[] = [];
|
|
281
|
+
const resolved = resolveSubagentModelOverride("deepseek/deepseek-v4", parentModel, availableModels, undefined, {
|
|
282
|
+
scope,
|
|
283
|
+
source: "inherited",
|
|
284
|
+
onWarn: (v) => warnings.push(v.message),
|
|
285
|
+
});
|
|
286
|
+
assert.equal(resolved, "deepseek/deepseek-v4");
|
|
287
|
+
assert.equal(warnings.length, 1);
|
|
288
|
+
assert.match(warnings[0]!, /outside the configured subagent model scope/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("warns for an inherited parent-session model that is out of scope", () => {
|
|
292
|
+
const warnings: string[] = [];
|
|
293
|
+
// No explicit model requested: inherits the parent (deepseek), which is out of scope.
|
|
294
|
+
const resolved = resolveSubagentModelOverride(undefined, parentModel, availableModels, undefined, {
|
|
295
|
+
scope,
|
|
296
|
+
onWarn: (v) => warnings.push(v.message),
|
|
297
|
+
});
|
|
298
|
+
assert.equal(resolved, "deepseek/deepseek-v4");
|
|
299
|
+
assert.equal(warnings.length, 1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("passes through an in-scope explicit model without warning or error", () => {
|
|
303
|
+
const warnings: string[] = [];
|
|
304
|
+
const resolved = resolveSubagentModelOverride("gpt-5-mini", parentModel, availableModels, undefined, {
|
|
305
|
+
scope,
|
|
306
|
+
source: "explicit",
|
|
307
|
+
onWarn: (v) => warnings.push(v.message),
|
|
308
|
+
});
|
|
309
|
+
assert.equal(resolved, "openai/gpt-5-mini");
|
|
310
|
+
assert.equal(warnings.length, 0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("checks the resolved (canonicalized) model against the scope", () => {
|
|
314
|
+
// Fuzzy-resolves Claude-Sonnet-4 -> anthropic/claude-sonnet-4, which is in scope.
|
|
315
|
+
const warnings: string[] = [];
|
|
316
|
+
const resolved = resolveSubagentModelOverride("Claude-Sonnet-4", parentModel, availableModels, undefined, {
|
|
317
|
+
scope,
|
|
318
|
+
source: "explicit",
|
|
319
|
+
onWarn: (v) => warnings.push(v.message),
|
|
320
|
+
});
|
|
321
|
+
assert.equal(resolved, "anthropic/claude-sonnet-4");
|
|
322
|
+
assert.equal(warnings.length, 0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("ignores a thinking suffix when checking scope", () => {
|
|
326
|
+
const warnings: string[] = [];
|
|
327
|
+
const resolved = resolveSubagentModelOverride("gpt-5-mini:high", parentModel, availableModels, undefined, {
|
|
328
|
+
scope,
|
|
329
|
+
source: "explicit",
|
|
330
|
+
onWarn: (v) => warnings.push(v.message),
|
|
331
|
+
});
|
|
332
|
+
assert.equal(resolved, "openai/gpt-5-mini:high");
|
|
333
|
+
assert.equal(warnings.length, 0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("warns for out-of-scope fallback models while keeping them available", () => {
|
|
337
|
+
const warnings: string[] = [];
|
|
338
|
+
const candidates = buildModelCandidates("gpt-5-mini", ["deepseek/deepseek-v4"], availableModels, undefined, {
|
|
339
|
+
scope,
|
|
340
|
+
onWarn: (v) => warnings.push(v.message),
|
|
341
|
+
});
|
|
342
|
+
assert.deepEqual(candidates, ["openai/gpt-5-mini", "deepseek/deepseek-v4"]);
|
|
343
|
+
assert.equal(warnings.length, 1);
|
|
344
|
+
assert.match(warnings[0]!, /deepseek\/deepseek-v4/);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { findModelInfo, getSupportedThinkingLevels, type ModelInfo } from "../../src/shared/model-info.ts";
|
|
4
|
+
|
|
5
|
+
describe("model info helpers", () => {
|
|
6
|
+
const ambiguousModels: ModelInfo[] = [
|
|
7
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", reasoning: true, thinkingLevelMap: { high: "high" } },
|
|
8
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini", reasoning: true, thinkingLevelMap: { off: null, high: "high", xhigh: "xhigh" } },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
it("does not choose arbitrary metadata for ambiguous bare model ids", () => {
|
|
12
|
+
assert.equal(findModelInfo("gpt-5-mini", ambiguousModels), undefined);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("uses the preferred provider for ambiguous bare model metadata", () => {
|
|
16
|
+
assert.equal(findModelInfo("gpt-5-mini", ambiguousModels, "github-copilot")?.fullId, "github-copilot/gpt-5-mini");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches provider-qualified model metadata before bare ids", () => {
|
|
20
|
+
assert.equal(findModelInfo("openai/gpt-5-mini:high", ambiguousModels, "github-copilot")?.fullId, "openai/gpt-5-mini");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the legacy full thinking list for reasoning models without per-level metadata", () => {
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
getSupportedThinkingLevels({ provider: "openai", id: "gpt-5", fullId: "openai/gpt-5", reasoning: true }),
|
|
26
|
+
["off", "minimal", "low", "medium", "high", "xhigh"],
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the legacy full thinking list when older model metadata omits reasoning", () => {
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
getSupportedThinkingLevels({ provider: "openai", id: "gpt-5", fullId: "openai/gpt-5" }),
|
|
33
|
+
["off", "minimal", "low", "medium", "high", "xhigh"],
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("filters levels only when per-level metadata is present", () => {
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
getSupportedThinkingLevels({
|
|
40
|
+
provider: "deepseek",
|
|
41
|
+
id: "deepseek-v4-pro",
|
|
42
|
+
fullId: "deepseek/deepseek-v4-pro",
|
|
43
|
+
reasoning: true,
|
|
44
|
+
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high", xhigh: "max" },
|
|
45
|
+
}),
|
|
46
|
+
["off", "high", "xhigh"],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("honors metadata that marks off unsupported", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
getSupportedThinkingLevels({
|
|
53
|
+
provider: "always-thinking",
|
|
54
|
+
id: "model",
|
|
55
|
+
fullId: "always-thinking/model",
|
|
56
|
+
reasoning: true,
|
|
57
|
+
thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high" },
|
|
58
|
+
}),
|
|
59
|
+
["high"],
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
6
|
+
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
7
|
+
|
|
8
|
+
let tempHome = "";
|
|
9
|
+
let tempProject = "";
|
|
10
|
+
const originalHome = process.env.HOME;
|
|
11
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
12
|
+
const originalPiCodingAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
13
|
+
const originalExtraAgentDirs = process.env.SELESAI_SUBAGENT_EXTRA_AGENT_DIRS;
|
|
14
|
+
|
|
15
|
+
function writeJson(filePath: string, value: unknown): void {
|
|
16
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
17
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("subagents.modelScope discovery", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-scope-home-"));
|
|
23
|
+
tempProject = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-scope-project-"));
|
|
24
|
+
process.env.HOME = tempHome;
|
|
25
|
+
process.env.USERPROFILE = tempHome;
|
|
26
|
+
delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
27
|
+
delete process.env.SELESAI_SUBAGENT_EXTRA_AGENT_DIRS;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
32
|
+
else process.env.HOME = originalHome;
|
|
33
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
34
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
35
|
+
if (originalPiCodingAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
36
|
+
else process.env.SELESAI_CODING_AGENT_DIR = originalPiCodingAgentDir;
|
|
37
|
+
if (originalExtraAgentDirs === undefined) delete process.env.SELESAI_SUBAGENT_EXTRA_AGENT_DIRS;
|
|
38
|
+
else process.env.SELESAI_SUBAGENT_EXTRA_AGENT_DIRS = originalExtraAgentDirs;
|
|
39
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
40
|
+
fs.rmSync(tempProject, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("exposes a user modelScope from discoverAgents", () => {
|
|
44
|
+
writeJson(path.join(tempHome, ".selesai", "agent", "settings.json"), {
|
|
45
|
+
subagents: { modelScope: { enforce: true, allow: ["anthropic/*", "openai/gpt-5-*"] } },
|
|
46
|
+
});
|
|
47
|
+
const result = discoverAgents(tempProject, "both");
|
|
48
|
+
assert.deepEqual(result.modelScope, { enforce: true, allow: ["anthropic/*", "openai/gpt-5-*"] });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns undefined when no modelScope is configured", () => {
|
|
52
|
+
assert.equal(discoverAgents(tempProject, "both").modelScope, undefined);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prefers project modelScope over user modelScope", () => {
|
|
56
|
+
writeJson(path.join(tempHome, ".selesai", "agent", "settings.json"), {
|
|
57
|
+
subagents: { modelScope: { enforce: true, allow: ["anthropic/*"] } },
|
|
58
|
+
});
|
|
59
|
+
writeJson(path.join(tempProject, ".selesai", "settings.json"), {
|
|
60
|
+
subagents: { modelScope: { enforce: false, allow: ["deepseek/*"] } },
|
|
61
|
+
});
|
|
62
|
+
const result = discoverAgents(tempProject, "both");
|
|
63
|
+
assert.deepEqual(result.modelScope, { enforce: false, allow: ["deepseek/*"] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("falls back to user modelScope when project does not set one", () => {
|
|
67
|
+
writeJson(path.join(tempHome, ".selesai", "agent", "settings.json"), {
|
|
68
|
+
subagents: { modelScope: { enforce: true, allow: ["anthropic/*"] } },
|
|
69
|
+
});
|
|
70
|
+
const result = discoverAgents(tempProject, "both");
|
|
71
|
+
assert.deepEqual(result.modelScope, { enforce: true, allow: ["anthropic/*"] });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects an invalid modelScope config at discovery time", () => {
|
|
75
|
+
writeJson(path.join(tempHome, ".selesai", "agent", "settings.json"), {
|
|
76
|
+
subagents: { modelScope: { enforce: true } },
|
|
77
|
+
});
|
|
78
|
+
assert.throws(() => discoverAgents(tempProject, "both"), /without a non-empty 'allow'/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects a malformed modelScope value at discovery time", () => {
|
|
82
|
+
writeJson(path.join(tempHome, ".selesai", "agent", "settings.json"), {
|
|
83
|
+
subagents: { modelScope: "anthropic/*" },
|
|
84
|
+
});
|
|
85
|
+
assert.throws(() => discoverAgents(tempProject, "both"), /invalid 'modelScope'/);
|
|
86
|
+
});
|
|
87
|
+
});
|