@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,1306 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { MockPi } from "../support/helpers.ts";
|
|
6
|
+
import { createEventBus, createMockPi, createTempDir, events, removeTempDir, tryImport } from "../support/helpers.ts";
|
|
7
|
+
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
8
|
+
import { INTERCOM_DETACH_REQUEST_EVENT } from "../../src/shared/types.ts";
|
|
9
|
+
|
|
10
|
+
interface ExecutorModule {
|
|
11
|
+
createSubagentExecutor?: (...args: unknown[]) => {
|
|
12
|
+
execute: (
|
|
13
|
+
id: string,
|
|
14
|
+
params: Record<string, unknown>,
|
|
15
|
+
signal: AbortSignal,
|
|
16
|
+
onUpdate: ((result: unknown) => void) | undefined,
|
|
17
|
+
ctx: unknown,
|
|
18
|
+
) => Promise<{
|
|
19
|
+
isError?: boolean;
|
|
20
|
+
content: Array<{ text?: string }>;
|
|
21
|
+
details?: {
|
|
22
|
+
context?: "fresh" | "fork";
|
|
23
|
+
mode?: "single" | "parallel" | "chain";
|
|
24
|
+
asyncId?: string;
|
|
25
|
+
results?: Array<{ detached?: boolean; exitCode?: number; skills?: string[] }>;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AsyncExecutionModule {
|
|
32
|
+
isAsyncAvailable?: () => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProgressUpdate {
|
|
36
|
+
details?: {
|
|
37
|
+
progress?: Array<{ status?: string; currentTool?: string }>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const executorMod = await tryImport<ExecutorModule>("./src/runs/foreground/subagent-executor.ts");
|
|
42
|
+
const asyncExecutionMod = await tryImport<AsyncExecutionModule>("./src/runs/background/async-execution.ts");
|
|
43
|
+
const available = !!executorMod;
|
|
44
|
+
const createSubagentExecutor = executorMod?.createSubagentExecutor;
|
|
45
|
+
const asyncAvailable = asyncExecutionMod?.isAsyncAvailable?.() === true;
|
|
46
|
+
const originalHome = process.env.HOME;
|
|
47
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
48
|
+
|
|
49
|
+
interface SessionStubOptions {
|
|
50
|
+
sessionFile?: string;
|
|
51
|
+
leafId?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SessionManagerStub {
|
|
55
|
+
getSessionId(): string;
|
|
56
|
+
getSessionFile(): string | undefined;
|
|
57
|
+
getLeafId(): string | null;
|
|
58
|
+
openSession(sessionFile: string): { createBranchedSession(leafId: string): string | undefined };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeSessionManagerRecorder(options: SessionStubOptions = {}) {
|
|
62
|
+
const manager: SessionManagerStub = {
|
|
63
|
+
getSessionId: () => "session-123",
|
|
64
|
+
getSessionFile: () => options.sessionFile,
|
|
65
|
+
getLeafId: () => (options.leafId === undefined ? "leaf-current" : options.leafId),
|
|
66
|
+
openSession: () => ({
|
|
67
|
+
createBranchedSession: () => "/tmp/child.jsonl",
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
return { manager };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeState(cwd: string) {
|
|
74
|
+
return {
|
|
75
|
+
baseCwd: cwd,
|
|
76
|
+
currentSessionId: null,
|
|
77
|
+
asyncJobs: new Map(),
|
|
78
|
+
cleanupTimers: new Map(),
|
|
79
|
+
lastUiContext: null,
|
|
80
|
+
poller: null,
|
|
81
|
+
completionSeen: new Map(),
|
|
82
|
+
watcher: null,
|
|
83
|
+
watcherRestartTimer: null,
|
|
84
|
+
resultFileCoalescer: {
|
|
85
|
+
schedule: () => false,
|
|
86
|
+
clear: () => {},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("fork context execution wiring", { skip: !available ? "subagent executor not importable" : undefined }, () => {
|
|
92
|
+
let tempDir: string;
|
|
93
|
+
let mockPi: MockPi;
|
|
94
|
+
|
|
95
|
+
before(() => {
|
|
96
|
+
mockPi = createMockPi();
|
|
97
|
+
mockPi.install();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
after(() => {
|
|
101
|
+
mockPi.uninstall();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
tempDir = createTempDir("pi-subagent-fork-test-");
|
|
106
|
+
mockPi.reset();
|
|
107
|
+
mockPi.onCall({ output: "ok" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
112
|
+
else process.env.HOME = originalHome;
|
|
113
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
114
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
115
|
+
removeTempDir(tempDir);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function makeExecutor() {
|
|
119
|
+
return makeExecutorWithConfig({});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function makeExecutorWithConfig(config: Record<string, unknown>) {
|
|
123
|
+
return makeExecutorWithDiscoverAgents(() => ({
|
|
124
|
+
agents: [
|
|
125
|
+
{ name: "echo", description: "Echo test agent" },
|
|
126
|
+
{ name: "second", description: "Second test agent" },
|
|
127
|
+
],
|
|
128
|
+
projectAgentsDir: null,
|
|
129
|
+
}), config);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function makeExecutorWithDiscoverAgents(discoverAgentsImpl: typeof discoverAgents, config: Record<string, unknown> = {}) {
|
|
133
|
+
let sessionName: string | undefined;
|
|
134
|
+
const eventsApi = createEventBus();
|
|
135
|
+
return Object.assign(createSubagentExecutor({
|
|
136
|
+
pi: {
|
|
137
|
+
events: eventsApi,
|
|
138
|
+
getSessionName: () => sessionName,
|
|
139
|
+
setSessionName: (name: string) => {
|
|
140
|
+
sessionName = name;
|
|
141
|
+
},
|
|
142
|
+
sendMessage: () => {},
|
|
143
|
+
},
|
|
144
|
+
state: makeState(tempDir),
|
|
145
|
+
config,
|
|
146
|
+
asyncByDefault: false,
|
|
147
|
+
tempArtifactsDir: tempDir,
|
|
148
|
+
getSubagentSessionRoot: () => tempDir,
|
|
149
|
+
expandTilde: (p: string) => p,
|
|
150
|
+
discoverAgents: discoverAgentsImpl,
|
|
151
|
+
}), { eventsApi });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readCallArgs(): string[] {
|
|
155
|
+
const callFile = fs.readdirSync(mockPi.dir)
|
|
156
|
+
.filter((name) => name.startsWith("call-") && name.endsWith(".json"))
|
|
157
|
+
.sort()
|
|
158
|
+
.at(-1);
|
|
159
|
+
assert.ok(callFile, "expected a recorded mock pi call");
|
|
160
|
+
return readRecordedArgs(callFile);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readAllCallArgs(): string[][] {
|
|
164
|
+
return fs.readdirSync(mockPi.dir)
|
|
165
|
+
.filter((name) => name.startsWith("call-") && name.endsWith(".json"))
|
|
166
|
+
.sort()
|
|
167
|
+
.map(readRecordedArgs);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readRecordedArgs(callFile: string): string[] {
|
|
171
|
+
const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8"));
|
|
172
|
+
assert.equal(typeof payload, "object", "expected recorded args payload");
|
|
173
|
+
assert.notEqual(payload, null, "expected recorded args payload");
|
|
174
|
+
assert.ok("args" in payload, "expected recorded args payload");
|
|
175
|
+
assert.ok(Array.isArray(payload.args), "expected recorded args");
|
|
176
|
+
return payload.args;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readSessionArgsFromCalls(): string[] {
|
|
180
|
+
return readAllCallArgs()
|
|
181
|
+
.map((args) => {
|
|
182
|
+
const sessionIndex = args.indexOf("--session");
|
|
183
|
+
if (sessionIndex === -1) return undefined;
|
|
184
|
+
const sessionFile = args[sessionIndex + 1];
|
|
185
|
+
assert.ok(sessionFile, "expected a session file after --session");
|
|
186
|
+
return sessionFile;
|
|
187
|
+
})
|
|
188
|
+
.filter((sessionFile): sessionFile is string => Boolean(sessionFile));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readCallArgsForTask(taskText: string): string[] {
|
|
192
|
+
const args = readAllCallArgs().find((callArgs) => {
|
|
193
|
+
const prompt = callArgs.at(-1) ?? "";
|
|
194
|
+
return prompt.startsWith(`Task: ${taskText}\n`)
|
|
195
|
+
|| prompt.includes(`\n\nTask:\n${taskText}\n`);
|
|
196
|
+
});
|
|
197
|
+
assert.ok(args, `expected a recorded mock pi call for task '${taskText}'`);
|
|
198
|
+
return args;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readSessionArg(args: string[]): string {
|
|
202
|
+
const sessionIndex = args.indexOf("--session");
|
|
203
|
+
assert.notEqual(sessionIndex, -1);
|
|
204
|
+
const sessionFile = args[sessionIndex + 1];
|
|
205
|
+
assert.ok(sessionFile, "expected a session file after --session");
|
|
206
|
+
return sessionFile;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function makeForkingSessionManagerRecorder(options: { sessionFile: string; leafId: string }) {
|
|
210
|
+
const openedPaths: string[] = [];
|
|
211
|
+
const branchedLeafIds: string[] = [];
|
|
212
|
+
let counter = 0;
|
|
213
|
+
fs.mkdirSync(path.dirname(options.sessionFile), { recursive: true });
|
|
214
|
+
fs.writeFileSync(options.sessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
215
|
+
const manager = {
|
|
216
|
+
getSessionId: () => "session-123",
|
|
217
|
+
getSessionFile: () => options.sessionFile,
|
|
218
|
+
getLeafId: () => options.leafId,
|
|
219
|
+
openSession: (sessionFile: string) => {
|
|
220
|
+
openedPaths.push(sessionFile);
|
|
221
|
+
return {
|
|
222
|
+
createBranchedSession: (leafId: string) => {
|
|
223
|
+
branchedLeafIds.push(leafId);
|
|
224
|
+
counter++;
|
|
225
|
+
const childSessionFile = path.join(tempDir, `fork-${counter}.jsonl`);
|
|
226
|
+
fs.writeFileSync(childSessionFile, '{"type":"session","version":1,"id":"child","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
227
|
+
return childSessionFile;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
return { manager, openedPaths, branchedLeafIds };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writeAgent(projectRoot: string, name: string, model: string): void {
|
|
236
|
+
const filePath = path.join(projectRoot, ".selesai", "agents", `${name}.md`);
|
|
237
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
filePath,
|
|
240
|
+
`---\nname: ${name}\ndescription: ${name} agent\nmodel: ${model}\n---\n\nUse ${model}.\n`,
|
|
241
|
+
"utf-8",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function writeProjectOverride(projectRoot: string, agentName: string, model: string): void {
|
|
246
|
+
const settingsPath = path.join(projectRoot, ".selesai", "settings.json");
|
|
247
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
248
|
+
fs.writeFileSync(
|
|
249
|
+
settingsPath,
|
|
250
|
+
JSON.stringify({ subagents: { agentOverrides: { [agentName]: { model } } } }, null, 2),
|
|
251
|
+
"utf-8",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writePackageSkill(packageRoot: string, skillName: string): void {
|
|
256
|
+
const skillDir = path.join(packageRoot, "skills", skillName);
|
|
257
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
258
|
+
fs.writeFileSync(
|
|
259
|
+
path.join(packageRoot, "package.json"),
|
|
260
|
+
JSON.stringify({ name: `${skillName}-pkg`, version: "1.0.0", pi: { skills: [`./skills/${skillName}`] } }, null, 2),
|
|
261
|
+
"utf-8",
|
|
262
|
+
);
|
|
263
|
+
fs.writeFileSync(
|
|
264
|
+
path.join(skillDir, "SKILL.md"),
|
|
265
|
+
`---\nname: ${skillName}\ndescription: test skill\n---\nbody\n`,
|
|
266
|
+
"utf-8",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function makeCtx(sessionManager: SessionManagerStub) {
|
|
271
|
+
return {
|
|
272
|
+
cwd: tempDir,
|
|
273
|
+
hasUI: false,
|
|
274
|
+
ui: {},
|
|
275
|
+
modelRegistry: { getAvailable: () => [] },
|
|
276
|
+
sessionManager,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
it("runs a single agent when task is omitted", async () => {
|
|
281
|
+
const { manager } = makeSessionManagerRecorder();
|
|
282
|
+
const executor = makeExecutor();
|
|
283
|
+
|
|
284
|
+
const result = await executor.execute(
|
|
285
|
+
"id",
|
|
286
|
+
{ agent: "echo" },
|
|
287
|
+
new AbortController().signal,
|
|
288
|
+
undefined,
|
|
289
|
+
makeCtx(manager),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
assert.equal(result.isError, undefined);
|
|
293
|
+
const args = readCallArgs();
|
|
294
|
+
assert.ok((args.at(-1) ?? "").startsWith("Task: \n\n## Acceptance Contract"));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("does not treat top-level agent as single mode when tasks are present", async () => {
|
|
298
|
+
const { manager } = makeSessionManagerRecorder();
|
|
299
|
+
const executor = makeExecutor();
|
|
300
|
+
|
|
301
|
+
const result = await executor.execute(
|
|
302
|
+
"id",
|
|
303
|
+
{ agent: "echo", tasks: [{ agent: "second", task: "parallel task" }] },
|
|
304
|
+
new AbortController().signal,
|
|
305
|
+
undefined,
|
|
306
|
+
makeCtx(manager),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
assert.equal(result.isError, undefined);
|
|
310
|
+
const args = readCallArgs();
|
|
311
|
+
assert.ok((args.at(-1) ?? "").startsWith("Task: parallel task\n\n## Acceptance Contract"));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("uses agent defaultContext fork when launch context is omitted", async () => {
|
|
315
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
316
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
317
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
318
|
+
agents: [
|
|
319
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
320
|
+
],
|
|
321
|
+
projectAgentsDir: null,
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
const result = await executor.execute(
|
|
325
|
+
"id",
|
|
326
|
+
{ agent: "worker", task: "test" },
|
|
327
|
+
new AbortController().signal,
|
|
328
|
+
undefined,
|
|
329
|
+
makeCtx(manager),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
assert.equal(result.isError, undefined);
|
|
333
|
+
assert.equal(result.details?.context, "fork");
|
|
334
|
+
assert.deepEqual(openedPaths, [parentSessionFile]);
|
|
335
|
+
assert.deepEqual(branchedLeafIds, ["leaf-current"]);
|
|
336
|
+
assert.deepEqual(readSessionArgsFromCalls(), [path.join(tempDir, "fork-1.jsonl")]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("sanitizes inherited signed thinking and forces child thinking off", async () => {
|
|
340
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
341
|
+
const childSessionFile = path.join(tempDir, "fork-with-thinking.jsonl");
|
|
342
|
+
fs.writeFileSync(parentSessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
343
|
+
const manager = {
|
|
344
|
+
getSessionId: () => "session-123",
|
|
345
|
+
getSessionFile: () => parentSessionFile,
|
|
346
|
+
getLeafId: () => "assistant-1",
|
|
347
|
+
openSession: () => ({
|
|
348
|
+
createBranchedSession: () => {
|
|
349
|
+
fs.writeFileSync(childSessionFile, [
|
|
350
|
+
{ type: "session", version: 1, id: "child", timestamp: "2026-04-16T00:00:00.000Z", cwd: "/tmp", parentSession: parentSessionFile },
|
|
351
|
+
{ type: "message", id: "user-1", parentId: null, timestamp: "2026-04-16T00:00:01.000Z", message: { role: "user", content: "prompt" } },
|
|
352
|
+
{ type: "message", id: "assistant-1", parentId: "user-1", timestamp: "2026-04-16T00:00:02.000Z", message: { role: "assistant", provider: "anthropic", api: "anthropic-messages", model: "anthropic/claude-sonnet-4-5", content: [{ type: "thinking", thinking: "private chain", thinkingSignature: "signed" }, { type: "text", text: "answer" }] } },
|
|
353
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n", "utf-8");
|
|
354
|
+
return childSessionFile;
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
359
|
+
agents: [
|
|
360
|
+
{ name: "worker", description: "Worker", defaultContext: "fork", model: "anthropic/claude-sonnet-4-5:high", thinking: "high" },
|
|
361
|
+
],
|
|
362
|
+
projectAgentsDir: null,
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
const result = await executor.execute(
|
|
366
|
+
"id",
|
|
367
|
+
{ agent: "worker", task: "test" },
|
|
368
|
+
new AbortController().signal,
|
|
369
|
+
undefined,
|
|
370
|
+
makeCtx(manager),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
assert.equal(result.isError, undefined);
|
|
374
|
+
const args = readCallArgs();
|
|
375
|
+
assert.equal(args[args.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off");
|
|
376
|
+
const entries = fs.readFileSync(childSessionFile, "utf-8").trim().split("\n").map((line) => JSON.parse(line));
|
|
377
|
+
assert.deepEqual(entries[2].message.content, [{ type: "text", text: "answer" }]);
|
|
378
|
+
assert.equal(entries[3].type, "thinking_level_change");
|
|
379
|
+
assert.equal(entries[3].thinkingLevel, "off");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("forces every foreground fallback attempt off after sanitizing inherited signed thinking", async () => {
|
|
383
|
+
mockPi.reset();
|
|
384
|
+
mockPi.onCall({
|
|
385
|
+
jsonl: [{
|
|
386
|
+
type: "message_end",
|
|
387
|
+
message: {
|
|
388
|
+
role: "assistant",
|
|
389
|
+
content: [{ type: "text", text: "temporary provider failure" }],
|
|
390
|
+
model: "openai/gpt-5-mini",
|
|
391
|
+
errorMessage: "rate limit exceeded",
|
|
392
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
393
|
+
},
|
|
394
|
+
}],
|
|
395
|
+
exitCode: 1,
|
|
396
|
+
});
|
|
397
|
+
mockPi.onCall({ output: "Recovered on fallback" });
|
|
398
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
399
|
+
const childSessionFile = path.join(tempDir, "fork-with-thinking.jsonl");
|
|
400
|
+
fs.writeFileSync(parentSessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
401
|
+
const manager = {
|
|
402
|
+
getSessionId: () => "session-123",
|
|
403
|
+
getSessionFile: () => parentSessionFile,
|
|
404
|
+
getLeafId: () => "assistant-1",
|
|
405
|
+
openSession: () => ({
|
|
406
|
+
createBranchedSession: () => {
|
|
407
|
+
fs.writeFileSync(childSessionFile, [
|
|
408
|
+
{ type: "session", version: 1, id: "child", timestamp: "2026-04-16T00:00:00.000Z", cwd: "/tmp", parentSession: parentSessionFile },
|
|
409
|
+
{ type: "message", id: "assistant-1", parentId: null, timestamp: "2026-04-16T00:00:02.000Z", message: { role: "assistant", provider: "anthropic", api: "anthropic-messages", model: "anthropic/claude-sonnet-4-5", content: [{ type: "thinking", thinking: "private chain", thinkingSignature: "signed" }, { type: "text", text: "answer" }] } },
|
|
410
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n", "utf-8");
|
|
411
|
+
return childSessionFile;
|
|
412
|
+
},
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
416
|
+
agents: [
|
|
417
|
+
{ name: "worker", description: "Worker", defaultContext: "fork", model: "openai/gpt-5-mini:high", fallbackModels: ["anthropic/claude-sonnet-4:low"], thinking: "high" },
|
|
418
|
+
],
|
|
419
|
+
projectAgentsDir: null,
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
const result = await executor.execute(
|
|
423
|
+
"id",
|
|
424
|
+
{ agent: "worker", task: "test" },
|
|
425
|
+
new AbortController().signal,
|
|
426
|
+
undefined,
|
|
427
|
+
makeCtx(manager),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
assert.equal(result.isError, undefined);
|
|
431
|
+
const modelArgs = readAllCallArgs().map((args) => args[args.indexOf("--model") + 1]);
|
|
432
|
+
assert.deepEqual(modelArgs, ["openai/gpt-5-mini:off", "anthropic/claude-sonnet-4:off"]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("keeps default-fork context on run-path errors", async () => {
|
|
436
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
437
|
+
const { manager } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
438
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
439
|
+
agents: [
|
|
440
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
441
|
+
],
|
|
442
|
+
projectAgentsDir: null,
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
const ctx = makeCtx(manager);
|
|
446
|
+
ctx.modelRegistry.getAvailable = () => {
|
|
447
|
+
throw new Error("model registry unavailable");
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const result = await executor.execute(
|
|
451
|
+
"id",
|
|
452
|
+
{ agent: "worker" },
|
|
453
|
+
new AbortController().signal,
|
|
454
|
+
undefined,
|
|
455
|
+
ctx,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
assert.equal(result.isError, true);
|
|
459
|
+
assert.match(result.content[0]?.text ?? "", /model registry unavailable/);
|
|
460
|
+
assert.equal(result.details?.context, "fork");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("keeps explicit fresh context over agent defaultContext fork", async () => {
|
|
464
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
465
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
466
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
467
|
+
agents: [
|
|
468
|
+
{ name: "oracle", description: "Oracle", defaultContext: "fork" },
|
|
469
|
+
],
|
|
470
|
+
projectAgentsDir: null,
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
const result = await executor.execute(
|
|
474
|
+
"id",
|
|
475
|
+
{ agent: "oracle", task: "test", context: "fresh" },
|
|
476
|
+
new AbortController().signal,
|
|
477
|
+
undefined,
|
|
478
|
+
makeCtx(manager),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
assert.equal(result.isError, undefined);
|
|
482
|
+
assert.equal(result.details?.context, undefined);
|
|
483
|
+
assert.deepEqual(openedPaths, []);
|
|
484
|
+
assert.deepEqual(branchedLeafIds, []);
|
|
485
|
+
assert.notEqual(readSessionArgsFromCalls()[0], path.join(tempDir, "fork-1.jsonl"));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("uses each agent defaultContext for top-level parallel when launch context is omitted", async () => {
|
|
489
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
490
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
491
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
492
|
+
agents: [
|
|
493
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
494
|
+
{ name: "second", description: "Second" },
|
|
495
|
+
],
|
|
496
|
+
projectAgentsDir: null,
|
|
497
|
+
}));
|
|
498
|
+
|
|
499
|
+
const result = await executor.execute(
|
|
500
|
+
"id",
|
|
501
|
+
{ tasks: [{ agent: "worker", task: "one" }, { agent: "second", task: "two" }] },
|
|
502
|
+
new AbortController().signal,
|
|
503
|
+
undefined,
|
|
504
|
+
makeCtx(manager),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
assert.equal(result.isError, undefined);
|
|
508
|
+
assert.equal(result.details?.context, "fork");
|
|
509
|
+
assert.deepEqual(openedPaths, [parentSessionFile]);
|
|
510
|
+
assert.deepEqual(branchedLeafIds, ["leaf-current"]);
|
|
511
|
+
const workerArgs = readCallArgsForTask("one");
|
|
512
|
+
const freshArgs = readCallArgsForTask("two");
|
|
513
|
+
assert.match(workerArgs.at(-1) ?? "", /delegated subagent running from a fork/);
|
|
514
|
+
assert.doesNotMatch(freshArgs.at(-1) ?? "", /delegated subagent running from a fork/);
|
|
515
|
+
assert.equal(readSessionArg(workerArgs), path.join(tempDir, "fork-1.jsonl"));
|
|
516
|
+
assert.notEqual(readSessionArg(freshArgs), path.join(tempDir, "fork-1.jsonl"));
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("keeps explicit fresh context over top-level parallel agent defaultContext fork", async () => {
|
|
520
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
521
|
+
const { manager, openedPaths } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
522
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
523
|
+
agents: [
|
|
524
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
525
|
+
{ name: "second", description: "Second" },
|
|
526
|
+
],
|
|
527
|
+
projectAgentsDir: null,
|
|
528
|
+
}));
|
|
529
|
+
|
|
530
|
+
const result = await executor.execute(
|
|
531
|
+
"id",
|
|
532
|
+
{ tasks: [{ agent: "worker", task: "one" }, { agent: "second", task: "two" }], context: "fresh" },
|
|
533
|
+
new AbortController().signal,
|
|
534
|
+
undefined,
|
|
535
|
+
makeCtx(manager),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
assert.equal(result.isError, undefined);
|
|
539
|
+
assert.equal(result.details?.context, undefined);
|
|
540
|
+
assert.deepEqual(openedPaths, []);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("uses each agent defaultContext for chain runs when launch context is omitted", async () => {
|
|
544
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
545
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
546
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
547
|
+
agents: [
|
|
548
|
+
{ name: "echo", description: "Echo" },
|
|
549
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
550
|
+
],
|
|
551
|
+
projectAgentsDir: null,
|
|
552
|
+
}));
|
|
553
|
+
|
|
554
|
+
const result = await executor.execute(
|
|
555
|
+
"id",
|
|
556
|
+
{ chain: [{ agent: "echo", task: "scan" }, { agent: "worker", task: "write" }], clarify: false },
|
|
557
|
+
new AbortController().signal,
|
|
558
|
+
undefined,
|
|
559
|
+
makeCtx(manager),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
assert.equal(result.isError, undefined);
|
|
563
|
+
assert.equal(result.details?.context, "fork");
|
|
564
|
+
assert.deepEqual(openedPaths, [parentSessionFile]);
|
|
565
|
+
assert.deepEqual(branchedLeafIds, ["leaf-current"]);
|
|
566
|
+
const scanArgs = readCallArgsForTask("scan");
|
|
567
|
+
const writeArgs = readCallArgsForTask("write");
|
|
568
|
+
assert.doesNotMatch(scanArgs.at(-1) ?? "", /delegated subagent running from a fork/);
|
|
569
|
+
assert.match(writeArgs.at(-1) ?? "", /delegated subagent running from a fork/);
|
|
570
|
+
const forkSessionArgs = readSessionArgsFromCalls().filter((sessionFile) => path.basename(sessionFile).startsWith("fork-"));
|
|
571
|
+
assert.deepEqual(forkSessionArgs, [path.join(tempDir, "fork-1.jsonl")]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("fails before launching mixed parallel children when a default-fork session cannot branch", async () => {
|
|
575
|
+
const parentSessionFile = path.join(tempDir, "parent-mixed-fail.jsonl");
|
|
576
|
+
fs.writeFileSync(parentSessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
577
|
+
const manager = {
|
|
578
|
+
getSessionId: () => "session-123",
|
|
579
|
+
getSessionFile: () => parentSessionFile,
|
|
580
|
+
getLeafId: () => "leaf-fail",
|
|
581
|
+
openSession: () => ({
|
|
582
|
+
createBranchedSession: () => {
|
|
583
|
+
throw new Error("branch write failed");
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
};
|
|
587
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
588
|
+
agents: [
|
|
589
|
+
{ name: "scout", description: "Scout", defaultContext: "fresh" },
|
|
590
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
591
|
+
],
|
|
592
|
+
projectAgentsDir: null,
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
const result = await executor.execute(
|
|
596
|
+
"id",
|
|
597
|
+
{ tasks: [{ agent: "scout", task: "scan" }, { agent: "worker", task: "write" }] },
|
|
598
|
+
new AbortController().signal,
|
|
599
|
+
undefined,
|
|
600
|
+
makeCtx(manager),
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
assert.equal(result.isError, true);
|
|
604
|
+
assert.match(result.content[0]?.text ?? "", /Failed to create forked subagent session/);
|
|
605
|
+
assert.match(result.content[0]?.text ?? "", /branch write failed/);
|
|
606
|
+
assert.equal(mockPi.callCount(), 0);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("preflights static default-fork chain steps even when the chain also has dynamic fanout", async () => {
|
|
610
|
+
const parentSessionFile = path.join(tempDir, "parent-dynamic-chain-fail.jsonl");
|
|
611
|
+
fs.writeFileSync(parentSessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
612
|
+
const manager = {
|
|
613
|
+
getSessionId: () => "session-123",
|
|
614
|
+
getSessionFile: () => parentSessionFile,
|
|
615
|
+
getLeafId: () => "leaf-fail",
|
|
616
|
+
openSession: () => ({
|
|
617
|
+
createBranchedSession: () => {
|
|
618
|
+
throw new Error("branch write failed");
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
};
|
|
622
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
623
|
+
agents: [
|
|
624
|
+
{ name: "scout", description: "Scout", defaultContext: "fresh" },
|
|
625
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
626
|
+
],
|
|
627
|
+
projectAgentsDir: null,
|
|
628
|
+
}));
|
|
629
|
+
|
|
630
|
+
const result = await executor.execute(
|
|
631
|
+
"id",
|
|
632
|
+
{
|
|
633
|
+
chain: [
|
|
634
|
+
{ agent: "scout", task: "scan" },
|
|
635
|
+
{ agent: "worker", task: "write" },
|
|
636
|
+
{
|
|
637
|
+
expand: { from: { output: "items", path: "$" } },
|
|
638
|
+
parallel: { agent: "scout", task: "inspect item" },
|
|
639
|
+
collect: { as: "inspections" },
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
clarify: false,
|
|
643
|
+
},
|
|
644
|
+
new AbortController().signal,
|
|
645
|
+
undefined,
|
|
646
|
+
makeCtx(manager),
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
assert.equal(result.isError, true);
|
|
650
|
+
assert.match(result.content[0]?.text ?? "", /Failed to create forked subagent session/);
|
|
651
|
+
assert.match(result.content[0]?.text ?? "", /branch write failed/);
|
|
652
|
+
assert.equal(mockPi.callCount(), 0);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("keeps later foreground forked chain steps aligned after short dynamic fanout", async () => {
|
|
656
|
+
mockPi.reset();
|
|
657
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ id: "one" }] } });
|
|
658
|
+
mockPi.onCall({ output: "inspected one" });
|
|
659
|
+
mockPi.onCall({ output: "final done" });
|
|
660
|
+
const parentSessionFile = path.join(tempDir, "parent-dynamic-chain.jsonl");
|
|
661
|
+
const { manager } = makeForkingSessionManagerRecorder({ sessionFile: parentSessionFile, leafId: "leaf-current" });
|
|
662
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
663
|
+
agents: [
|
|
664
|
+
{ name: "producer", description: "Producer", defaultContext: "fresh" },
|
|
665
|
+
{ name: "worker", description: "Worker", defaultContext: "fork" },
|
|
666
|
+
],
|
|
667
|
+
projectAgentsDir: null,
|
|
668
|
+
}));
|
|
669
|
+
|
|
670
|
+
const result = await executor.execute(
|
|
671
|
+
"id",
|
|
672
|
+
{
|
|
673
|
+
chain: [
|
|
674
|
+
{ agent: "producer", task: "produce", as: "items", outputSchema: { type: "object" } },
|
|
675
|
+
{
|
|
676
|
+
expand: { from: { output: "items", path: "/items" }, item: "item", key: "/id", maxItems: 3 },
|
|
677
|
+
parallel: { agent: "worker", task: "inspect {item.id}" },
|
|
678
|
+
collect: { as: "inspections" },
|
|
679
|
+
},
|
|
680
|
+
{ agent: "worker", task: "final" },
|
|
681
|
+
],
|
|
682
|
+
clarify: false,
|
|
683
|
+
},
|
|
684
|
+
new AbortController().signal,
|
|
685
|
+
undefined,
|
|
686
|
+
makeCtx(manager),
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
assert.equal(result.isError, undefined);
|
|
690
|
+
assert.equal(mockPi.callCount(), 3);
|
|
691
|
+
assert.equal(readSessionArg(readCallArgsForTask("inspect one")), path.join(tempDir, "fork-1.jsonl"));
|
|
692
|
+
assert.equal(readSessionArg(readCallArgsForTask("final")), path.join(tempDir, "fork-4.jsonl"));
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("reports unknown top-level parallel agents before default-fork preconditions", async () => {
|
|
696
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: undefined, leafId: "leaf-current" });
|
|
697
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
698
|
+
agents: [{ name: "worker", description: "Worker", defaultContext: "fork" }],
|
|
699
|
+
projectAgentsDir: null,
|
|
700
|
+
}));
|
|
701
|
+
|
|
702
|
+
const result = await executor.execute(
|
|
703
|
+
"id",
|
|
704
|
+
{ tasks: [{ agent: "worker", task: "one" }, { agent: "missing", task: "two" }] },
|
|
705
|
+
new AbortController().signal,
|
|
706
|
+
undefined,
|
|
707
|
+
makeCtx(manager),
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
assert.equal(result.isError, true);
|
|
711
|
+
assert.match(result.content[0]?.text ?? "", /Unknown agent: missing/);
|
|
712
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /persisted parent session/);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("fails fast when context=fork and parent session is missing", async () => {
|
|
716
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: undefined, leafId: "leaf-current" });
|
|
717
|
+
const executor = makeExecutor();
|
|
718
|
+
|
|
719
|
+
const result = await executor.execute(
|
|
720
|
+
"id",
|
|
721
|
+
{ agent: "echo", task: "test", context: "fork" },
|
|
722
|
+
new AbortController().signal,
|
|
723
|
+
undefined,
|
|
724
|
+
makeCtx(manager),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
assert.equal(result.isError, true);
|
|
728
|
+
assert.match(result.content[0]?.text ?? "", /persisted parent session/);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("fails fast when context=fork and leaf is missing", async () => {
|
|
732
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: "/tmp/parent.jsonl", leafId: null });
|
|
733
|
+
const executor = makeExecutor();
|
|
734
|
+
|
|
735
|
+
const result = await executor.execute(
|
|
736
|
+
"id",
|
|
737
|
+
{ agent: "echo", task: "test", context: "fork" },
|
|
738
|
+
new AbortController().signal,
|
|
739
|
+
undefined,
|
|
740
|
+
makeCtx(manager),
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
assert.equal(result.isError, true);
|
|
744
|
+
assert.match(result.content[0]?.text ?? "", /current leaf/);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("returns a tool error (instead of throwing) when branch creation fails", async () => {
|
|
748
|
+
const executor = makeExecutor();
|
|
749
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
750
|
+
fs.writeFileSync(parentSessionFile, '{"type":"session","version":1,"id":"parent","timestamp":"2026-04-16T00:00:00.000Z","cwd":"/tmp"}\n', "utf-8");
|
|
751
|
+
const manager = {
|
|
752
|
+
getSessionId: () => "session-123",
|
|
753
|
+
getSessionFile: () => parentSessionFile,
|
|
754
|
+
getLeafId: () => "leaf-fail",
|
|
755
|
+
openSession: () => ({
|
|
756
|
+
createBranchedSession: () => {
|
|
757
|
+
throw new Error("branch write failed");
|
|
758
|
+
},
|
|
759
|
+
}),
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const result = await executor.execute(
|
|
763
|
+
"id",
|
|
764
|
+
{ agent: "echo", task: "test", context: "fork" },
|
|
765
|
+
new AbortController().signal,
|
|
766
|
+
undefined,
|
|
767
|
+
makeCtx(manager),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
assert.equal(result.isError, true);
|
|
771
|
+
assert.match(result.content[0]?.text ?? "", /Failed to create forked subagent session/);
|
|
772
|
+
assert.match(result.content[0]?.text ?? "", /branch write failed/);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("creates one forked session for single mode", async () => {
|
|
776
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({
|
|
777
|
+
sessionFile: path.join(tempDir, "parent.jsonl"),
|
|
778
|
+
leafId: "leaf-123",
|
|
779
|
+
});
|
|
780
|
+
const executor = makeExecutor();
|
|
781
|
+
|
|
782
|
+
const result = await executor.execute(
|
|
783
|
+
"id",
|
|
784
|
+
{ agent: "echo", task: "single task", context: "fork" },
|
|
785
|
+
new AbortController().signal,
|
|
786
|
+
undefined,
|
|
787
|
+
makeCtx(manager),
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
assert.equal(result.isError, undefined);
|
|
791
|
+
assert.deepEqual(openedPaths, [path.join(tempDir, "parent.jsonl")]);
|
|
792
|
+
assert.deepEqual(branchedLeafIds, ["leaf-123"]);
|
|
793
|
+
const args = readCallArgs();
|
|
794
|
+
const sessionIndex = args.indexOf("--session");
|
|
795
|
+
assert.notEqual(sessionIndex, -1);
|
|
796
|
+
assert.notEqual(args[sessionIndex + 1], path.join(tempDir, "parent.jsonl"));
|
|
797
|
+
assert.ok(args[sessionIndex + 1]);
|
|
798
|
+
assert.equal(fs.existsSync(args[sessionIndex + 1]!), true);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("creates isolated forked sessions per parallel task", async () => {
|
|
802
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({
|
|
803
|
+
sessionFile: path.join(tempDir, "parent-parallel.jsonl"),
|
|
804
|
+
leafId: "leaf-777",
|
|
805
|
+
});
|
|
806
|
+
const executor = makeExecutor();
|
|
807
|
+
|
|
808
|
+
const result = await executor.execute(
|
|
809
|
+
"id",
|
|
810
|
+
{
|
|
811
|
+
tasks: [
|
|
812
|
+
{ agent: "echo", task: "task one" },
|
|
813
|
+
{ agent: "second", task: "task two" },
|
|
814
|
+
],
|
|
815
|
+
context: "fork",
|
|
816
|
+
},
|
|
817
|
+
new AbortController().signal,
|
|
818
|
+
undefined,
|
|
819
|
+
makeCtx(manager),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
assert.equal(result.isError, undefined);
|
|
823
|
+
assert.deepEqual(openedPaths, [path.join(tempDir, "parent-parallel.jsonl"), path.join(tempDir, "parent-parallel.jsonl")]);
|
|
824
|
+
assert.deepEqual(branchedLeafIds, ["leaf-777", "leaf-777"]);
|
|
825
|
+
const sessionArgs = readSessionArgsFromCalls();
|
|
826
|
+
assert.equal(sessionArgs.length, 2);
|
|
827
|
+
assert.equal(new Set(sessionArgs).size, 2);
|
|
828
|
+
for (const childSessionFile of sessionArgs) {
|
|
829
|
+
assert.notEqual(childSessionFile, path.join(tempDir, "parent-parallel.jsonl"));
|
|
830
|
+
assert.equal(fs.existsSync(childSessionFile), true);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("expands top-level parallel task counts before fork session allocation", async () => {
|
|
835
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({
|
|
836
|
+
sessionFile: path.join(tempDir, "parent-count.jsonl"),
|
|
837
|
+
leafId: "leaf-count",
|
|
838
|
+
});
|
|
839
|
+
const executor = makeExecutor();
|
|
840
|
+
|
|
841
|
+
const result = await executor.execute(
|
|
842
|
+
"id",
|
|
843
|
+
{
|
|
844
|
+
tasks: [{ agent: "echo", task: "task one", count: 3 }],
|
|
845
|
+
context: "fork",
|
|
846
|
+
},
|
|
847
|
+
new AbortController().signal,
|
|
848
|
+
undefined,
|
|
849
|
+
makeCtx(manager),
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
assert.equal(result.isError, undefined);
|
|
853
|
+
assert.deepEqual(openedPaths, [
|
|
854
|
+
path.join(tempDir, "parent-count.jsonl"),
|
|
855
|
+
path.join(tempDir, "parent-count.jsonl"),
|
|
856
|
+
path.join(tempDir, "parent-count.jsonl"),
|
|
857
|
+
]);
|
|
858
|
+
assert.deepEqual(branchedLeafIds, ["leaf-count", "leaf-count", "leaf-count"]);
|
|
859
|
+
const sessionArgs = readSessionArgsFromCalls();
|
|
860
|
+
assert.equal(sessionArgs.length, 3);
|
|
861
|
+
assert.equal(new Set(sessionArgs).size, 3);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("rejects top-level parallel worktree runs with a conflicting task cwd", async () => {
|
|
865
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: "/tmp/parent.jsonl", leafId: "leaf-777" });
|
|
866
|
+
const executor = makeExecutor();
|
|
867
|
+
|
|
868
|
+
const result = await executor.execute(
|
|
869
|
+
"id",
|
|
870
|
+
{
|
|
871
|
+
tasks: [
|
|
872
|
+
{ agent: "echo", task: "task one" },
|
|
873
|
+
{ agent: "second", task: "task two", cwd: `${tempDir}/other` },
|
|
874
|
+
],
|
|
875
|
+
worktree: true,
|
|
876
|
+
},
|
|
877
|
+
new AbortController().signal,
|
|
878
|
+
undefined,
|
|
879
|
+
makeCtx(manager),
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
assert.equal(result.isError, true);
|
|
883
|
+
assert.match(result.content[0]?.text ?? "", /worktree isolation uses the shared cwd/i);
|
|
884
|
+
assert.match(result.content[0]?.text ?? "", /task 2 \(second\) sets cwd/i);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("rejects top-level parallel counts that expand past MAX_PARALLEL", async () => {
|
|
888
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: "/tmp/parent.jsonl", leafId: "leaf-max" });
|
|
889
|
+
const executor = makeExecutor();
|
|
890
|
+
|
|
891
|
+
const result = await executor.execute(
|
|
892
|
+
"id",
|
|
893
|
+
{
|
|
894
|
+
tasks: [{ agent: "echo", task: "task one", count: 9 }],
|
|
895
|
+
},
|
|
896
|
+
new AbortController().signal,
|
|
897
|
+
undefined,
|
|
898
|
+
makeCtx(manager),
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
assert.equal(result.isError, true);
|
|
902
|
+
assert.match(result.content[0]?.text ?? "", /Max 8 tasks/);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("uses top-level parallel config overrides for maxTasks and concurrency", async () => {
|
|
906
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: "/tmp/parent.jsonl", leafId: "leaf-max-config" });
|
|
907
|
+
const maxTasksExecutor = makeExecutorWithConfig({ parallel: { maxTasks: 9 } });
|
|
908
|
+
|
|
909
|
+
const maxTasksResult = await maxTasksExecutor.execute(
|
|
910
|
+
"id",
|
|
911
|
+
{
|
|
912
|
+
tasks: [{ agent: "echo", task: "task one", count: 9 }],
|
|
913
|
+
},
|
|
914
|
+
new AbortController().signal,
|
|
915
|
+
undefined,
|
|
916
|
+
makeCtx(manager),
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
assert.equal(maxTasksResult.isError, undefined);
|
|
920
|
+
assert.equal(mockPi.callCount(), 9);
|
|
921
|
+
|
|
922
|
+
for (const testCase of [
|
|
923
|
+
{ name: "config", configConcurrency: 2, paramsConcurrency: undefined, expectedMaxRunning: 2 },
|
|
924
|
+
{ name: "per-call", configConcurrency: 3, paramsConcurrency: 1, expectedMaxRunning: 1 },
|
|
925
|
+
]) {
|
|
926
|
+
mockPi.reset();
|
|
927
|
+
for (let i = 0; i < 3; i++) {
|
|
928
|
+
mockPi.onCall({
|
|
929
|
+
steps: [
|
|
930
|
+
{ jsonl: [events.toolStart("bash", { command: `${testCase.name}-${i}` })] },
|
|
931
|
+
{ delay: 250 },
|
|
932
|
+
{ jsonl: [events.toolEnd("bash"), events.assistantMessage(`done-${i}`)] },
|
|
933
|
+
],
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const executor = makeExecutorWithConfig({ parallel: { concurrency: testCase.configConcurrency } });
|
|
938
|
+
let maxRunning = 0;
|
|
939
|
+
|
|
940
|
+
const result = await executor.execute(
|
|
941
|
+
"id",
|
|
942
|
+
{
|
|
943
|
+
tasks: [
|
|
944
|
+
{ agent: "echo", task: "task one" },
|
|
945
|
+
{ agent: "second", task: "task two" },
|
|
946
|
+
{ agent: "echo", task: "task three" },
|
|
947
|
+
],
|
|
948
|
+
...(testCase.paramsConcurrency ? { concurrency: testCase.paramsConcurrency } : {}),
|
|
949
|
+
},
|
|
950
|
+
new AbortController().signal,
|
|
951
|
+
(update: ProgressUpdate) => {
|
|
952
|
+
const progress = update.details?.progress ?? [];
|
|
953
|
+
const running = progress.filter((entry) => entry.status === "running").length;
|
|
954
|
+
maxRunning = Math.max(maxRunning, running);
|
|
955
|
+
},
|
|
956
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
assert.equal(result.isError, undefined, testCase.name);
|
|
960
|
+
assert.equal(maxRunning, testCase.expectedMaxRunning, testCase.name);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("caps top-level foreground parallel execution with globalConcurrencyLimit", async () => {
|
|
965
|
+
mockPi.reset();
|
|
966
|
+
for (let i = 0; i < 4; i++) {
|
|
967
|
+
mockPi.onCall({
|
|
968
|
+
steps: [
|
|
969
|
+
{ jsonl: [events.toolStart("bash", { command: `global-${i}` })] },
|
|
970
|
+
{ delay: 250 },
|
|
971
|
+
{ jsonl: [events.toolEnd("bash"), events.assistantMessage(`done-${i}`)] },
|
|
972
|
+
],
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const executor = makeExecutorWithConfig({ globalConcurrencyLimit: 2, parallel: { concurrency: 4 } });
|
|
977
|
+
let maxRunning = 0;
|
|
978
|
+
|
|
979
|
+
const result = await executor.execute(
|
|
980
|
+
"id",
|
|
981
|
+
{
|
|
982
|
+
tasks: [
|
|
983
|
+
{ agent: "echo", task: "task one" },
|
|
984
|
+
{ agent: "second", task: "task two" },
|
|
985
|
+
{ agent: "echo", task: "task three" },
|
|
986
|
+
{ agent: "second", task: "task four" },
|
|
987
|
+
],
|
|
988
|
+
},
|
|
989
|
+
new AbortController().signal,
|
|
990
|
+
(update: ProgressUpdate) => {
|
|
991
|
+
const progress = update.details?.progress ?? [];
|
|
992
|
+
const running = progress.filter((entry) => entry.status === "running").length;
|
|
993
|
+
maxRunning = Math.max(maxRunning, running);
|
|
994
|
+
},
|
|
995
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
assert.equal(result.isError, undefined);
|
|
999
|
+
assert.equal(mockPi.callCount(), 4);
|
|
1000
|
+
assert.equal(maxRunning, 2);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it("detaches parallel child runs cleanly on intercom handoff", async () => {
|
|
1004
|
+
mockPi.reset();
|
|
1005
|
+
mockPi.onCall({
|
|
1006
|
+
steps: [
|
|
1007
|
+
{ jsonl: [events.toolStart("intercom", { action: "send", to: "orchestrator" })] },
|
|
1008
|
+
{ delay: 1000, jsonl: [events.assistantMessage("after handoff")] },
|
|
1009
|
+
],
|
|
1010
|
+
});
|
|
1011
|
+
mockPi.onCall({ output: "other done" });
|
|
1012
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
1013
|
+
agents: [
|
|
1014
|
+
{ name: "echo", description: "Echo", systemPrompt: "Intercom orchestration channel:" },
|
|
1015
|
+
{ name: "second", description: "Second", systemPrompt: "Intercom orchestration channel:" },
|
|
1016
|
+
],
|
|
1017
|
+
projectAgentsDir: null,
|
|
1018
|
+
}));
|
|
1019
|
+
let detachEmitted = false;
|
|
1020
|
+
const result = await executor.execute(
|
|
1021
|
+
"intercom-parallel",
|
|
1022
|
+
{
|
|
1023
|
+
tasks: [
|
|
1024
|
+
{ agent: "echo", task: "send handoff" },
|
|
1025
|
+
{ agent: "second", task: "continue" },
|
|
1026
|
+
],
|
|
1027
|
+
},
|
|
1028
|
+
new AbortController().signal,
|
|
1029
|
+
(update: ProgressUpdate) => {
|
|
1030
|
+
if (detachEmitted) return;
|
|
1031
|
+
if (!update.details?.progress?.some((entry) => entry.currentTool === "intercom")) return;
|
|
1032
|
+
detachEmitted = true;
|
|
1033
|
+
executor.eventsApi.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "parallel-detach" });
|
|
1034
|
+
},
|
|
1035
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
assert.equal(result.isError, undefined);
|
|
1039
|
+
assert.match(result.content[0]?.text ?? "", /Parallel run detached for intercom coordination/);
|
|
1040
|
+
assert.equal(detachEmitted, true);
|
|
1041
|
+
assert.equal(result.details?.results?.some((entry) => entry.detached === true && entry.exitCode === 0), true);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("runs top-level parallel async requests in the background", { skip: !asyncAvailable ? "jiti not available" : undefined }, async () => {
|
|
1045
|
+
const executor = makeExecutor();
|
|
1046
|
+
|
|
1047
|
+
const result = await executor.execute(
|
|
1048
|
+
"id",
|
|
1049
|
+
{
|
|
1050
|
+
tasks: [
|
|
1051
|
+
{ agent: "echo", task: "task one" },
|
|
1052
|
+
{ agent: "second", task: "task two" },
|
|
1053
|
+
],
|
|
1054
|
+
async: true,
|
|
1055
|
+
clarify: false,
|
|
1056
|
+
},
|
|
1057
|
+
new AbortController().signal,
|
|
1058
|
+
undefined,
|
|
1059
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
assert.equal(result.isError, undefined);
|
|
1063
|
+
assert.equal(result.details?.mode, "parallel");
|
|
1064
|
+
assert.ok(result.details?.asyncId, "expected an asyncId for background top-level parallel runs");
|
|
1065
|
+
assert.match(result.content[0]?.text ?? "", /Async parallel:/);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("runs async chain requests in the background when clarify is omitted", { skip: !asyncAvailable ? "jiti not available" : undefined }, async () => {
|
|
1069
|
+
const executor = makeExecutor();
|
|
1070
|
+
|
|
1071
|
+
const result = await executor.execute(
|
|
1072
|
+
"id",
|
|
1073
|
+
{
|
|
1074
|
+
chain: [
|
|
1075
|
+
{ agent: "echo", task: "task one" },
|
|
1076
|
+
{ agent: "second", task: "task two" },
|
|
1077
|
+
],
|
|
1078
|
+
async: true,
|
|
1079
|
+
},
|
|
1080
|
+
new AbortController().signal,
|
|
1081
|
+
undefined,
|
|
1082
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
assert.equal(result.isError, undefined);
|
|
1086
|
+
assert.equal(result.details?.mode, "chain");
|
|
1087
|
+
assert.ok(result.details?.asyncId, "expected an asyncId for background chain runs");
|
|
1088
|
+
assert.match(result.content[0]?.text ?? "", /Async chain:/);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("keeps explicit clarify async chain requests in the foreground", async () => {
|
|
1092
|
+
const executor = makeExecutor();
|
|
1093
|
+
|
|
1094
|
+
const result = await executor.execute(
|
|
1095
|
+
"id",
|
|
1096
|
+
{
|
|
1097
|
+
chain: [
|
|
1098
|
+
{ agent: "echo", task: "task one" },
|
|
1099
|
+
{ agent: "second", task: "task two" },
|
|
1100
|
+
],
|
|
1101
|
+
async: true,
|
|
1102
|
+
clarify: true,
|
|
1103
|
+
},
|
|
1104
|
+
new AbortController().signal,
|
|
1105
|
+
undefined,
|
|
1106
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
assert.equal(result.isError, undefined);
|
|
1110
|
+
assert.equal(result.details?.mode, "chain");
|
|
1111
|
+
assert.equal(result.details?.asyncId, undefined);
|
|
1112
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Async chain:/);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("rejects invalid background top-level parallel requests during executor preflight", async () => {
|
|
1116
|
+
const executor = makeExecutor();
|
|
1117
|
+
for (const testCase of [
|
|
1118
|
+
{
|
|
1119
|
+
name: "max tasks",
|
|
1120
|
+
params: { tasks: [{ agent: "echo", task: "task one", count: 9 }], async: true, clarify: false },
|
|
1121
|
+
patterns: [/Max 8 tasks/],
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
name: "worktree cwd conflict",
|
|
1125
|
+
params: {
|
|
1126
|
+
tasks: [
|
|
1127
|
+
{ agent: "echo", task: "task one" },
|
|
1128
|
+
{ agent: "second", task: "task two", cwd: `${tempDir}/other` },
|
|
1129
|
+
],
|
|
1130
|
+
worktree: true,
|
|
1131
|
+
async: true,
|
|
1132
|
+
clarify: false,
|
|
1133
|
+
},
|
|
1134
|
+
patterns: [/worktree isolation uses the shared cwd/i, /task 2 \(second\) sets cwd/i],
|
|
1135
|
+
},
|
|
1136
|
+
]) {
|
|
1137
|
+
const result = await executor.execute(
|
|
1138
|
+
"id",
|
|
1139
|
+
testCase.params,
|
|
1140
|
+
new AbortController().signal,
|
|
1141
|
+
undefined,
|
|
1142
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
assert.equal(result.isError, true, testCase.name);
|
|
1146
|
+
for (const pattern of testCase.patterns) {
|
|
1147
|
+
assert.match(result.content[0]?.text ?? "", pattern, testCase.name);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("rejects async chain worktree runs with a conflicting task cwd", async () => {
|
|
1153
|
+
const { manager } = makeSessionManagerRecorder({ sessionFile: "/tmp/parent.jsonl", leafId: "leaf-chain" });
|
|
1154
|
+
const executor = makeExecutor();
|
|
1155
|
+
|
|
1156
|
+
const result = await executor.execute(
|
|
1157
|
+
"id",
|
|
1158
|
+
{
|
|
1159
|
+
chain: [
|
|
1160
|
+
{
|
|
1161
|
+
parallel: [
|
|
1162
|
+
{ agent: "echo", task: "p1" },
|
|
1163
|
+
{ agent: "second", task: "p2", cwd: `${tempDir}/other` },
|
|
1164
|
+
],
|
|
1165
|
+
worktree: true,
|
|
1166
|
+
},
|
|
1167
|
+
],
|
|
1168
|
+
async: true,
|
|
1169
|
+
clarify: false,
|
|
1170
|
+
},
|
|
1171
|
+
new AbortController().signal,
|
|
1172
|
+
undefined,
|
|
1173
|
+
makeCtx(manager),
|
|
1174
|
+
);
|
|
1175
|
+
|
|
1176
|
+
assert.equal(result.isError, true);
|
|
1177
|
+
assert.match(result.content[0]?.text ?? "", /parallel chain step 1/i);
|
|
1178
|
+
assert.match(result.content[0]?.text ?? "", /task 2 \(second\) sets cwd/i);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("creates isolated forked sessions per chain step (including counted parallel steps)", async () => {
|
|
1182
|
+
const { manager, openedPaths, branchedLeafIds } = makeForkingSessionManagerRecorder({
|
|
1183
|
+
sessionFile: path.join(tempDir, "parent-chain.jsonl"),
|
|
1184
|
+
leafId: "leaf-chain",
|
|
1185
|
+
});
|
|
1186
|
+
const executor = makeExecutor();
|
|
1187
|
+
|
|
1188
|
+
const result = await executor.execute(
|
|
1189
|
+
"id",
|
|
1190
|
+
{
|
|
1191
|
+
chain: [
|
|
1192
|
+
{ agent: "echo", task: "step 1" },
|
|
1193
|
+
{ parallel: [{ agent: "echo", task: "p1", count: 2 }, { agent: "second", task: "p2", count: 2 }] },
|
|
1194
|
+
{ agent: "second", task: "step 3" },
|
|
1195
|
+
],
|
|
1196
|
+
context: "fork",
|
|
1197
|
+
clarify: false,
|
|
1198
|
+
},
|
|
1199
|
+
new AbortController().signal,
|
|
1200
|
+
undefined,
|
|
1201
|
+
makeCtx(manager),
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
assert.equal(result.isError, undefined);
|
|
1205
|
+
assert.deepEqual(openedPaths, Array(6).fill(path.join(tempDir, "parent-chain.jsonl")));
|
|
1206
|
+
assert.deepEqual(branchedLeafIds, Array(6).fill("leaf-chain"));
|
|
1207
|
+
const sessionArgs = readSessionArgsFromCalls().filter((sessionFile) => path.dirname(sessionFile) === tempDir && path.basename(sessionFile).startsWith("fork-"));
|
|
1208
|
+
assert.equal(sessionArgs.length, 6, "1 sequential + 4 parallel + 1 sequential");
|
|
1209
|
+
assert.equal(new Set(sessionArgs).size, 6);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("uses request cwd for management actions", async () => {
|
|
1213
|
+
const executor = makeExecutor();
|
|
1214
|
+
const worktreeDir = path.join(tempDir, "worktree");
|
|
1215
|
+
fs.mkdirSync(path.join(worktreeDir, ".selesai"), { recursive: true });
|
|
1216
|
+
|
|
1217
|
+
const result = await executor.execute(
|
|
1218
|
+
"id",
|
|
1219
|
+
{
|
|
1220
|
+
action: "create",
|
|
1221
|
+
cwd: "worktree",
|
|
1222
|
+
config: { name: "local-helper", description: "Local helper", scope: "project" },
|
|
1223
|
+
},
|
|
1224
|
+
new AbortController().signal,
|
|
1225
|
+
undefined,
|
|
1226
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
assert.equal(result.isError, false);
|
|
1230
|
+
assert.equal(fs.existsSync(path.join(worktreeDir, ".selesai", "agents", "local-helper.md")), true);
|
|
1231
|
+
assert.equal(fs.existsSync(path.join(tempDir, ".selesai", "agents", "local-helper.md")), false);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it("uses request cwd for execution-time agent discovery", async () => {
|
|
1235
|
+
const worktreeDir = path.join(tempDir, "worktree");
|
|
1236
|
+
writeAgent(tempDir, "echo", "openai/gpt-5-main");
|
|
1237
|
+
writeAgent(worktreeDir, "echo", "anthropic/claude-haiku-4-5");
|
|
1238
|
+
const executor = makeExecutorWithDiscoverAgents(discoverAgents);
|
|
1239
|
+
const task = `test ${path.basename(tempDir)}`;
|
|
1240
|
+
|
|
1241
|
+
const result = await executor.execute(
|
|
1242
|
+
"id",
|
|
1243
|
+
{ agent: "echo", task, cwd: "worktree" },
|
|
1244
|
+
new AbortController().signal,
|
|
1245
|
+
undefined,
|
|
1246
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
assert.equal(result.isError, undefined);
|
|
1250
|
+
const args = readAllCallArgs().find((callArgs) => (callArgs.at(-1) ?? "").startsWith(`Task: ${task}\n\n## Acceptance Contract`));
|
|
1251
|
+
assert.ok(args, "expected a recorded mock pi call for this test task");
|
|
1252
|
+
const modelIndex = args.indexOf("--model");
|
|
1253
|
+
assert.notEqual(modelIndex, -1);
|
|
1254
|
+
assert.equal(args[modelIndex + 1], "anthropic/claude-haiku-4-5");
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it("resolves parallel task cwd values relative to the request cwd", async () => {
|
|
1258
|
+
const worktreeDir = path.join(tempDir, "worktree");
|
|
1259
|
+
writePackageSkill(path.join(worktreeDir, "packages", "app"), "parallel-step-skill");
|
|
1260
|
+
const executor = makeExecutorWithDiscoverAgents(() => ({
|
|
1261
|
+
agents: [{ name: "echo", description: "Echo test agent", skills: ["parallel-step-skill"] }],
|
|
1262
|
+
projectAgentsDir: null,
|
|
1263
|
+
}));
|
|
1264
|
+
|
|
1265
|
+
const result = await executor.execute(
|
|
1266
|
+
"id",
|
|
1267
|
+
{
|
|
1268
|
+
tasks: [{ agent: "echo", task: "test", cwd: "packages/app" }],
|
|
1269
|
+
cwd: worktreeDir,
|
|
1270
|
+
},
|
|
1271
|
+
new AbortController().signal,
|
|
1272
|
+
undefined,
|
|
1273
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
assert.equal(result.isError, undefined);
|
|
1277
|
+
assert.deepEqual(result.details?.results?.[0]?.skills, ["parallel-step-skill"]);
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it("uses request cwd for project builtin overrides during management", async () => {
|
|
1281
|
+
const tempHome = createTempDir("pi-subagent-home-");
|
|
1282
|
+
process.env.HOME = tempHome;
|
|
1283
|
+
process.env.USERPROFILE = tempHome;
|
|
1284
|
+
const worktreeDir = path.join(tempDir, "worktree");
|
|
1285
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
1286
|
+
writeProjectOverride(tempDir, "reviewer", "openai/gpt-5-main");
|
|
1287
|
+
writeProjectOverride(worktreeDir, "reviewer", "openai/gpt-5-worktree");
|
|
1288
|
+
const executor = makeExecutor();
|
|
1289
|
+
|
|
1290
|
+
try {
|
|
1291
|
+
const result = await executor.execute(
|
|
1292
|
+
"id",
|
|
1293
|
+
{ action: "get", agent: "reviewer", cwd: "worktree" },
|
|
1294
|
+
new AbortController().signal,
|
|
1295
|
+
undefined,
|
|
1296
|
+
makeCtx(makeSessionManagerRecorder().manager),
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
assert.equal(result.isError, false);
|
|
1300
|
+
assert.match(result.content[0]?.text ?? "", /Model: openai\/gpt-5-worktree/);
|
|
1301
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", /Model: openai\/gpt-5-main/);
|
|
1302
|
+
} finally {
|
|
1303
|
+
removeTempDir(tempHome);
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
});
|