@selesai/code 0.5.20 → 0.5.21
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/extensions/handoff-new.test.ts +8 -9
- package/dist/extensions/handoff-new.ts +6 -8
- package/dist/extensions/pi-subagents/CHANGELOG.md +1367 -0
- package/dist/extensions/pi-subagents/README.md +602 -220
- package/dist/extensions/pi-subagents/banner.png +0 -0
- package/dist/extensions/pi-subagents/index.ts +1 -0
- package/dist/extensions/pi-subagents/install.mjs +3 -3
- package/dist/extensions/pi-subagents/package-lock.json +3405 -0
- package/dist/extensions/pi-subagents/package.json +26 -14
- package/dist/extensions/pi-subagents/prompts/gather-context-and-clarify.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-cleanup.md +9 -9
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +3 -3
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +7 -7
- package/dist/extensions/pi-subagents/prompts/parallel-research.md +6 -6
- package/dist/extensions/pi-subagents/prompts/parallel-review.md +8 -8
- package/dist/extensions/pi-subagents/prompts/review-loop.md +13 -11
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +267 -176
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +89 -16
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +19 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +218 -120
- package/dist/extensions/pi-subagents/src/agents/frontmatter.ts +67 -13
- package/dist/extensions/pi-subagents/src/agents/proactive-skills.ts +2 -2
- package/dist/extensions/pi-subagents/src/agents/skills.ts +25 -12
- package/dist/extensions/pi-subagents/src/api/background-work.ts +197 -0
- package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +17 -0
- package/dist/extensions/pi-subagents/src/api/delegation.ts +285 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +399 -0
- package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +165 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +7 -1
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +15 -0
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +113 -157
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +41 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +45 -15
- package/dist/extensions/pi-subagents/src/extension/steering-notices.ts +35 -0
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -15
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -3
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +225 -29
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +11 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +8 -10
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +452 -62
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +60 -9
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +198 -52
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +119 -21
- package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +16 -8
- package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +6 -4
- package/dist/extensions/pi-subagents/src/runs/background/completion-dedupe.ts +2 -11
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +260 -13
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +32 -6
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +171 -90
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +149 -88
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +14 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +33 -17
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +9 -1
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +38 -10
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +237 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +1297 -307
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +610 -0
- package/dist/extensions/pi-subagents/src/runs/background/top-level-async.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +26 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +230 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +22 -6
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +228 -140
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +452 -138
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +90 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +1001 -416
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +443 -132
- package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +38 -0
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +178 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +121 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +41 -93
- package/dist/extensions/pi-subagents/src/runs/shared/context-mode.ts +44 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +11 -9
- package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +36 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +44 -7
- package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +154 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +18 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +174 -54
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +38 -31
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +90 -5
- package/dist/extensions/pi-subagents/src/runs/shared/session-lease.ts +299 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +61 -6
- package/dist/extensions/pi-subagents/src/runs/shared/spawn-budget.ts +128 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +112 -7
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +149 -36
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +181 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +83 -0
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +11 -5
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +50 -4
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +63 -14
- package/dist/extensions/pi-subagents/src/shared/accessible-dir.ts +25 -0
- package/dist/extensions/pi-subagents/src/shared/artifacts.ts +37 -7
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +18 -43
- package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +52 -0
- package/dist/extensions/pi-subagents/src/shared/env.ts +16 -0
- package/dist/extensions/pi-subagents/src/shared/file-system-retry.ts +47 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +28 -3
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +123 -0
- package/dist/extensions/pi-subagents/src/shared/model-info.ts +8 -5
- package/dist/extensions/pi-subagents/src/shared/settings.ts +9 -1
- package/dist/extensions/pi-subagents/src/shared/status-format.ts +7 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +522 -51
- package/dist/extensions/pi-subagents/src/shared/utils.ts +59 -53
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +604 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-json.ts +108 -0
- package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +249 -0
- package/dist/extensions/pi-subagents/src/slash/prompt-template-bridge.ts +353 -345
- package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/selector.ts +147 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +265 -23
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +2 -2
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +428 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +362 -0
- package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +472 -0
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +664 -0
- package/dist/extensions/pi-subagents/src/tui/render.ts +115 -31
- package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +220 -0
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +205 -0
- package/dist/extensions/pi-subagents/src/watchdog/emission-guard.ts +123 -0
- package/dist/extensions/pi-subagents/src/watchdog/lsp-diagnostics.ts +532 -0
- package/dist/extensions/pi-subagents/src/watchdog/model-selection.ts +167 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +117 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +433 -0
- package/dist/extensions/pi-subagents/src/watchdog/render.ts +54 -0
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +298 -0
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +714 -0
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +528 -0
- package/dist/extensions/pi-subagents/src/watchdog/tool-actions.ts +155 -0
- package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +161 -0
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +188 -0
- package/dist/extensions/pi-subagents/src/watchdog/warning-format.ts +73 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +130 -2
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +439 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1632 -61
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +54 -1
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +112 -0
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +98 -1
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +179 -9
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +16 -9
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +6 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +589 -9
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +528 -21
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +227 -3
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +235 -7
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +172 -2
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1502 -18
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +993 -4
- package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +7 -1
- package/dist/extensions/pi-subagents/test/support/helpers.ts +32 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +59 -2
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +32 -6
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +25 -24
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +59 -35
- package/dist/extensions/pi-subagents/test/support/session-lease-child.mjs +42 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +703 -69
- package/dist/extensions/pi-subagents/test/unit/accessible-dir.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +15 -15
- package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +66 -66
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +478 -19
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +228 -7
- package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +198 -78
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +15 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +102 -7
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +439 -6
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +41 -1
- package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +79 -0
- package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +285 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +82 -0
- package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +46 -4
- package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +9 -17
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +175 -3
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +10 -5
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +126 -6
- package/dist/extensions/pi-subagents/test/unit/default-extensions.test.ts +219 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +913 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +13 -2
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +351 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +280 -0
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +575 -0
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +94 -1
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +157 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +367 -5
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +25 -3
- package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +341 -10
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +16 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +168 -7
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +85 -7
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +175 -0
- package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +243 -16
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +79 -36
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +174 -56
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +322 -0
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +204 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +20 -12
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +117 -4
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +40 -7
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +48 -1
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +21 -3
- package/dist/extensions/pi-subagents/test/unit/selector.test.ts +57 -0
- package/dist/extensions/pi-subagents/test/unit/session-lease.test.ts +326 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +101 -2
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +111 -1
- package/dist/extensions/pi-subagents/test/unit/spawn-budget.test.ts +124 -0
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +6 -4
- package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +7 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +397 -0
- package/dist/extensions/pi-subagents/test/unit/steering-notices.test.ts +58 -0
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +173 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +25 -4
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +390 -25
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +837 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +15 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +47 -1
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +56 -10
- package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +387 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-child-status.test.ts +110 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-emission-guard.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-lsp-diagnostics.test.ts +144 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-model-selection.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-render.test.ts +76 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-review.test.ts +327 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +863 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +290 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-turn-delta.test.ts +130 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +32 -4
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +5 -1
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +30 -0
- package/dist/extensions/question/batch.ts +39 -0
- package/dist/extensions/question/dialog-adapter.ts +23 -1
- package/dist/extensions/question/index.ts +237 -7
- package/dist/extensions/question/schemas.ts +28 -15
- package/dist/extensions/question/tests/batch.test.ts +26 -0
- package/dist/extensions/question/tests/ui-protocol.test.ts +23 -3
- package/dist/extensions/question/tui-adapter.ts +2 -3
- package/dist/extensions/question/types.ts +37 -0
- package/dist/extensions/question/ui-protocol.ts +3 -3
- package/dist/skills/batch-grill-me/SKILL.md +3 -1
- package/package.json +1 -1
|
@@ -25,7 +25,26 @@ import {
|
|
|
25
25
|
events,
|
|
26
26
|
tryImport,
|
|
27
27
|
} from "../support/helpers.ts";
|
|
28
|
-
import
|
|
28
|
+
import registerSubagentExtension from "../../src/extension/index.ts";
|
|
29
|
+
import {
|
|
30
|
+
SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
31
|
+
SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
|
|
32
|
+
SUBAGENT_DELEGATION_REQUEST_EVENT,
|
|
33
|
+
SUBAGENT_DELEGATION_RESPONSE_EVENT,
|
|
34
|
+
SUBAGENT_DELEGATION_STARTED_EVENT,
|
|
35
|
+
SUBAGENT_DELEGATION_UPDATE_EVENT,
|
|
36
|
+
type SubagentDelegationResponse,
|
|
37
|
+
type SubagentDelegationUpdate,
|
|
38
|
+
type SubagentDelegationV2Request,
|
|
39
|
+
type SubagentDelegationV2Response,
|
|
40
|
+
type SubagentDelegationV2Started,
|
|
41
|
+
} from "../../src/api/delegation.ts";
|
|
42
|
+
import { INTERCOM_DETACH_REQUEST_EVENT, INTERCOM_DETACH_RESPONSE_EVENT, type SubagentState } from "../../src/shared/types.ts";
|
|
43
|
+
import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
|
|
44
|
+
import { WAIT_TOOL_ENABLED_ENV } from "../../src/runs/background/wait-config.ts";
|
|
45
|
+
import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV } from "../../src/runs/shared/tool-budget.ts";
|
|
46
|
+
import { MainWatchdogRuntime } from "../../src/watchdog/runtime.ts";
|
|
47
|
+
import { MAX_CHILD_PENDING_LINE_BYTES, MAX_CHILD_STDERR_BYTES } from "../../src/runs/shared/child-protocol.ts";
|
|
29
48
|
import {
|
|
30
49
|
SUBAGENT_FANOUT_CHILD_ENV,
|
|
31
50
|
SUBAGENT_PARENT_CHILD_INDEX_ENV,
|
|
@@ -67,6 +86,7 @@ interface RunSyncResult {
|
|
|
67
86
|
agent: string;
|
|
68
87
|
messages: unknown[];
|
|
69
88
|
error?: string;
|
|
89
|
+
protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number };
|
|
70
90
|
model?: string;
|
|
71
91
|
skills?: string[];
|
|
72
92
|
skillsWarning?: string;
|
|
@@ -91,6 +111,11 @@ interface RunSyncResult {
|
|
|
91
111
|
outputReference?: { path: string; bytes: number; lines: number; message: string };
|
|
92
112
|
outputSaveError?: string;
|
|
93
113
|
sessionFile?: string;
|
|
114
|
+
structuredOutput?: unknown;
|
|
115
|
+
agentContract?: { version: 1 };
|
|
116
|
+
execution?: { status?: string; success?: boolean; exitCode?: number; error?: string };
|
|
117
|
+
review?: { status?: string };
|
|
118
|
+
effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean; message?: string } };
|
|
94
119
|
acceptance?: {
|
|
95
120
|
status?: string;
|
|
96
121
|
verifyRuns?: Array<{ status?: string }>;
|
|
@@ -103,6 +128,56 @@ interface MockPiCallRecord {
|
|
|
103
128
|
systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>;
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
function writeWatchdogSettings(projectDir: string, tailMs = 120_000): void {
|
|
132
|
+
const settingsPath = path.join(projectDir, ".selesai", "settings.json");
|
|
133
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
134
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
135
|
+
subagents: {
|
|
136
|
+
watchdog: {
|
|
137
|
+
enabled: true,
|
|
138
|
+
children: {
|
|
139
|
+
enabled: true,
|
|
140
|
+
watchdogTailTimeoutMs: tailMs,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}, null, 2), "utf-8");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function withIsolatedWatchdogSettings<T>(projectDir: string, run: () => Promise<T>): Promise<T> {
|
|
148
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
149
|
+
const previousHome = process.env.HOME;
|
|
150
|
+
const previousUserProfile = process.env.USERPROFILE;
|
|
151
|
+
const isolatedHome = path.join(projectDir, "isolated-home");
|
|
152
|
+
process.env.SELESAI_CODING_AGENT_DIR = path.join(isolatedHome, ".selesai", "agent");
|
|
153
|
+
process.env.HOME = isolatedHome;
|
|
154
|
+
process.env.USERPROFILE = isolatedHome;
|
|
155
|
+
try {
|
|
156
|
+
return await run();
|
|
157
|
+
} finally {
|
|
158
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
159
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
160
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
161
|
+
else process.env.HOME = previousHome;
|
|
162
|
+
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
163
|
+
else process.env.USERPROFILE = previousUserProfile;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function childWatchdogStatus(phase: "idle" | "reviewing" | "autofollow" | "settling" | "stale" | "failed", seq: number, followUpPending = false) {
|
|
168
|
+
return {
|
|
169
|
+
type: CHILD_WATCHDOG_STATUS_EVENT,
|
|
170
|
+
runId: "watchdog-child-run",
|
|
171
|
+
agent: "echo",
|
|
172
|
+
childIndex: 0,
|
|
173
|
+
stepIndex: 0,
|
|
174
|
+
seq,
|
|
175
|
+
phase,
|
|
176
|
+
ts: Date.now() + seq,
|
|
177
|
+
followUpPending,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
106
181
|
function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") {
|
|
107
182
|
return {
|
|
108
183
|
type: "message_end",
|
|
@@ -143,13 +218,17 @@ interface ExecutorToolResult {
|
|
|
143
218
|
isError?: boolean;
|
|
144
219
|
details?: {
|
|
145
220
|
totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
|
|
221
|
+
asyncId?: string;
|
|
146
222
|
timeoutMs?: number;
|
|
223
|
+
turnBudget?: { maxTurns: number; graceTurns: number };
|
|
224
|
+
artifacts?: { dir: string; files: ArtifactPaths[] };
|
|
147
225
|
};
|
|
148
226
|
}
|
|
149
227
|
|
|
150
228
|
interface ExecutorModule {
|
|
151
229
|
createSubagentExecutor?: (...args: unknown[]) => {
|
|
152
230
|
execute: (...args: unknown[]) => Promise<ExecutorToolResult>;
|
|
231
|
+
executeDelegated: (...args: unknown[]) => Promise<ExecutorToolResult>;
|
|
153
232
|
};
|
|
154
233
|
}
|
|
155
234
|
|
|
@@ -218,16 +297,31 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
218
297
|
return readCall().args;
|
|
219
298
|
}
|
|
220
299
|
|
|
221
|
-
function makeExecutor(
|
|
300
|
+
function makeExecutor(
|
|
301
|
+
agents = [makeAgent("echo")],
|
|
302
|
+
config: Record<string, unknown> = {},
|
|
303
|
+
asyncByDefault = false,
|
|
304
|
+
initialSpawnState?: NonNullable<SubagentState["subagentSpawns"]>,
|
|
305
|
+
allowMutatingManagementActions = true,
|
|
306
|
+
initialAsyncJobs: SubagentState["asyncJobs"] = new Map(),
|
|
307
|
+
) {
|
|
222
308
|
return createSubagentExecutor!({
|
|
223
309
|
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
224
|
-
state: {
|
|
310
|
+
state: {
|
|
311
|
+
baseCwd: tempDir,
|
|
312
|
+
currentSessionId: initialSpawnState?.sessionId ?? null,
|
|
313
|
+
...(initialSpawnState ? { subagentSpawns: initialSpawnState } : {}),
|
|
314
|
+
asyncJobs: initialAsyncJobs,
|
|
315
|
+
foregroundControls: new Map(),
|
|
316
|
+
lastForegroundControlId: null,
|
|
317
|
+
},
|
|
225
318
|
config,
|
|
226
|
-
asyncByDefault
|
|
319
|
+
asyncByDefault,
|
|
227
320
|
tempArtifactsDir: tempDir,
|
|
228
321
|
getSubagentSessionRoot: () => tempDir,
|
|
229
322
|
expandTilde: (value: string) => value,
|
|
230
323
|
discoverAgents: () => ({ agents }),
|
|
324
|
+
allowMutatingManagementActions,
|
|
231
325
|
});
|
|
232
326
|
}
|
|
233
327
|
|
|
@@ -263,6 +357,83 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
263
357
|
assert.match(result.content[0]?.text ?? "", /single alias finished/);
|
|
264
358
|
});
|
|
265
359
|
|
|
360
|
+
it("admits a zero run-level tool budget only for marked v2 delegated execution", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
361
|
+
const zeroBudget = { hard: 0, block: "*" as const };
|
|
362
|
+
const params = { agent: "echo", task: "Answer without tools", toolBudget: zeroBudget };
|
|
363
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
364
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
365
|
+
|
|
366
|
+
const ordinary = await executor.execute(
|
|
367
|
+
"ordinary-zero-budget",
|
|
368
|
+
{ ...params, delegatedAllowZeroToolBudget: true },
|
|
369
|
+
new AbortController().signal,
|
|
370
|
+
undefined,
|
|
371
|
+
ctx,
|
|
372
|
+
);
|
|
373
|
+
assert.equal(ordinary.isError, true);
|
|
374
|
+
assert.match(ordinary.content[0]?.text ?? "", /toolBudget\.hard must be an integer >= 1/);
|
|
375
|
+
|
|
376
|
+
const v1Delegated = await executor.executeDelegated(
|
|
377
|
+
"v1-delegated-zero-budget",
|
|
378
|
+
params,
|
|
379
|
+
new AbortController().signal,
|
|
380
|
+
undefined,
|
|
381
|
+
ctx,
|
|
382
|
+
);
|
|
383
|
+
assert.equal(v1Delegated.isError, true);
|
|
384
|
+
assert.match(v1Delegated.content[0]?.text ?? "", /toolBudget\.hard must be an integer >= 1/);
|
|
385
|
+
|
|
386
|
+
mockPi.onCall({ echoEnv: [TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV] });
|
|
387
|
+
const v2Delegated = await executor.executeDelegated(
|
|
388
|
+
"v2-delegated-zero-budget",
|
|
389
|
+
{ ...params, delegatedAllowZeroToolBudget: true },
|
|
390
|
+
new AbortController().signal,
|
|
391
|
+
undefined,
|
|
392
|
+
ctx,
|
|
393
|
+
);
|
|
394
|
+
assert.equal(v2Delegated.isError, undefined);
|
|
395
|
+
const env = JSON.parse(v2Delegated.content[0]?.text ?? "{}") as Record<string, string>;
|
|
396
|
+
assert.deepEqual(JSON.parse(env[TOOL_BUDGET_ENV] ?? "null"), zeroBudget);
|
|
397
|
+
assert.equal(env[TOOL_BUDGET_ZERO_AUTH_ENV], "1");
|
|
398
|
+
assert.equal(mockPi.callCount(), 1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("keeps delegated agent and config tool budgets at a minimum of one", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
402
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
403
|
+
const cases = [
|
|
404
|
+
makeExecutor([makeAgent("echo", { toolBudget: { hard: 0 } })]),
|
|
405
|
+
makeExecutor([makeAgent("echo")], { toolBudget: { hard: 0 } }),
|
|
406
|
+
];
|
|
407
|
+
for (const [index, executor] of cases.entries()) {
|
|
408
|
+
const result = await executor.executeDelegated(
|
|
409
|
+
`delegated-default-zero-budget-${index}`,
|
|
410
|
+
{ agent: "echo", task: "Do work", delegatedAllowZeroToolBudget: true },
|
|
411
|
+
new AbortController().signal,
|
|
412
|
+
undefined,
|
|
413
|
+
ctx,
|
|
414
|
+
);
|
|
415
|
+
assert.equal(result.isError, true);
|
|
416
|
+
assert.match(result.content[0]?.text ?? "", /(?:agent\.|config\.)?toolBudget\.hard must be an integer >= 1/);
|
|
417
|
+
}
|
|
418
|
+
assert.equal(mockPi.callCount(), 0);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("rejects string \"none\" acceptance before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
422
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
423
|
+
|
|
424
|
+
const result = await executor.execute(
|
|
425
|
+
"string-none-acceptance",
|
|
426
|
+
{ agent: "echo", task: "Do work", acceptance: "none" },
|
|
427
|
+
new AbortController().signal,
|
|
428
|
+
undefined,
|
|
429
|
+
makeMinimalCtx(tempDir),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
assert.equal(result.isError, true);
|
|
433
|
+
assert.match(result.content[0]?.text ?? "", /acceptance level "none" requires a reason/);
|
|
434
|
+
assert.equal(mockPi.callCount(), 0);
|
|
435
|
+
});
|
|
436
|
+
|
|
266
437
|
it("rejects unknown action strings at runtime", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
267
438
|
const executor = makeExecutor([makeAgent("echo")]);
|
|
268
439
|
|
|
@@ -279,6 +450,45 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
279
450
|
assert.match(result.content[0]?.text ?? "", /Valid:/);
|
|
280
451
|
});
|
|
281
452
|
|
|
453
|
+
it("routes watchdog.configure through the management action path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
454
|
+
const gpt = { provider: "openai-codex", id: "gpt-5.5", reasoning: true };
|
|
455
|
+
const opus = { provider: "anthropic", id: "claude-opus-4-8", reasoning: true };
|
|
456
|
+
const models = [gpt, opus];
|
|
457
|
+
const watchdog = new MainWatchdogRuntime({ cwd: tempDir });
|
|
458
|
+
const executor = createSubagentExecutor!({
|
|
459
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
460
|
+
state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
|
|
461
|
+
config: {},
|
|
462
|
+
asyncByDefault: false,
|
|
463
|
+
watchdog,
|
|
464
|
+
tempArtifactsDir: tempDir,
|
|
465
|
+
getSubagentSessionRoot: () => tempDir,
|
|
466
|
+
expandTilde: (value: string) => value,
|
|
467
|
+
discoverAgents: () => ({ agents: [makeAgent("echo")] }),
|
|
468
|
+
});
|
|
469
|
+
const ctx = {
|
|
470
|
+
...makeMinimalCtx(tempDir),
|
|
471
|
+
model: gpt,
|
|
472
|
+
modelRegistry: {
|
|
473
|
+
getAvailable: () => models,
|
|
474
|
+
find: (provider: string, id: string) => models.find((model) => model.provider === provider && model.id === id),
|
|
475
|
+
hasConfiguredAuth: (model: unknown) => Boolean(model),
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const result = await executor.execute(
|
|
480
|
+
"watchdog-configure",
|
|
481
|
+
{ action: "watchdog.configure", model: "recommended" },
|
|
482
|
+
new AbortController().signal,
|
|
483
|
+
undefined,
|
|
484
|
+
ctx,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
assert.equal(result.isError, undefined);
|
|
488
|
+
assert.match(result.content[0]?.text ?? "", /session model configured: anthropic\/claude-opus-4-8:high/);
|
|
489
|
+
assert.equal(watchdog.getSnapshot(tempDir).config.main.model, "anthropic/claude-opus-4-8");
|
|
490
|
+
});
|
|
491
|
+
|
|
282
492
|
it("rejects duplicate concurrent subagent execution calls", async () => {
|
|
283
493
|
mockPi.onCall({ output: "first call completed", delay: 100 });
|
|
284
494
|
const executor = makeExecutor([makeAgent("echo")]);
|
|
@@ -294,7 +504,310 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
294
504
|
assert.equal(mockPi.callCount(), 1);
|
|
295
505
|
});
|
|
296
506
|
|
|
297
|
-
it("
|
|
507
|
+
it("allows concurrent delegated foreground execution calls", async () => {
|
|
508
|
+
mockPi.onCall({ output: "first delegated call", delay: 100 });
|
|
509
|
+
mockPi.onCall({ output: "second delegated call", delay: 100 });
|
|
510
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
|
|
511
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
512
|
+
|
|
513
|
+
const [first, second] = await Promise.all([
|
|
514
|
+
executor.executeDelegated("first", { agent: "echo", task: "First delegated call" }, new AbortController().signal, undefined, ctx),
|
|
515
|
+
executor.executeDelegated("second", { agent: "second", task: "Second delegated call" }, new AbortController().signal, undefined, ctx),
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
assert.equal(first.isError, undefined);
|
|
519
|
+
assert.equal(second.isError, undefined);
|
|
520
|
+
assert.equal(mockPi.callCount(), 2);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("routes registered strict v1 delegation through the concurrent executor", async () => {
|
|
524
|
+
mockPi.onCall({
|
|
525
|
+
steps: [
|
|
526
|
+
{ jsonl: [events.toolStart("read", { path: "package.json" })], delay: 20 },
|
|
527
|
+
{ jsonl: [events.toolEnd("read"), events.toolResult("read", "{}")], delay: 20 },
|
|
528
|
+
{ jsonl: [events.assistantMessage("registered first delegated call")] },
|
|
529
|
+
],
|
|
530
|
+
});
|
|
531
|
+
mockPi.onCall({ output: "registered second delegated call", delay: 100 });
|
|
532
|
+
const extensionEvents = createEventBus();
|
|
533
|
+
const runtimeHandlers = new Map<string, Array<(event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void>>();
|
|
534
|
+
const fakePi = new Proxy({
|
|
535
|
+
events: extensionEvents,
|
|
536
|
+
on(event: string, handler: (event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void) {
|
|
537
|
+
const handlers = runtimeHandlers.get(event) ?? [];
|
|
538
|
+
handlers.push(handler);
|
|
539
|
+
runtimeHandlers.set(event, handlers);
|
|
540
|
+
return () => runtimeHandlers.set(event, (runtimeHandlers.get(event) ?? []).filter((entry) => entry !== handler));
|
|
541
|
+
},
|
|
542
|
+
registerTool() {},
|
|
543
|
+
registerCommand() {},
|
|
544
|
+
registerShortcut() {},
|
|
545
|
+
registerMessageRenderer() {},
|
|
546
|
+
sendMessage() {},
|
|
547
|
+
getSessionName() { return undefined; },
|
|
548
|
+
}, {
|
|
549
|
+
get(target, prop) {
|
|
550
|
+
if (prop in target) return target[prop as keyof typeof target];
|
|
551
|
+
return () => undefined;
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
const ctx = {
|
|
555
|
+
...makeMinimalCtx(tempDir),
|
|
556
|
+
sessionManager: {
|
|
557
|
+
getSessionId: () => "registered-delegation-session",
|
|
558
|
+
getSessionFile: () => path.join(tempDir, "registered-delegation-session.jsonl"),
|
|
559
|
+
getEntries: () => [],
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
const responses: SubagentDelegationResponse[] = [];
|
|
563
|
+
const updates: SubagentDelegationUpdate[] = [];
|
|
564
|
+
extensionEvents.on(SUBAGENT_DELEGATION_RESPONSE_EVENT, (payload) => responses.push(payload as SubagentDelegationResponse));
|
|
565
|
+
extensionEvents.on(SUBAGENT_DELEGATION_UPDATE_EVENT, (payload) => updates.push(payload as SubagentDelegationUpdate));
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
registerSubagentExtension(fakePi as never);
|
|
569
|
+
for (const handler of runtimeHandlers.get("session_start") ?? []) {
|
|
570
|
+
await handler({ reason: "startup" }, ctx);
|
|
571
|
+
}
|
|
572
|
+
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
|
|
573
|
+
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
574
|
+
requestId: "registered-a",
|
|
575
|
+
agent: "worker",
|
|
576
|
+
task: "First registered delegated call",
|
|
577
|
+
context: "fresh",
|
|
578
|
+
cwd: tempDir,
|
|
579
|
+
agentContract: { version: 1 },
|
|
580
|
+
});
|
|
581
|
+
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
|
|
582
|
+
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
583
|
+
requestId: "registered-b",
|
|
584
|
+
agent: "reviewer",
|
|
585
|
+
task: "Second registered delegated call",
|
|
586
|
+
context: "fresh",
|
|
587
|
+
cwd: tempDir,
|
|
588
|
+
agentContract: { version: 1 },
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const callDeadlineAt = Date.now() + 30_000;
|
|
592
|
+
while (mockPi.callCount() < 2 && Date.now() < callDeadlineAt) {
|
|
593
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
594
|
+
}
|
|
595
|
+
assert.equal(mockPi.callCount(), 2, "registered strict v1 requests should bypass the ordinary one-call guard");
|
|
596
|
+
|
|
597
|
+
const responseDeadlineAt = Date.now() + 30_000;
|
|
598
|
+
while (responses.length < 2 && Date.now() < responseDeadlineAt) {
|
|
599
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
600
|
+
}
|
|
601
|
+
assert.deepEqual(responses.map((response) => response.requestId).sort(), ["registered-a", "registered-b"]);
|
|
602
|
+
assert.ok(responses.every((response) => response.status === "completed"));
|
|
603
|
+
assert.deepEqual(responses.map((response) => response.output).sort(), [
|
|
604
|
+
"registered first delegated call",
|
|
605
|
+
"registered second delegated call",
|
|
606
|
+
]);
|
|
607
|
+
const toolResponse = responses.find((response) => response.output === "registered first delegated call");
|
|
608
|
+
assert.ok(toolResponse);
|
|
609
|
+
assert.equal(updates.some((update) => update.requestId === toolResponse.requestId && update.currentTool === "read"), true);
|
|
610
|
+
} finally {
|
|
611
|
+
for (const handler of runtimeHandlers.get("session_shutdown") ?? []) {
|
|
612
|
+
await handler({}, ctx);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("routes registered strict v2 text delegation through the concurrent executor", async () => {
|
|
618
|
+
const literalJsonText = '{"looks":"json"}';
|
|
619
|
+
mockPi.onCall({
|
|
620
|
+
steps: [
|
|
621
|
+
{ jsonl: [events.toolStart("read", { path: "package.json" })], delay: 20 },
|
|
622
|
+
{ jsonl: [events.toolEnd("read"), events.toolResult("read", "{}")], delay: 20 },
|
|
623
|
+
{
|
|
624
|
+
jsonl: [{
|
|
625
|
+
type: "message_end",
|
|
626
|
+
message: {
|
|
627
|
+
role: "assistant",
|
|
628
|
+
content: [{ type: "text", text: literalJsonText }],
|
|
629
|
+
model: "mock/test-model",
|
|
630
|
+
stopReason: "stop",
|
|
631
|
+
usage: {
|
|
632
|
+
input: 11,
|
|
633
|
+
output: 7,
|
|
634
|
+
cacheRead: 3,
|
|
635
|
+
cacheWrite: 2,
|
|
636
|
+
cost: { total: 0.0125 },
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
}],
|
|
640
|
+
delay: 60,
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
});
|
|
644
|
+
mockPi.onCall({ output: "registered v2 second node", delay: 100 });
|
|
645
|
+
const extensionEvents = createEventBus();
|
|
646
|
+
const runtimeHandlers = new Map<string, Array<(event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void>>();
|
|
647
|
+
const fakePi = new Proxy({
|
|
648
|
+
events: extensionEvents,
|
|
649
|
+
on(event: string, handler: (event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void) {
|
|
650
|
+
const handlers = runtimeHandlers.get(event) ?? [];
|
|
651
|
+
handlers.push(handler);
|
|
652
|
+
runtimeHandlers.set(event, handlers);
|
|
653
|
+
return () => runtimeHandlers.set(event, (runtimeHandlers.get(event) ?? []).filter((entry) => entry !== handler));
|
|
654
|
+
},
|
|
655
|
+
registerTool() {},
|
|
656
|
+
registerCommand() {},
|
|
657
|
+
registerShortcut() {},
|
|
658
|
+
registerMessageRenderer() {},
|
|
659
|
+
sendMessage() {},
|
|
660
|
+
getSessionName() { return undefined; },
|
|
661
|
+
}, {
|
|
662
|
+
get(target, prop) {
|
|
663
|
+
if (prop in target) return target[prop as keyof typeof target];
|
|
664
|
+
return () => undefined;
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
const ctx = {
|
|
668
|
+
...makeMinimalCtx(tempDir),
|
|
669
|
+
modelRegistry: {
|
|
670
|
+
getAvailable: () => [{ provider: "mock", id: "test-model", reasoning: true }],
|
|
671
|
+
},
|
|
672
|
+
sessionManager: {
|
|
673
|
+
getSessionId: () => "registered-delegation-v2-session",
|
|
674
|
+
getSessionFile: () => path.join(tempDir, "registered-delegation-v2-session.jsonl"),
|
|
675
|
+
getEntries: () => [],
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
const started: SubagentDelegationV2Started[] = [];
|
|
679
|
+
const responses: SubagentDelegationV2Response[] = [];
|
|
680
|
+
extensionEvents.on(SUBAGENT_DELEGATION_STARTED_EVENT, (payload) => {
|
|
681
|
+
if ((payload as { version?: unknown }).version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
|
|
682
|
+
started.push(payload as SubagentDelegationV2Started);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
extensionEvents.on(SUBAGENT_DELEGATION_RESPONSE_EVENT, (payload) => {
|
|
686
|
+
if ((payload as { version?: unknown }).version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
|
|
687
|
+
responses.push(payload as SubagentDelegationV2Response);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const firstRequest = {
|
|
692
|
+
version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
|
|
693
|
+
requestId: "registered-v2-a",
|
|
694
|
+
ownerRunId: "owner-v2",
|
|
695
|
+
nodeId: "node-a",
|
|
696
|
+
agent: "worker",
|
|
697
|
+
task: "Return literal JSON-looking text",
|
|
698
|
+
context: "fresh",
|
|
699
|
+
cwd: tempDir,
|
|
700
|
+
model: "mock/test-model",
|
|
701
|
+
thinking: "high",
|
|
702
|
+
result: { kind: "text" },
|
|
703
|
+
} satisfies SubagentDelegationV2Request;
|
|
704
|
+
const secondRequest = {
|
|
705
|
+
version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
|
|
706
|
+
requestId: "registered-v2-b",
|
|
707
|
+
ownerRunId: "owner-v2",
|
|
708
|
+
nodeId: "node-b",
|
|
709
|
+
agent: "reviewer",
|
|
710
|
+
task: "Run the second logical node",
|
|
711
|
+
context: "fresh",
|
|
712
|
+
cwd: tempDir,
|
|
713
|
+
model: "mock/test-model",
|
|
714
|
+
thinking: "high",
|
|
715
|
+
result: { kind: "text" },
|
|
716
|
+
} satisfies SubagentDelegationV2Request;
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
registerSubagentExtension(fakePi as never);
|
|
720
|
+
for (const handler of runtimeHandlers.get("session_start") ?? []) {
|
|
721
|
+
await handler({ reason: "startup" }, ctx);
|
|
722
|
+
}
|
|
723
|
+
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, firstRequest);
|
|
724
|
+
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, secondRequest);
|
|
725
|
+
|
|
726
|
+
const callDeadlineAt = Date.now() + 30_000;
|
|
727
|
+
while (mockPi.callCount() < 2 && responses.length < 2 && Date.now() < callDeadlineAt) {
|
|
728
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
729
|
+
}
|
|
730
|
+
assert.equal(mockPi.callCount(), 2, `different V2 logical nodes should use the concurrent delegated execution path: ${JSON.stringify(responses)}`);
|
|
731
|
+
assert.deepEqual(started.map(({ requestId, ownerRunId, nodeId }) => ({ requestId, ownerRunId, nodeId })).sort((a, b) => a.nodeId.localeCompare(b.nodeId)), [
|
|
732
|
+
{ requestId: "registered-v2-a", ownerRunId: "owner-v2", nodeId: "node-a" },
|
|
733
|
+
{ requestId: "registered-v2-b", ownerRunId: "owner-v2", nodeId: "node-b" },
|
|
734
|
+
]);
|
|
735
|
+
|
|
736
|
+
const responseDeadlineAt = Date.now() + 30_000;
|
|
737
|
+
while (responses.length < 2 && Date.now() < responseDeadlineAt) {
|
|
738
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
739
|
+
}
|
|
740
|
+
assert.equal(responses.length, 2);
|
|
741
|
+
assert.ok(responses.every((response) => response.status === "completed"));
|
|
742
|
+
const terminalResponses = responses.filter((response) => response.status !== "invalid_request");
|
|
743
|
+
assert.equal(terminalResponses.length, 2);
|
|
744
|
+
for (const response of terminalResponses) {
|
|
745
|
+
assert.equal(response.ownerRunId, "owner-v2");
|
|
746
|
+
assert.equal(response.model, "mock/test-model:high");
|
|
747
|
+
assert.equal(response.thinking, "high");
|
|
748
|
+
}
|
|
749
|
+
const literalResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === literalJsonText);
|
|
750
|
+
assert.ok(literalResponse);
|
|
751
|
+
assert.deepEqual(literalResponse.result, { kind: "text", text: literalJsonText });
|
|
752
|
+
assert.deepEqual(literalResponse.usage && {
|
|
753
|
+
input: literalResponse.usage.input,
|
|
754
|
+
output: literalResponse.usage.output,
|
|
755
|
+
cacheRead: literalResponse.usage.cacheRead,
|
|
756
|
+
cacheWrite: literalResponse.usage.cacheWrite,
|
|
757
|
+
cost: literalResponse.usage.cost,
|
|
758
|
+
turns: literalResponse.usage.turns,
|
|
759
|
+
toolCalls: literalResponse.usage.toolCalls,
|
|
760
|
+
}, {
|
|
761
|
+
input: 11,
|
|
762
|
+
output: 7,
|
|
763
|
+
cacheRead: 3,
|
|
764
|
+
cacheWrite: 2,
|
|
765
|
+
cost: 0.0125,
|
|
766
|
+
turns: 1,
|
|
767
|
+
toolCalls: 1,
|
|
768
|
+
});
|
|
769
|
+
assert.equal(typeof literalResponse.usage?.durationMs, "number");
|
|
770
|
+
const plainResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === "registered v2 second node");
|
|
771
|
+
assert.ok(plainResponse);
|
|
772
|
+
} finally {
|
|
773
|
+
for (const handler of runtimeHandlers.get("session_shutdown") ?? []) {
|
|
774
|
+
await handler({}, ctx);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("allows concurrent async launches in one turn", async () => {
|
|
780
|
+
mockPi.onCall({ output: "async one" });
|
|
781
|
+
mockPi.onCall({ output: "async two" });
|
|
782
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
|
|
783
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
784
|
+
const [first, second] = await Promise.all([
|
|
785
|
+
executor.execute("first", { agent: "echo", task: "First", async: true }, new AbortController().signal, undefined, ctx),
|
|
786
|
+
executor.execute("second", { agent: "second", task: "Second", async: true }, new AbortController().signal, undefined, ctx),
|
|
787
|
+
]);
|
|
788
|
+
assert.doesNotMatch(first.content[0]?.text ?? "", /Issue exactly ONE subagent call per turn/);
|
|
789
|
+
assert.doesNotMatch(second.content[0]?.text ?? "", /Issue exactly ONE subagent call per turn/);
|
|
790
|
+
const deadlineAt = Date.now() + 30_000;
|
|
791
|
+
while (mockPi.callCount() < 2 && Date.now() < deadlineAt) {
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
793
|
+
}
|
|
794
|
+
assert.equal(mockPi.callCount(), 2, "both detached mock children should start before test cleanup");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("does not impose a cumulative spawn cap by default", async () => {
|
|
798
|
+
mockPi.onCall({ output: "continued after forty launches" });
|
|
799
|
+
const spawnState = { sessionId: "session-123", count: 40 };
|
|
800
|
+
const executor = makeExecutor([makeAgent("echo")], {}, false, spawnState);
|
|
801
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
802
|
+
|
|
803
|
+
const result = await executor.execute("forty-one", { agent: "echo", task: "Continue work" }, new AbortController().signal, undefined, ctx);
|
|
804
|
+
|
|
805
|
+
assert.equal(result.isError, undefined);
|
|
806
|
+
assert.equal(mockPi.callCount(), 1);
|
|
807
|
+
assert.equal(spawnState.count, 40, "unlimited sessions should bypass cumulative accounting");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("blocks total subagent spawns after an opt-in per-session quota", async () => {
|
|
298
811
|
mockPi.onCall({ output: "first call completed" });
|
|
299
812
|
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 1 });
|
|
300
813
|
const ctx = makeMinimalCtx(tempDir);
|
|
@@ -308,6 +821,185 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
308
821
|
assert.equal(mockPi.callCount(), 1);
|
|
309
822
|
});
|
|
310
823
|
|
|
824
|
+
it("reports structured spawn-budget usage through status", async () => {
|
|
825
|
+
const spawnState = { sessionId: "session-123", count: 3, configuredLimit: 4, granted: 1, grantHistory: [] };
|
|
826
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 4 }, false, spawnState);
|
|
827
|
+
|
|
828
|
+
const status = await executor.execute("status", { action: "status" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
829
|
+
|
|
830
|
+
assert.match(status.content[0]?.text ?? "", /^Spawn budget: 3\/5 used, 2 remaining/);
|
|
831
|
+
assert.deepEqual(status.details?.spawnBudget, {
|
|
832
|
+
used: 3,
|
|
833
|
+
configuredLimit: 4,
|
|
834
|
+
granted: 1,
|
|
835
|
+
limit: 5,
|
|
836
|
+
remaining: 2,
|
|
837
|
+
grantRemaining: 3,
|
|
838
|
+
grantHistory: [],
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("preflights static chains before creating run artifacts", async () => {
|
|
843
|
+
const sessionDir = path.join(tempDir, "preflight-session");
|
|
844
|
+
const executor = makeExecutor(
|
|
845
|
+
[makeAgent("echo"), makeAgent("second")],
|
|
846
|
+
{ maxSubagentSpawnsPerSession: 1 },
|
|
847
|
+
);
|
|
848
|
+
const result = await executor.execute(
|
|
849
|
+
"chain-preflight",
|
|
850
|
+
{
|
|
851
|
+
chain: [
|
|
852
|
+
{ agent: "echo", task: "First" },
|
|
853
|
+
{ agent: "second", task: "Second" },
|
|
854
|
+
],
|
|
855
|
+
sessionDir,
|
|
856
|
+
},
|
|
857
|
+
new AbortController().signal,
|
|
858
|
+
undefined,
|
|
859
|
+
makeMinimalCtx(tempDir),
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
assert.equal(result.isError, true);
|
|
863
|
+
assert.match(result.content[0]?.text ?? "", /0\/1 used, 2 requested\).*1 remaining/);
|
|
864
|
+
assert.match(result.content[0]?.text ?? "", /no children were started/);
|
|
865
|
+
assert.equal(fs.existsSync(sessionDir), false);
|
|
866
|
+
assert.equal(mockPi.callCount(), 0);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("applies bounded root-interactive spawn-budget grants", async () => {
|
|
870
|
+
mockPi.onCall({ output: "continued after grant" });
|
|
871
|
+
const spawnState = { sessionId: "session-123", count: 1 };
|
|
872
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 1 }, false, spawnState);
|
|
873
|
+
const decisions = [false, true];
|
|
874
|
+
let confirmations = 0;
|
|
875
|
+
const interactiveCtx = {
|
|
876
|
+
...makeMinimalCtx(tempDir),
|
|
877
|
+
hasUI: true,
|
|
878
|
+
ui: { confirm: async () => { confirmations += 1; return decisions.shift() ?? false; } },
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const canceled = await executor.execute(
|
|
882
|
+
"cancel-grant",
|
|
883
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
884
|
+
new AbortController().signal,
|
|
885
|
+
undefined,
|
|
886
|
+
interactiveCtx,
|
|
887
|
+
);
|
|
888
|
+
const granted = await executor.execute(
|
|
889
|
+
"grant",
|
|
890
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
891
|
+
new AbortController().signal,
|
|
892
|
+
undefined,
|
|
893
|
+
interactiveCtx,
|
|
894
|
+
);
|
|
895
|
+
const run = await executor.execute(
|
|
896
|
+
"after-grant",
|
|
897
|
+
{ agent: "echo", task: "Continue" },
|
|
898
|
+
new AbortController().signal,
|
|
899
|
+
undefined,
|
|
900
|
+
interactiveCtx,
|
|
901
|
+
);
|
|
902
|
+
const exhausted = await executor.execute(
|
|
903
|
+
"grant-again",
|
|
904
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
905
|
+
new AbortController().signal,
|
|
906
|
+
undefined,
|
|
907
|
+
interactiveCtx,
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
assert.equal(canceled.isError, undefined);
|
|
911
|
+
assert.match(canceled.content[0]?.text ?? "", /grant canceled; no capacity was added/i);
|
|
912
|
+
assert.equal(granted.isError, undefined);
|
|
913
|
+
assert.match(granted.content[0]?.text ?? "", /grant applied: \+1/i);
|
|
914
|
+
assert.equal(confirmations, 2);
|
|
915
|
+
assert.equal(granted.details?.spawnBudget?.limit, 2);
|
|
916
|
+
assert.equal(run.isError, undefined);
|
|
917
|
+
assert.equal(spawnState.count, 2);
|
|
918
|
+
assert.equal(exhausted.isError, true);
|
|
919
|
+
assert.match(exhausted.content[0]?.text ?? "", /only 0 of the original configured limit remains grantable/);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("rechecks spawn-budget state after confirmation", async () => {
|
|
923
|
+
const spawnState = { sessionId: "session-123", count: 0 };
|
|
924
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 }, false, spawnState);
|
|
925
|
+
const ctx = {
|
|
926
|
+
...makeMinimalCtx(tempDir),
|
|
927
|
+
hasUI: true,
|
|
928
|
+
ui: { confirm: async () => { spawnState.count = 1; return true; } },
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const result = await executor.execute(
|
|
932
|
+
"grant-race",
|
|
933
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
934
|
+
new AbortController().signal,
|
|
935
|
+
undefined,
|
|
936
|
+
ctx,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
assert.equal(result.isError, true);
|
|
940
|
+
assert.match(result.content[0]?.text ?? "", /budget, or active-child state changed/);
|
|
941
|
+
assert.equal(result.details?.spawnBudget?.granted, 0);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("rejects spawn-budget grants outside a settled root interactive session", async () => {
|
|
945
|
+
mockPi.onCall({ output: "still running", delay: 100 });
|
|
946
|
+
const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 });
|
|
947
|
+
const headless = await executor.execute(
|
|
948
|
+
"headless-grant",
|
|
949
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
950
|
+
new AbortController().signal,
|
|
951
|
+
undefined,
|
|
952
|
+
makeMinimalCtx(tempDir),
|
|
953
|
+
);
|
|
954
|
+
const childSafe = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 }, false, undefined, false);
|
|
955
|
+
const child = await childSafe.execute(
|
|
956
|
+
"child-grant",
|
|
957
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
958
|
+
new AbortController().signal,
|
|
959
|
+
undefined,
|
|
960
|
+
{ ...makeMinimalCtx(tempDir), hasUI: true },
|
|
961
|
+
);
|
|
962
|
+
const asyncActive = makeExecutor(
|
|
963
|
+
[makeAgent("echo")],
|
|
964
|
+
{ maxSubagentSpawnsPerSession: 2 },
|
|
965
|
+
false,
|
|
966
|
+
undefined,
|
|
967
|
+
true,
|
|
968
|
+
new Map([["async-active", { asyncId: "async-active", asyncDir: tempDir, status: "running", sessionId: "session-123" }]]),
|
|
969
|
+
);
|
|
970
|
+
const detached = await asyncActive.execute(
|
|
971
|
+
"async-active-grant",
|
|
972
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
973
|
+
new AbortController().signal,
|
|
974
|
+
undefined,
|
|
975
|
+
{ ...makeMinimalCtx(tempDir), hasUI: true },
|
|
976
|
+
);
|
|
977
|
+
const running = executor.execute(
|
|
978
|
+
"running",
|
|
979
|
+
{ agent: "echo", task: "Long run" },
|
|
980
|
+
new AbortController().signal,
|
|
981
|
+
undefined,
|
|
982
|
+
makeMinimalCtx(tempDir),
|
|
983
|
+
);
|
|
984
|
+
const active = await executor.execute(
|
|
985
|
+
"active-grant",
|
|
986
|
+
{ action: "grant-spawn-budget", additional: 1 },
|
|
987
|
+
new AbortController().signal,
|
|
988
|
+
undefined,
|
|
989
|
+
{ ...makeMinimalCtx(tempDir), hasUI: true },
|
|
990
|
+
);
|
|
991
|
+
await running;
|
|
992
|
+
|
|
993
|
+
assert.equal(headless.isError, true);
|
|
994
|
+
assert.match(headless.content[0]?.text ?? "", /root interactive parent session/);
|
|
995
|
+
assert.equal(child.isError, true);
|
|
996
|
+
assert.match(child.content[0]?.text ?? "", /root interactive parent session/);
|
|
997
|
+
assert.equal(detached.isError, true);
|
|
998
|
+
assert.match(detached.content[0]?.text ?? "", /rejected while current-session children are queued or running/);
|
|
999
|
+
assert.equal(active.isError, true);
|
|
1000
|
+
assert.match(active.content[0]?.text ?? "", /rejected while current-session children are queued or running/);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
311
1003
|
it("allows management actions while an execution call is in progress", async () => {
|
|
312
1004
|
mockPi.onCall({ output: "first call completed", delay: 100 });
|
|
313
1005
|
const executor = makeExecutor([makeAgent("echo")]);
|
|
@@ -379,6 +1071,161 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
379
1071
|
]);
|
|
380
1072
|
});
|
|
381
1073
|
|
|
1074
|
+
it("agent contract v1 reports omitted acceptance separately without injecting a prompt", async () => {
|
|
1075
|
+
mockPi.onCall({ output: "Plan only" });
|
|
1076
|
+
const agents = [makeAgent("worker", { tools: ["read", "write"] })];
|
|
1077
|
+
|
|
1078
|
+
const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
|
|
1079
|
+
runId: "v1-no-acceptance",
|
|
1080
|
+
agentContract: { version: 1 },
|
|
1081
|
+
});
|
|
1082
|
+
const call = readCall();
|
|
1083
|
+
|
|
1084
|
+
assert.equal(result.exitCode, 0);
|
|
1085
|
+
assert.equal(result.agentContract?.version, 1);
|
|
1086
|
+
assert.deepEqual(result.execution, { status: "completed", success: true, exitCode: 0 });
|
|
1087
|
+
assert.equal(result.acceptance?.status, "not-required");
|
|
1088
|
+
assert.equal(result.review?.status, "not-requested");
|
|
1089
|
+
assert.deepEqual(result.effects, {});
|
|
1090
|
+
assert.doesNotMatch(call.args.join("\n"), /## Acceptance Contract/);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it("agent contract v1 keeps acceptance rejection out of execution status", async () => {
|
|
1094
|
+
mockPi.onCall({ output: "Done\n```acceptance-report\n{\"criteriaSatisfied\":[{\"id\":\"criterion-1\",\"status\":\"not-satisfied\",\"evidence\":\"no proof\"}]}\n```" });
|
|
1095
|
+
const agents = [makeAgent("worker", { tools: ["read"], completionGuard: false })];
|
|
1096
|
+
|
|
1097
|
+
const result = await runSync(tempDir, agents, "worker", "Summarize the fix", {
|
|
1098
|
+
runId: "v1-acceptance-reject",
|
|
1099
|
+
agentContract: { version: 1 },
|
|
1100
|
+
acceptance: { level: "checked", criteria: ["Return required proof"] },
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
assert.equal(result.exitCode, 0);
|
|
1104
|
+
assert.equal(result.error, undefined);
|
|
1105
|
+
assert.equal(result.execution?.status, "completed");
|
|
1106
|
+
assert.equal(result.execution?.success, true);
|
|
1107
|
+
assert.equal(result.acceptance?.status, "rejected");
|
|
1108
|
+
assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("agent contract v1 records explicit completion guard as an effect", async () => {
|
|
1112
|
+
mockPi.onCall({ output: "Plan only" });
|
|
1113
|
+
const agents = [makeAgent("worker", { tools: ["read", "write"], completionGuard: true })];
|
|
1114
|
+
|
|
1115
|
+
const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
|
|
1116
|
+
runId: "v1-completion-effect",
|
|
1117
|
+
agentContract: { version: 1 },
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
assert.equal(result.exitCode, 0);
|
|
1121
|
+
assert.equal(result.execution?.status, "completed");
|
|
1122
|
+
assert.equal(result.effects?.fileMutation?.status, "missing");
|
|
1123
|
+
assert.equal(result.effects?.fileMutation?.expected, true);
|
|
1124
|
+
assert.equal(result.effects?.fileMutation?.attempted, false);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("direct single tool calls support outputSchema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1128
|
+
mockPi.onCall({
|
|
1129
|
+
stdoutRaw: [
|
|
1130
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
|
|
1131
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
1132
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
1133
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
1134
|
+
structuredOutputCapture: { ok: true, note: "captured" },
|
|
1135
|
+
});
|
|
1136
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1137
|
+
|
|
1138
|
+
const result = await executor.execute(
|
|
1139
|
+
"single-schema",
|
|
1140
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } }, acceptance: false },
|
|
1141
|
+
new AbortController().signal,
|
|
1142
|
+
undefined,
|
|
1143
|
+
makeMinimalCtx(tempDir),
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
assert.equal(result.isError, undefined);
|
|
1147
|
+
assert.deepEqual(result.details?.results?.[0]?.structuredOutput, { ok: true, note: "captured" });
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it("rejects structured output capture files that were not produced by the structured_output tool", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1151
|
+
mockPi.onCall({ output: "spoofed", structuredOutputCapture: { ok: true } });
|
|
1152
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1153
|
+
|
|
1154
|
+
const result = await executor.execute(
|
|
1155
|
+
"single-schema-spoof",
|
|
1156
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false, artifacts: false },
|
|
1157
|
+
new AbortController().signal,
|
|
1158
|
+
undefined,
|
|
1159
|
+
makeMinimalCtx(tempDir),
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
const child = result.details?.results?.[0];
|
|
1163
|
+
assert.equal(result.isError, true);
|
|
1164
|
+
assert.equal(child?.structuredOutputFailed, true);
|
|
1165
|
+
assert.match(child?.error ?? "", /Missing structured_output call/);
|
|
1166
|
+
assert.ok(child?.structuredOutputPath);
|
|
1167
|
+
assert.equal(fs.existsSync(path.dirname(child.structuredOutputPath)), false);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("does not create a temporary structured output directory before file-only validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1171
|
+
const previousTmpdir = process.env.TMPDIR;
|
|
1172
|
+
process.env.TMPDIR = tempDir;
|
|
1173
|
+
try {
|
|
1174
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1175
|
+
|
|
1176
|
+
const result = await executor.execute(
|
|
1177
|
+
"single-schema-file-only-missing-path",
|
|
1178
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, outputMode: "file-only", acceptance: false, artifacts: false },
|
|
1179
|
+
new AbortController().signal,
|
|
1180
|
+
undefined,
|
|
1181
|
+
makeMinimalCtx(tempDir),
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
assert.equal(result.isError, true);
|
|
1185
|
+
assert.match(result.content[0]?.text ?? "", /outputMode: "file-only"/);
|
|
1186
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1187
|
+
assert.equal(fs.readdirSync(tempDir).some((name) => name.startsWith("pi-subagent-structured-")), false);
|
|
1188
|
+
} finally {
|
|
1189
|
+
if (previousTmpdir === undefined) delete process.env.TMPDIR;
|
|
1190
|
+
else process.env.TMPDIR = previousTmpdir;
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("allows a structured_output tool call at the exact strict turn boundary", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1195
|
+
mockPi.onCall({
|
|
1196
|
+
stdoutRaw: [
|
|
1197
|
+
{
|
|
1198
|
+
type: "message_end",
|
|
1199
|
+
message: {
|
|
1200
|
+
role: "assistant",
|
|
1201
|
+
content: [{ type: "toolCall", id: "structured-1", name: "structured_output", arguments: { value: { ok: true } } }],
|
|
1202
|
+
model: "mock/test-model",
|
|
1203
|
+
stopReason: "toolUse",
|
|
1204
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
{ type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
|
|
1208
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
|
|
1209
|
+
{ type: "tool_execution_end", toolName: "structured_output" },
|
|
1210
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
1211
|
+
structuredOutputCapture: { ok: true },
|
|
1212
|
+
});
|
|
1213
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1214
|
+
|
|
1215
|
+
const result = await executor.execute(
|
|
1216
|
+
"single-schema-strict-boundary",
|
|
1217
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, turnBudget: { maxTurns: 1, graceTurns: 0 }, enforceHardTurnLimit: true, acceptance: false },
|
|
1218
|
+
new AbortController().signal,
|
|
1219
|
+
undefined,
|
|
1220
|
+
makeMinimalCtx(tempDir),
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
const child = result.details?.results?.[0];
|
|
1224
|
+
assert.equal(result.isError, undefined);
|
|
1225
|
+
assert.equal(child?.turnBudgetExceeded, undefined);
|
|
1226
|
+
assert.deepEqual(child?.structuredOutput, { ok: true });
|
|
1227
|
+
});
|
|
1228
|
+
|
|
382
1229
|
it("returns captured output when the foreground executor fails an implementation run", async () => {
|
|
383
1230
|
mockPi.onCall({ output: "Oracle review:\n- finding one\n- finding two" });
|
|
384
1231
|
const executor = makeExecutor([makeAgent("oracle")]);
|
|
@@ -423,7 +1270,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
423
1270
|
assert.equal(result.finalOutput, "Validation report after the patch");
|
|
424
1271
|
});
|
|
425
1272
|
|
|
426
|
-
it("keeps bash-enabled
|
|
1273
|
+
it("keeps bash-enabled implementation tasks conservative unless completion guard is disabled", async () => {
|
|
427
1274
|
mockPi.onCall({ output: "cold start test after patch" });
|
|
428
1275
|
mockPi.onCall({ output: "cold start test after patch" });
|
|
429
1276
|
const agents = [
|
|
@@ -431,13 +1278,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
431
1278
|
makeAgent("test-runner-optout", { tools: ["read", "grep", "bash", "ls"], completionGuard: false }),
|
|
432
1279
|
];
|
|
433
1280
|
|
|
434
|
-
const withoutOptOut = await runSync(tempDir, agents, "test-runner", "
|
|
1281
|
+
const withoutOptOut = await runSync(tempDir, agents, "test-runner", "Patch the cold start test", {
|
|
435
1282
|
runId: "guard-bash-conservative",
|
|
436
1283
|
});
|
|
437
1284
|
assert.equal(withoutOptOut.exitCode, 1);
|
|
438
1285
|
assert.match(withoutOptOut.error ?? "", /completed without making edits/);
|
|
439
1286
|
|
|
440
|
-
const withOptOut = await runSync(tempDir, agents, "test-runner-optout", "
|
|
1287
|
+
const withOptOut = await runSync(tempDir, agents, "test-runner-optout", "Patch the cold start test", {
|
|
441
1288
|
runId: "guard-bash-optout",
|
|
442
1289
|
});
|
|
443
1290
|
assert.equal(withOptOut.exitCode, 0);
|
|
@@ -623,6 +1470,59 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
623
1470
|
assert.deepEqual(result.attemptedModels, ["github-copilot/gpt-5-mini"]);
|
|
624
1471
|
});
|
|
625
1472
|
|
|
1473
|
+
it("parses split UTF-8 JSON and a final unterminated protocol line", async () => {
|
|
1474
|
+
const line = Buffer.from(JSON.stringify(events.assistantMessage("你好 from fragmented JSON")));
|
|
1475
|
+
const unicodeStart = line.indexOf(Buffer.from("你"));
|
|
1476
|
+
mockPi.onCall({ stdoutBase64Chunks: [
|
|
1477
|
+
line.subarray(0, unicodeStart + 1).toString("base64"),
|
|
1478
|
+
line.subarray(unicodeStart + 1).toString("base64"),
|
|
1479
|
+
] });
|
|
1480
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Read fragmented output", { acceptance: false });
|
|
1481
|
+
assert.equal(result.exitCode, 0);
|
|
1482
|
+
assert.equal(getFinalOutput(result.messages), "你好 from fragmented JSON");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("fails with protocol_output_limit when a child emits an oversized stdout line", async () => {
|
|
1486
|
+
mockPi.onCall({ stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) });
|
|
1487
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Emit malformed output", { acceptance: false });
|
|
1488
|
+
assert.equal(result.exitCode, 1);
|
|
1489
|
+
assert.equal(result.protocolError?.code, "protocol_output_limit");
|
|
1490
|
+
assert.equal(result.protocolError?.stream, "stdout");
|
|
1491
|
+
assert.match(result.error ?? "", /protocol_output_limit/);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it("keeps only a bounded UTF-8 stderr tail", async () => {
|
|
1495
|
+
mockPi.onCall({ output: "failed", stderr: `${"x".repeat(MAX_CHILD_STDERR_BYTES + 1024)}终`, exitCode: 1 });
|
|
1496
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Fail noisily", { acceptance: false });
|
|
1497
|
+
assert.equal(result.exitCode, 1);
|
|
1498
|
+
assert.ok(Buffer.byteLength(result.error ?? "") <= MAX_CHILD_STDERR_BYTES);
|
|
1499
|
+
assert.match(result.error ?? "", /终$/);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it("cancels final drain while agent_end reports a retry and waits for agent_settled", async () => {
|
|
1503
|
+
mockPi.onCall({ steps: [
|
|
1504
|
+
{ jsonl: [events.assistantMessage("retrying response"), { type: "agent_end", willRetry: true }] },
|
|
1505
|
+
{ delay: 1400, jsonl: [events.assistantMessage("settled response"), { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
|
|
1506
|
+
] });
|
|
1507
|
+
const startedAt = Date.now();
|
|
1508
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Retry once", { acceptance: false });
|
|
1509
|
+
assert.equal(result.exitCode, 0);
|
|
1510
|
+
assert.equal(getFinalOutput(result.messages), "settled response");
|
|
1511
|
+
assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during the retry delay");
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("treats agent_settled as a clean terminal watermark", async () => {
|
|
1515
|
+
const nonTerminalMessage = events.assistantMessage("settled without a terminal assistant stop") as { message: { stopReason: string } };
|
|
1516
|
+
nonTerminalMessage.message.stopReason = "length";
|
|
1517
|
+
mockPi.onCall({ jsonl: [nonTerminalMessage, { type: "agent_settled" }], keepAliveAfterFinalMessageMs: 5000 });
|
|
1518
|
+
const startedAt = Date.now();
|
|
1519
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Wait until settled", { acceptance: false });
|
|
1520
|
+
assert.equal(result.exitCode, 0);
|
|
1521
|
+
assert.equal(result.error, undefined);
|
|
1522
|
+
assert.equal(getFinalOutput(result.messages), "settled without a terminal assistant stop");
|
|
1523
|
+
assert.ok(Date.now() - startedAt < 4000, "agent_settled should trigger bounded child cleanup");
|
|
1524
|
+
});
|
|
1525
|
+
|
|
626
1526
|
it("tracks usage from message events", async () => {
|
|
627
1527
|
mockPi.onCall({ output: "Done" });
|
|
628
1528
|
const agents = makeAgentConfigs(["echo"]);
|
|
@@ -729,6 +1629,40 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
729
1629
|
assert.equal(mockPi.callCount(), 2);
|
|
730
1630
|
});
|
|
731
1631
|
|
|
1632
|
+
it("prefers empty-output fallback over an earlier tool error", async () => {
|
|
1633
|
+
mockPi.onCall({
|
|
1634
|
+
jsonl: [
|
|
1635
|
+
events.toolResult("read", "ENOENT: no such file or directory", true),
|
|
1636
|
+
events.toolResult("read", "recovered file contents"),
|
|
1637
|
+
{
|
|
1638
|
+
type: "message_end",
|
|
1639
|
+
message: {
|
|
1640
|
+
role: "assistant",
|
|
1641
|
+
content: [],
|
|
1642
|
+
model: "openai/gpt-5-mini",
|
|
1643
|
+
stopReason: "stop",
|
|
1644
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
],
|
|
1648
|
+
exitCode: 0,
|
|
1649
|
+
});
|
|
1650
|
+
mockPi.onCall({ output: "Recovered on fallback" });
|
|
1651
|
+
const agents = [makeAgent("echo", {
|
|
1652
|
+
model: "openai/gpt-5-mini",
|
|
1653
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
1654
|
+
})];
|
|
1655
|
+
|
|
1656
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
1657
|
+
runId: "fallback-empty-output-after-tool-error",
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
assert.equal(result.exitCode, 0);
|
|
1661
|
+
assert.equal(result.model, "anthropic/claude-sonnet-4");
|
|
1662
|
+
assert.match(result.modelAttempts?.[0]?.error ?? "", /no output/i);
|
|
1663
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
732
1666
|
it("fails zero-exit provider errors when no fallback succeeds", async () => {
|
|
733
1667
|
mockPi.onCall({
|
|
734
1668
|
jsonl: [{
|
|
@@ -901,7 +1835,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
901
1835
|
|
|
902
1836
|
it("does not retry on ordinary task/tool failures", async () => {
|
|
903
1837
|
mockPi.onCall({
|
|
904
|
-
jsonl: [events.toolResult("bash", "process exited with code 127")],
|
|
1838
|
+
jsonl: [events.toolResult("bash", "process exited with code 127", true)],
|
|
905
1839
|
exitCode: 0,
|
|
906
1840
|
});
|
|
907
1841
|
const agents = [makeAgent("echo", {
|
|
@@ -965,6 +1899,30 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
965
1899
|
assert.equal(result.progress.currentToolStartedAt, undefined);
|
|
966
1900
|
});
|
|
967
1901
|
|
|
1902
|
+
it("does not flag a delayed active tool as idle attention", async () => {
|
|
1903
|
+
const updates: Array<{ details?: { progress?: ProgressSummary[] } }> = [];
|
|
1904
|
+
const controlEvents: NonNullable<RunSyncResult["controlEvents"]> = [];
|
|
1905
|
+
mockPi.onCall({
|
|
1906
|
+
steps: [
|
|
1907
|
+
{ jsonl: [events.toolStart("bash", { command: "sleep 2" })] },
|
|
1908
|
+
{ delay: 2_000, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "done")] },
|
|
1909
|
+
{ jsonl: [events.assistantMessage("Done")] },
|
|
1910
|
+
],
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
1914
|
+
runId: "delayed-tool-attention",
|
|
1915
|
+
controlConfig: { enabled: true, needsAttentionAfterMs: 200, activeNoticeAfterMs: 999_999, notifyOn: ["needs_attention"] },
|
|
1916
|
+
onUpdate: (update: { details?: { progress?: ProgressSummary[] } }) => updates.push(update),
|
|
1917
|
+
onControlEvent: (event: NonNullable<RunSyncResult["controlEvents"]>[number]) => controlEvents.push(event),
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
assert.equal(result.exitCode, 0);
|
|
1921
|
+
assert.equal(result.progress.activityState, undefined);
|
|
1922
|
+
assert.equal(controlEvents.some((event) => event.type === "needs_attention"), false);
|
|
1923
|
+
assert.equal(updates.some((update) => update.details?.progress?.some((progress) => progress.currentTool === "bash")), true);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
968
1926
|
it("sets progress.status to failed on non-zero exit", async () => {
|
|
969
1927
|
mockPi.onCall({ exitCode: 1 });
|
|
970
1928
|
const agents = makeAgentConfigs(["fail"]);
|
|
@@ -1010,6 +1968,23 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1010
1968
|
}
|
|
1011
1969
|
});
|
|
1012
1970
|
|
|
1971
|
+
it("injects an agent-file-relative local skill into the foreground child prompt", async () => {
|
|
1972
|
+
mockPi.onCall({ output: "Done" });
|
|
1973
|
+
const agentFile = path.join(tempDir, "agents", "nested", "worker.md");
|
|
1974
|
+
const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
|
|
1975
|
+
fs.mkdirSync(path.dirname(skillFile), { recursive: true });
|
|
1976
|
+
fs.writeFileSync(skillFile, "---\ndescription: local skill description\n---\nLocal skill body\n", "utf-8");
|
|
1977
|
+
const agents = [makeAgent("worker", { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] })];
|
|
1978
|
+
|
|
1979
|
+
const result = await runSync(tempDir, agents, "worker", "Task", {});
|
|
1980
|
+
|
|
1981
|
+
assert.equal(result.exitCode, 0);
|
|
1982
|
+
assert.deepEqual(result.skills, ["local"]);
|
|
1983
|
+
const prompt = readCall().systemPrompts.map((record) => record.text ?? "").join("\n");
|
|
1984
|
+
assert.match(prompt, /local skill description/);
|
|
1985
|
+
assert.match(prompt, new RegExp(escapeRegExp(skillFile)));
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1013
1988
|
it("falls back to the runtime cwd when the task cwd lacks a skill", async () => {
|
|
1014
1989
|
const taskCwd = path.join(tempDir, "nested");
|
|
1015
1990
|
fs.mkdirSync(taskCwd, { recursive: true });
|
|
@@ -1066,6 +2041,51 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1066
2041
|
assert.match(transcript.at(-1)?.text ?? "", /^Result text/);
|
|
1067
2042
|
assert.equal(result.transcriptError, undefined);
|
|
1068
2043
|
assert.ok(fs.existsSync(artifactsDir), "artifacts dir should exist");
|
|
2044
|
+
const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string };
|
|
2045
|
+
assert.equal(metadata.launchContractDigest, result.launchContractDigest);
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
it("routes foreground artifacts to the configured session directory", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2049
|
+
mockPi.onCall({ output: "session artifact result" });
|
|
2050
|
+
const sessionFile = path.join(tempDir, "sessions", "parent-session", "session.jsonl");
|
|
2051
|
+
const ctx = makeMinimalCtx(tempDir);
|
|
2052
|
+
ctx.sessionManager.getSessionFile = () => sessionFile;
|
|
2053
|
+
const executor = makeExecutor([makeAgent("echo")], { artifactDir: "session" });
|
|
2054
|
+
|
|
2055
|
+
const result = await executor.execute(
|
|
2056
|
+
"session-artifact-dir",
|
|
2057
|
+
{ agent: "echo", task: "Write session-scoped artifacts", runId: "session-artifacts" },
|
|
2058
|
+
new AbortController().signal,
|
|
2059
|
+
undefined,
|
|
2060
|
+
ctx,
|
|
2061
|
+
);
|
|
2062
|
+
|
|
2063
|
+
const expectedDir = path.join(path.dirname(sessionFile), "subagent-artifacts");
|
|
2064
|
+
assert.equal(result.isError, undefined);
|
|
2065
|
+
assert.equal(result.details?.artifacts?.dir, expectedDir);
|
|
2066
|
+
assert.ok(result.details?.artifacts?.files[0]?.outputPath.startsWith(`${expectedDir}${path.sep}`));
|
|
2067
|
+
assert.equal(fs.readFileSync(result.details.artifacts.files[0].outputPath, "utf-8"), "session artifact result");
|
|
2068
|
+
assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents", "artifacts")), false);
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
it("writes a failure stub to foreground output artifacts when no output was produced", async () => {
|
|
2072
|
+
mockPi.onCall({ output: "", stderr: "model unavailable", exitCode: 1 });
|
|
2073
|
+
const artifactsDir = path.join(tempDir, "artifacts-failed-output");
|
|
2074
|
+
|
|
2075
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
2076
|
+
runId: "failed-no-output",
|
|
2077
|
+
artifactsDir,
|
|
2078
|
+
artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeMetadata: true },
|
|
2079
|
+
acceptance: false,
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
assert.equal(result.exitCode, 1);
|
|
2083
|
+
assert.ok(result.artifactPaths?.outputPath, "should expose an output artifact path");
|
|
2084
|
+
const artifact = fs.readFileSync(result.artifactPaths.outputPath, "utf-8");
|
|
2085
|
+
assert.match(artifact, /Subagent run failed before producing output\./);
|
|
2086
|
+
assert.match(artifact, /Error:\nmodel unavailable/);
|
|
2087
|
+
assert.match(artifact, /Transcript:/);
|
|
2088
|
+
assert.match(artifact, /Metadata:/);
|
|
1069
2089
|
});
|
|
1070
2090
|
|
|
1071
2091
|
it("does not surface transcript paths when transcript artifacts are disabled", async () => {
|
|
@@ -1201,6 +2221,36 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1201
2221
|
assert.match(systemPrompt, /Ignore any other output filename or output path mentioned elsewhere/);
|
|
1202
2222
|
});
|
|
1203
2223
|
|
|
2224
|
+
it("persists read-only file-only output without requiring a child write tool", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2225
|
+
mockPi.onCall({ output: "complete read-only analysis" });
|
|
2226
|
+
const outputPath = path.join(tempDir, "read-only-analysis.md");
|
|
2227
|
+
const executor = makeExecutor([makeAgent("analyst", {
|
|
2228
|
+
tools: ["read", "grep", "find", "ls"],
|
|
2229
|
+
systemPrompt: "Analyze without modifying files.",
|
|
2230
|
+
})]);
|
|
2231
|
+
|
|
2232
|
+
const result = await executor.execute(
|
|
2233
|
+
"single-read-only-output",
|
|
2234
|
+
{ agent: "analyst", task: "Analyze the runtime", output: outputPath, outputMode: "file-only", acceptance: false },
|
|
2235
|
+
new AbortController().signal,
|
|
2236
|
+
undefined,
|
|
2237
|
+
makeMinimalCtx(tempDir),
|
|
2238
|
+
);
|
|
2239
|
+
|
|
2240
|
+
const call = readCall();
|
|
2241
|
+
const taskArg = call.args.at(-1) ?? "";
|
|
2242
|
+
const systemPrompt = call.systemPrompts[0]?.text ?? "";
|
|
2243
|
+
assert.equal(result.isError, undefined);
|
|
2244
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), "complete read-only analysis");
|
|
2245
|
+
assert.match(result.content[0]?.text ?? "", /Output saved to:/);
|
|
2246
|
+
for (const instruction of [taskArg, systemPrompt]) {
|
|
2247
|
+
assert.match(instruction, /Return the complete artifact in your final response\./);
|
|
2248
|
+
assert.match(instruction, /runtime will persist it to exactly this path:/);
|
|
2249
|
+
assert.match(instruction, /Do not call contact_supervisor merely because no write-capable tool is available\./);
|
|
2250
|
+
assert.doesNotMatch(instruction, /Write your findings to exactly this path/);
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
|
|
1204
2254
|
it("treats string false as disabled output in foreground single runs", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1205
2255
|
mockPi.onCall({ output: "inline report" });
|
|
1206
2256
|
const executor = makeExecutor([makeAgent("echo", { output: "default-report.md" })]);
|
|
@@ -1221,6 +2271,50 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1221
2271
|
assert.doesNotMatch(readCallArgs().at(-1) ?? "", /Write your findings to(?: exactly this path)?:/);
|
|
1222
2272
|
});
|
|
1223
2273
|
|
|
2274
|
+
it("rejects explicit reviewed acceptance at every execution nesting level before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2275
|
+
const cases = [
|
|
2276
|
+
{ agent: "echo", task: "Review", acceptance: "reviewed" },
|
|
2277
|
+
{ agent: "echo", task: "Review", acceptance: { level: "reviewed" } },
|
|
2278
|
+
{ tasks: [{ agent: "echo", task: "Review", acceptance: "reviewed" }] },
|
|
2279
|
+
{ chain: [{ agent: "echo", task: "Review", acceptance: { level: "reviewed" } }] },
|
|
2280
|
+
{ chain: [{ parallel: [{ agent: "echo", task: "Review", acceptance: "reviewed" }] }] },
|
|
2281
|
+
{ chain: [{ expand: { from: { output: "targets", path: "/items" } }, parallel: { agent: "echo", acceptance: { level: "reviewed" } }, collect: { as: "reviews" } }] },
|
|
2282
|
+
];
|
|
2283
|
+
for (const [index, params] of cases.entries()) {
|
|
2284
|
+
const executor = makeExecutor();
|
|
2285
|
+
const result = await executor.execute(
|
|
2286
|
+
`reviewed-acceptance-${index}`,
|
|
2287
|
+
params,
|
|
2288
|
+
new AbortController().signal,
|
|
2289
|
+
undefined,
|
|
2290
|
+
makeMinimalCtx(tempDir),
|
|
2291
|
+
);
|
|
2292
|
+
|
|
2293
|
+
assert.equal(result.isError, true);
|
|
2294
|
+
assert.match(result.content[0]?.text ?? "", /achieved status.*omit acceptance.*acceptance\.review\.required/i);
|
|
2295
|
+
}
|
|
2296
|
+
assert.equal(mockPi.callCount(), 0);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
it("rejects explicit reviewed acceptance before appending a chain step", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2300
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
2301
|
+
const result = await executor.execute(
|
|
2302
|
+
"append-reviewed-acceptance",
|
|
2303
|
+
{
|
|
2304
|
+
action: "append-step",
|
|
2305
|
+
id: "missing-run",
|
|
2306
|
+
chain: [{ agent: "echo", task: "Review the previous work", acceptance: { level: "reviewed" } }],
|
|
2307
|
+
},
|
|
2308
|
+
new AbortController().signal,
|
|
2309
|
+
undefined,
|
|
2310
|
+
makeMinimalCtx(tempDir),
|
|
2311
|
+
);
|
|
2312
|
+
|
|
2313
|
+
assert.equal(result.isError, true);
|
|
2314
|
+
assert.match(result.content[0]?.text ?? "", /Cannot append step:.*achieved status.*acceptance\.review\.required/i);
|
|
2315
|
+
assert.equal(mockPi.callCount(), 0);
|
|
2316
|
+
});
|
|
2317
|
+
|
|
1224
2318
|
it("rejects mismatched foreground timeout aliases before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1225
2319
|
const executor = makeExecutor();
|
|
1226
2320
|
|
|
@@ -1237,6 +2331,109 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1237
2331
|
assert.equal(mockPi.callCount(), 0);
|
|
1238
2332
|
});
|
|
1239
2333
|
|
|
2334
|
+
it("applies agent frontmatter defaults to single-agent launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2335
|
+
const executor = makeExecutor([
|
|
2336
|
+
makeAgent("echo", {
|
|
2337
|
+
defaultAsync: true,
|
|
2338
|
+
defaultTimeoutMs: 2_000,
|
|
2339
|
+
defaultTurnBudget: { maxTurns: 4, graceTurns: 2 },
|
|
2340
|
+
}),
|
|
2341
|
+
]);
|
|
2342
|
+
|
|
2343
|
+
const result = await executor.execute(
|
|
2344
|
+
"agent-launch-defaults",
|
|
2345
|
+
{ agent: "echo", task: "Task" },
|
|
2346
|
+
new AbortController().signal,
|
|
2347
|
+
undefined,
|
|
2348
|
+
makeMinimalCtx(tempDir),
|
|
2349
|
+
);
|
|
2350
|
+
|
|
2351
|
+
assert.equal(result.isError, undefined);
|
|
2352
|
+
assert.match(result.content[0]?.text ?? "", /Async:/);
|
|
2353
|
+
assert.equal(typeof result.details?.asyncId, "string");
|
|
2354
|
+
assert.equal(result.details?.timeoutMs, 2_000);
|
|
2355
|
+
assert.deepEqual(result.details?.turnBudget, { maxTurns: 4, graceTurns: 2 });
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
it("applies agent acceptance defaults and lets explicit calls override them", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2359
|
+
mockPi.onCall({ output: "default acceptance disabled" });
|
|
2360
|
+
mockPi.onCall({ stdoutRaw: `${JSON.stringify(events.assistantMessage("explicit checked response without a report"))}\n` });
|
|
2361
|
+
const executor = makeExecutor([
|
|
2362
|
+
makeAgent("echo", { defaultAcceptance: { level: "none", reason: "lightweight response" } }),
|
|
2363
|
+
]);
|
|
2364
|
+
|
|
2365
|
+
const defaulted = await executor.execute(
|
|
2366
|
+
"agent-acceptance-default",
|
|
2367
|
+
{ agent: "echo", task: "Return a concise answer" },
|
|
2368
|
+
new AbortController().signal,
|
|
2369
|
+
undefined,
|
|
2370
|
+
makeMinimalCtx(tempDir),
|
|
2371
|
+
);
|
|
2372
|
+
assert.equal(defaulted.isError, undefined);
|
|
2373
|
+
assert.equal(defaulted.details?.results?.[0]?.acceptance?.status, "not-required");
|
|
2374
|
+
assert.equal(defaulted.details?.results?.[0]?.acceptance?.effectiveAcceptance.reason, "lightweight response");
|
|
2375
|
+
|
|
2376
|
+
const explicit = await executor.execute(
|
|
2377
|
+
"agent-acceptance-explicit",
|
|
2378
|
+
{ agent: "echo", task: "Return a concise answer", acceptance: "checked" },
|
|
2379
|
+
new AbortController().signal,
|
|
2380
|
+
undefined,
|
|
2381
|
+
makeMinimalCtx(tempDir),
|
|
2382
|
+
);
|
|
2383
|
+
assert.equal(explicit.isError, true);
|
|
2384
|
+
assert.equal(explicit.details?.results?.[0]?.acceptance?.status, "rejected");
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
it("lets agent frontmatter override the global async default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2388
|
+
mockPi.onCall({ output: "agent foreground default finished" });
|
|
2389
|
+
const executor = makeExecutor(
|
|
2390
|
+
[makeAgent("echo", { defaultAsync: false })],
|
|
2391
|
+
{},
|
|
2392
|
+
true,
|
|
2393
|
+
);
|
|
2394
|
+
|
|
2395
|
+
const result = await executor.execute(
|
|
2396
|
+
"agent-foreground-default",
|
|
2397
|
+
{ agent: "echo", task: "Task" },
|
|
2398
|
+
new AbortController().signal,
|
|
2399
|
+
undefined,
|
|
2400
|
+
makeMinimalCtx(tempDir),
|
|
2401
|
+
);
|
|
2402
|
+
|
|
2403
|
+
assert.equal(result.isError, undefined);
|
|
2404
|
+
assert.match(result.content[0]?.text ?? "", /agent foreground default finished/);
|
|
2405
|
+
assert.equal(result.details?.asyncId, undefined);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it("lets explicit single-agent launch values override frontmatter defaults", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2409
|
+
mockPi.onCall({ output: "explicit foreground finished" });
|
|
2410
|
+
const executor = makeExecutor([
|
|
2411
|
+
makeAgent("echo", {
|
|
2412
|
+
defaultAsync: true,
|
|
2413
|
+
defaultTimeoutMs: 1,
|
|
2414
|
+
defaultTurnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
2415
|
+
}),
|
|
2416
|
+
]);
|
|
2417
|
+
|
|
2418
|
+
const result = await executor.execute(
|
|
2419
|
+
"explicit-launch-values",
|
|
2420
|
+
{
|
|
2421
|
+
agent: "echo",
|
|
2422
|
+
task: "Task",
|
|
2423
|
+
async: false,
|
|
2424
|
+
timeoutMs: 2_000,
|
|
2425
|
+
turnBudget: { maxTurns: 4, graceTurns: 2 },
|
|
2426
|
+
},
|
|
2427
|
+
new AbortController().signal,
|
|
2428
|
+
undefined,
|
|
2429
|
+
makeMinimalCtx(tempDir),
|
|
2430
|
+
);
|
|
2431
|
+
|
|
2432
|
+
assert.equal(result.isError, undefined);
|
|
2433
|
+
assert.match(result.content[0]?.text ?? "", /explicit foreground finished/);
|
|
2434
|
+
assert.equal(result.details?.asyncId, undefined);
|
|
2435
|
+
});
|
|
2436
|
+
|
|
1240
2437
|
it("allows timeout settings for async runs before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1241
2438
|
const executor = makeExecutor();
|
|
1242
2439
|
|
|
@@ -1318,6 +2515,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1318
2515
|
}
|
|
1319
2516
|
});
|
|
1320
2517
|
|
|
2518
|
+
it("passes the effective wait-tool setting through to child execution", async () => {
|
|
2519
|
+
mockPi.onCall({ echoEnv: [WAIT_TOOL_ENABLED_ENV] });
|
|
2520
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
2521
|
+
runId: "wait-tool-env",
|
|
2522
|
+
waitToolEnabled: false,
|
|
2523
|
+
});
|
|
2524
|
+
assert.equal(result.exitCode, 0);
|
|
2525
|
+
assert.deepEqual(JSON.parse(result.finalOutput ?? "{}"), {
|
|
2526
|
+
[WAIT_TOOL_ENABLED_ENV]: "false",
|
|
2527
|
+
});
|
|
2528
|
+
});
|
|
2529
|
+
|
|
1321
2530
|
it("passes prompt inheritance env flags through to child execution", async () => {
|
|
1322
2531
|
mockPi.onCall({ echoEnv: ["SELESAI_SUBAGENT_INHERIT_PROJECT_CONTEXT", "SELESAI_SUBAGENT_INHERIT_SKILLS"] });
|
|
1323
2532
|
const agents = [makeAgent("echo", {
|
|
@@ -1410,6 +2619,19 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1410
2619
|
});
|
|
1411
2620
|
});
|
|
1412
2621
|
|
|
2622
|
+
it("fails with actionable diagnostics when a requested extension tool is not loaded", async () => {
|
|
2623
|
+
mockPi.onCall({ output: "Model incorrectly claimed success", missingTools: ["fixture_search"] });
|
|
2624
|
+
const agents = [makeAgent("extension-worker", { tools: ["read", "fixture_search"], fallbackModels: ["mock/fallback-model"] })];
|
|
2625
|
+
|
|
2626
|
+
const result = await runSync(tempDir, agents, "extension-worker", "Use fixture search", { runId: "missing-extension-tool" });
|
|
2627
|
+
|
|
2628
|
+
assert.equal(result.exitCode, 1);
|
|
2629
|
+
assert.match(result.error ?? "", /requested unavailable child tools: fixture_search/);
|
|
2630
|
+
assert.match(result.error ?? "", /subagentOnlyExtensions/);
|
|
2631
|
+
assert.match(result.error ?? "", /strict allowlist/);
|
|
2632
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
2633
|
+
});
|
|
2634
|
+
|
|
1413
2635
|
it("passes custom tool extensions through even when explicit extensions are allowlisted", { skip: process.platform === "win32" ? "extension path resolution intermittent on Windows CI" : undefined }, async () => {
|
|
1414
2636
|
mockPi.onCall({ output: "Done" });
|
|
1415
2637
|
const agents = [makeAgent("echo", {
|
|
@@ -1447,6 +2669,71 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1447
2669
|
assert.ok(extensionArgs.some((arg) => arg.replace(/\\/g, "/").endsWith("child-only-tool.ts")));
|
|
1448
2670
|
});
|
|
1449
2671
|
|
|
2672
|
+
it("ignores child watchdog status when foreground child watchdogs are not configured", async () => {
|
|
2673
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
2674
|
+
mockPi.onCall({
|
|
2675
|
+
jsonl: [events.assistantMessage("done-without-watchdog-config"), childWatchdogStatus("reviewing", 1)],
|
|
2676
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
2677
|
+
});
|
|
2678
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
2679
|
+
|
|
2680
|
+
const start = Date.now();
|
|
2681
|
+
const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
|
|
2682
|
+
const elapsed = Date.now() - start;
|
|
2683
|
+
|
|
2684
|
+
assert.ok(elapsed < 5000, `unconfigured watchdog status should not delay final drain, took ${elapsed}ms`);
|
|
2685
|
+
assert.equal(result.exitCode, 0);
|
|
2686
|
+
assert.equal(result.finalOutput, "done-without-watchdog-config");
|
|
2687
|
+
assert.equal((result as RunSyncResult & { watchdog?: unknown }).watchdog, undefined);
|
|
2688
|
+
});
|
|
2689
|
+
});
|
|
2690
|
+
|
|
2691
|
+
it("waits for child watchdog settlement before foreground final-drain cleanup", async () => {
|
|
2692
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
2693
|
+
writeWatchdogSettings(tempDir);
|
|
2694
|
+
mockPi.onCall({
|
|
2695
|
+
steps: [
|
|
2696
|
+
{ jsonl: [events.assistantMessage("done-before-watchdog"), childWatchdogStatus("reviewing", 1)] },
|
|
2697
|
+
{ delay: 1400, jsonl: [childWatchdogStatus("idle", 2)] },
|
|
2698
|
+
],
|
|
2699
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
2700
|
+
});
|
|
2701
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
2702
|
+
|
|
2703
|
+
const start = Date.now();
|
|
2704
|
+
const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
|
|
2705
|
+
const elapsed = Date.now() - start;
|
|
2706
|
+
|
|
2707
|
+
assert.ok(elapsed >= 1200, `watchdog settlement should delay final drain, took ${elapsed}ms`);
|
|
2708
|
+
assert.ok(elapsed < 6000, `settled watchdog should still allow cleanup, took ${elapsed}ms`);
|
|
2709
|
+
assert.equal(result.exitCode, 0);
|
|
2710
|
+
assert.equal(result.finalOutput, "done-before-watchdog");
|
|
2711
|
+
assert.equal((result as RunSyncResult & { watchdog?: { phase?: string } }).watchdog?.phase, "idle");
|
|
2712
|
+
});
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
it("falls back after child watchdog tail timeout without failing successful foreground output", async () => {
|
|
2716
|
+
await withIsolatedWatchdogSettings(tempDir, async () => {
|
|
2717
|
+
writeWatchdogSettings(tempDir, 150);
|
|
2718
|
+
mockPi.onCall({
|
|
2719
|
+
jsonl: [events.assistantMessage("done-before-watchdog-timeout"), childWatchdogStatus("reviewing", 1)],
|
|
2720
|
+
keepAliveAfterFinalMessageMs: 10000,
|
|
2721
|
+
});
|
|
2722
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
2723
|
+
|
|
2724
|
+
const start = Date.now();
|
|
2725
|
+
const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
|
|
2726
|
+
const elapsed = Date.now() - start;
|
|
2727
|
+
|
|
2728
|
+
assert.ok(elapsed < 5000, `watchdog tail fallback should not hang, took ${elapsed}ms`);
|
|
2729
|
+
assert.equal(result.exitCode, 0);
|
|
2730
|
+
assert.equal(result.finalOutput, "done-before-watchdog-timeout");
|
|
2731
|
+
const watchdog = (result as RunSyncResult & { watchdog?: { phase?: string; timedOut?: boolean } }).watchdog;
|
|
2732
|
+
assert.equal(watchdog?.phase, "stale");
|
|
2733
|
+
assert.equal(watchdog?.timedOut, true);
|
|
2734
|
+
});
|
|
2735
|
+
});
|
|
2736
|
+
|
|
1450
2737
|
it("treats forced drain after final assistant output as cleanup success", async () => {
|
|
1451
2738
|
mockPi.onCall({
|
|
1452
2739
|
jsonl: [events.assistantMessage("done-before-drain")],
|
|
@@ -1578,6 +2865,63 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1578
2865
|
assert.match(result.finalOutput ?? "", /final wrapped output/);
|
|
1579
2866
|
});
|
|
1580
2867
|
|
|
2868
|
+
it("defers a foreground hard turn limit through active tool work and aborts at the next assistant boundary", async () => {
|
|
2869
|
+
mockPi.onCall({
|
|
2870
|
+
steps: [
|
|
2871
|
+
{
|
|
2872
|
+
jsonl: [
|
|
2873
|
+
mockAssistantMessage("starting required tool work", "tool_use"),
|
|
2874
|
+
events.toolStart("bash", { command: "node build.mjs" }),
|
|
2875
|
+
],
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
delay: 500,
|
|
2879
|
+
jsonl: [
|
|
2880
|
+
events.toolResult("bash", "build completed"),
|
|
2881
|
+
events.toolEnd("bash"),
|
|
2882
|
+
mockAssistantMessage("safe assistant boundary reached", "stop"),
|
|
2883
|
+
],
|
|
2884
|
+
},
|
|
2885
|
+
],
|
|
2886
|
+
});
|
|
2887
|
+
const agents = makeAgentConfigs(["worker"]);
|
|
2888
|
+
const snapshots: Array<{
|
|
2889
|
+
turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number };
|
|
2890
|
+
turnBudgetExceeded?: boolean;
|
|
2891
|
+
error?: string;
|
|
2892
|
+
currentTool?: string;
|
|
2893
|
+
status?: string;
|
|
2894
|
+
}> = [];
|
|
2895
|
+
|
|
2896
|
+
const result = await runSync(tempDir, agents, "worker", "Finish active tool work before enforcing the hard limit.", {
|
|
2897
|
+
turnBudget: { maxTurns: 1, graceTurns: 0 },
|
|
2898
|
+
runId: "foreground-turn-budget-deferred",
|
|
2899
|
+
onUpdate(update: { details?: { results?: Array<{ turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number }; turnBudgetExceeded?: boolean; error?: string }>; progress?: Array<{ currentTool?: string; status?: string }> } }) {
|
|
2900
|
+
const current = update.details?.results?.[0];
|
|
2901
|
+
const progress = update.details?.progress?.[0];
|
|
2902
|
+
snapshots.push({
|
|
2903
|
+
turnBudget: current?.turnBudget,
|
|
2904
|
+
turnBudgetExceeded: current?.turnBudgetExceeded,
|
|
2905
|
+
error: current?.error,
|
|
2906
|
+
currentTool: progress?.currentTool,
|
|
2907
|
+
status: progress?.status,
|
|
2908
|
+
});
|
|
2909
|
+
},
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
const duringTool = snapshots.find((snapshot) => snapshot.turnBudget?.outcome === "termination-deferred" && snapshot.currentTool === "bash");
|
|
2913
|
+
assert.ok(duringTool, "expected a running snapshot with deferred termination and the active tool");
|
|
2914
|
+
assert.equal(duringTool.turnBudget?.terminationDeferredAtTurn, 1);
|
|
2915
|
+
assert.equal(duringTool.turnBudgetExceeded, undefined);
|
|
2916
|
+
assert.equal(duringTool.error, undefined);
|
|
2917
|
+
assert.equal(duringTool.status, "running");
|
|
2918
|
+
assert.equal(result.turnBudgetExceeded, true);
|
|
2919
|
+
assert.equal(result.turnBudget?.outcome, "exceeded");
|
|
2920
|
+
assert.equal(result.turnBudget?.turnCount, 2);
|
|
2921
|
+
assert.match(result.finalOutput ?? "", /safe assistant boundary reached/);
|
|
2922
|
+
assert.ok(result.messages?.some((message) => message.role === "toolResult" && JSON.stringify(message.content).includes("build completed")));
|
|
2923
|
+
});
|
|
2924
|
+
|
|
1581
2925
|
it("does not run acceptance verification after a foreground timeout", async () => {
|
|
1582
2926
|
const markerPath = path.join(tempDir, "verify-ran.txt");
|
|
1583
2927
|
const report = [
|
|
@@ -1699,25 +3043,166 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1699
3043
|
|
|
1700
3044
|
const result = await runPromise;
|
|
1701
3045
|
|
|
1702
|
-
assert.equal(result.exitCode,
|
|
3046
|
+
assert.equal(result.exitCode, -2);
|
|
1703
3047
|
assert.equal(result.detached, true);
|
|
1704
3048
|
assert.equal(result.detachedReason, "intercom coordination");
|
|
1705
|
-
assert.equal(result.finalOutput, "Detached for intercom coordination.");
|
|
3049
|
+
assert.equal(result.finalOutput, "Detached for intercom coordination before task completion.");
|
|
1706
3050
|
assert.equal(result.progress?.status, "detached");
|
|
1707
3051
|
assert.equal(accepted, true);
|
|
1708
3052
|
});
|
|
1709
3053
|
}
|
|
1710
3054
|
|
|
3055
|
+
it("enforces the stdout protocol limit after foreground detachment", async () => {
|
|
3056
|
+
const eventBus = createEventBus();
|
|
3057
|
+
mockPi.onCall({ steps: [
|
|
3058
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3059
|
+
{ delay: 100, stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) },
|
|
3060
|
+
{ delay: 5000 },
|
|
3061
|
+
] });
|
|
3062
|
+
let detachEmitted = false;
|
|
3063
|
+
let recoveredResult: RunSyncResult | undefined;
|
|
3064
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Detach then emit malformed output", {
|
|
3065
|
+
runId: "detached-protocol-limit",
|
|
3066
|
+
allowIntercomDetach: true,
|
|
3067
|
+
intercomEvents: eventBus,
|
|
3068
|
+
acceptance: false,
|
|
3069
|
+
onUpdate: (update) => {
|
|
3070
|
+
if (detachEmitted) return;
|
|
3071
|
+
const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
|
|
3072
|
+
if (!Array.isArray(progress) || !progress.some((item) => item.currentTool === "contact_supervisor")) return;
|
|
3073
|
+
detachEmitted = true;
|
|
3074
|
+
eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "detached-protocol-request" });
|
|
3075
|
+
},
|
|
3076
|
+
onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
|
|
3077
|
+
});
|
|
3078
|
+
assert.equal(result.exitCode, -2);
|
|
3079
|
+
for (let attempt = 0; attempt < 100 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3080
|
+
assert.equal(recoveredResult?.exitCode, 1);
|
|
3081
|
+
assert.equal(recoveredResult?.protocolError?.code, "protocol_output_limit");
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
it("does not save a detached placeholder to an explicit file-only output", async () => {
|
|
3085
|
+
const eventBus = createEventBus();
|
|
3086
|
+
mockPi.onCall({
|
|
3087
|
+
steps: [
|
|
3088
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3089
|
+
{ delay: 1000, jsonl: [events.assistantMessage("after reply")] },
|
|
3090
|
+
],
|
|
3091
|
+
});
|
|
3092
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
3093
|
+
const outputPath = path.join(tempDir, "detached-output.md");
|
|
3094
|
+
let detachEmitted = false;
|
|
3095
|
+
|
|
3096
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
3097
|
+
runId: "detached-file-only-output",
|
|
3098
|
+
allowIntercomDetach: true,
|
|
3099
|
+
intercomEvents: eventBus,
|
|
3100
|
+
outputPath,
|
|
3101
|
+
outputMode: "file-only",
|
|
3102
|
+
onUpdate: (update) => {
|
|
3103
|
+
if (detachEmitted) return;
|
|
3104
|
+
const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
|
|
3105
|
+
if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
|
|
3106
|
+
detachEmitted = true;
|
|
3107
|
+
eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "file-only-detach" });
|
|
3108
|
+
},
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
assert.equal(result.exitCode, -2);
|
|
3112
|
+
assert.equal(result.detached, true);
|
|
3113
|
+
assert.equal(result.savedOutputPath, undefined);
|
|
3114
|
+
assert.equal(fs.existsSync(outputPath), false);
|
|
3115
|
+
assert.match(result.outputSaveError ?? "", /not finalized/);
|
|
3116
|
+
});
|
|
3117
|
+
|
|
3118
|
+
it("finalizes explicit output before reporting detached child post-exit success", async () => {
|
|
3119
|
+
const eventBus = createEventBus();
|
|
3120
|
+
mockPi.onCall({
|
|
3121
|
+
steps: [
|
|
3122
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3123
|
+
{ delay: 100, jsonl: [events.assistantMessage("after reply")] },
|
|
3124
|
+
],
|
|
3125
|
+
});
|
|
3126
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
3127
|
+
const outputPath = path.join(tempDir, "detached-final-output.md");
|
|
3128
|
+
let detachEmitted = false;
|
|
3129
|
+
let recoveredResult: RunSyncResult | undefined;
|
|
3130
|
+
|
|
3131
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
3132
|
+
runId: "detached-file-only-post-exit-output",
|
|
3133
|
+
allowIntercomDetach: true,
|
|
3134
|
+
intercomEvents: eventBus,
|
|
3135
|
+
outputPath,
|
|
3136
|
+
outputMode: "file-only",
|
|
3137
|
+
onUpdate: (update) => {
|
|
3138
|
+
if (detachEmitted) return;
|
|
3139
|
+
const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
|
|
3140
|
+
if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
|
|
3141
|
+
detachEmitted = true;
|
|
3142
|
+
eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "file-only-post-exit-detach" });
|
|
3143
|
+
},
|
|
3144
|
+
onDetachedExit: (postExit) => {
|
|
3145
|
+
recoveredResult = postExit as RunSyncResult;
|
|
3146
|
+
},
|
|
3147
|
+
});
|
|
3148
|
+
|
|
3149
|
+
assert.equal(result.exitCode, -2);
|
|
3150
|
+
assert.equal(result.detached, true);
|
|
3151
|
+
assert.equal(fs.existsSync(outputPath), false);
|
|
3152
|
+
|
|
3153
|
+
for (let attempt = 0; attempt < 100 && (!fs.existsSync(outputPath) || !recoveredResult); attempt++) {
|
|
3154
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), "after reply");
|
|
3158
|
+
assert.ok(recoveredResult);
|
|
3159
|
+
assert.equal(recoveredResult.exitCode, 0);
|
|
3160
|
+
assert.equal(recoveredResult.progress?.status, "completed");
|
|
3161
|
+
assert.equal(recoveredResult.savedOutputPath, outputPath);
|
|
3162
|
+
assert.equal(recoveredResult.outputSaveError, undefined);
|
|
3163
|
+
assert.match(recoveredResult.finalOutput ?? "", /^Output saved to:/);
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
it("aborts a foreground coordination tool start instead of detaching without a delivered handoff", async () => {
|
|
3167
|
+
mockPi.onCall({
|
|
3168
|
+
steps: [
|
|
3169
|
+
{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
|
|
3170
|
+
{ delay: 10000, jsonl: [events.assistantMessage("after abort")] },
|
|
3171
|
+
],
|
|
3172
|
+
});
|
|
3173
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
3174
|
+
const controller = new AbortController();
|
|
3175
|
+
let aborted = false;
|
|
3176
|
+
|
|
3177
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
3178
|
+
runId: "contact-supervisor-abort-without-handoff",
|
|
3179
|
+
allowIntercomDetach: true,
|
|
3180
|
+
signal: controller.signal,
|
|
3181
|
+
onUpdate: (update) => {
|
|
3182
|
+
if (aborted) return;
|
|
3183
|
+
const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
|
|
3184
|
+
if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
|
|
3185
|
+
aborted = true;
|
|
3186
|
+
controller.abort();
|
|
3187
|
+
},
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
assert.equal(aborted, true);
|
|
3191
|
+
assert.notEqual(result.exitCode, -2);
|
|
3192
|
+
assert.equal(result.detached, undefined);
|
|
3193
|
+
assert.notEqual(result.progress?.status, "detached");
|
|
3194
|
+
});
|
|
3195
|
+
|
|
1711
3196
|
for (const testCase of [
|
|
1712
3197
|
{ name: "intercom ask", toolName: "intercom", args: { action: "ask", to: "orchestrator" } },
|
|
1713
3198
|
{ name: "contact_supervisor need_decision", toolName: "contact_supervisor", args: { reason: "need_decision", message: "Need a decision" } },
|
|
1714
3199
|
{ name: "contact_supervisor interview_request", toolName: "contact_supervisor", args: { reason: "interview_request", message: "Need input", interview: { questions: [] } } },
|
|
1715
3200
|
]) {
|
|
1716
|
-
it(`
|
|
3201
|
+
it(`does not detach foreground children on blocking ${testCase.name} before a delivered handoff`, async () => {
|
|
1717
3202
|
mockPi.onCall({
|
|
1718
3203
|
steps: [
|
|
1719
3204
|
{ jsonl: [events.toolStart(testCase.toolName, testCase.args)] },
|
|
1720
|
-
{ delay:
|
|
3205
|
+
{ delay: 50, jsonl: [events.assistantMessage("received pong")] },
|
|
1721
3206
|
],
|
|
1722
3207
|
});
|
|
1723
3208
|
const agents = makeAgentConfigs(["echo"]);
|
|
@@ -1728,10 +3213,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1728
3213
|
});
|
|
1729
3214
|
|
|
1730
3215
|
assert.equal(result.exitCode, 0);
|
|
1731
|
-
assert.equal(result.detached,
|
|
1732
|
-
assert.equal(result.
|
|
1733
|
-
assert.equal(result.
|
|
1734
|
-
assert.equal(result.progress?.status, "detached");
|
|
3216
|
+
assert.equal(result.detached, undefined);
|
|
3217
|
+
assert.equal(result.finalOutput, "received pong");
|
|
3218
|
+
assert.equal(result.progress?.status, "completed");
|
|
1735
3219
|
});
|
|
1736
3220
|
}
|
|
1737
3221
|
|
|
@@ -1807,7 +3291,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1807
3291
|
|
|
1808
3292
|
assert.equal(quietResult.exitCode, 0);
|
|
1809
3293
|
assert.equal(quietResult.detached, undefined);
|
|
1810
|
-
assert.equal(intercomResult.exitCode,
|
|
3294
|
+
assert.equal(intercomResult.exitCode, -2);
|
|
1811
3295
|
assert.equal(intercomResult.detached, true);
|
|
1812
3296
|
assert.equal(firstDetachResponse, true);
|
|
1813
3297
|
});
|