@selesai/code 0.5.28 → 0.5.29
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/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +9 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config-selector.d.ts.map +1 -1
- package/dist/cli/config-selector.js +1 -1
- package/dist/cli/config-selector.js.map +1 -1
- package/dist/cli/credential-print.d.ts +23 -0
- package/dist/cli/credential-print.d.ts.map +1 -0
- package/dist/cli/credential-print.js +117 -0
- package/dist/cli/credential-print.js.map +1 -0
- package/dist/cli/startup-ui.d.ts.map +1 -1
- package/dist/cli/startup-ui.js +1 -1
- package/dist/cli/startup-ui.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js +3 -0
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/agent-session.d.ts +12 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +20 -14
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +11 -3
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/extensions/runner.d.ts +1 -0
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +11 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +14 -1
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/footer-data-provider.d.ts +10 -0
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +1 -1
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/llama/provider.d.ts.map +1 -1
- package/dist/core/llama/provider.js +8 -3
- package/dist/core/llama/provider.js.map +1 -1
- package/dist/core/model-config.d.ts +30 -0
- package/dist/core/model-config.d.ts.map +1 -1
- package/dist/core/model-config.js +6 -0
- package/dist/core/model-config.js.map +1 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +2 -2
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +1 -0
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +20 -3
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/model-runtime.d.ts +2 -0
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +5 -4
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +13 -6
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/remote-catalog-provider.d.ts +1 -1
- package/dist/core/remote-catalog-provider.d.ts.map +1 -1
- package/dist/core/remote-catalog-provider.js +24 -11
- package/dist/core/remote-catalog-provider.js.map +1 -1
- package/dist/core/resource-loader.d.ts +15 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +66 -9
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +1 -1
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +2 -0
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +34 -5
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
- package/dist/core/tools/tool-definition-wrapper.js +3 -1
- package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
- package/dist/extensions/pi-intercom/CHANGELOG.md +249 -0
- package/dist/extensions/pi-intercom/README.md +102 -39
- package/dist/extensions/pi-intercom/broker/broker.ts +1233 -36
- package/dist/extensions/pi-intercom/broker/client.test.ts +83 -0
- package/dist/extensions/pi-intercom/broker/client.ts +315 -12
- package/dist/extensions/pi-intercom/broker/extension-state.ts +186 -0
- package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -0
- package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -0
- package/dist/extensions/pi-intercom/broker/framing.ts +82 -24
- package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -0
- package/dist/extensions/pi-intercom/broker/paths.ts +117 -8
- package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -0
- package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -0
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +160 -23
- package/dist/extensions/pi-intercom/broker/spawn.ts +113 -27
- package/dist/extensions/pi-intercom/config.test.ts +93 -0
- package/dist/extensions/pi-intercom/config.ts +55 -6
- package/dist/extensions/pi-intercom/cwd.test.ts +40 -0
- package/dist/extensions/pi-intercom/cwd.ts +31 -0
- package/dist/extensions/pi-intercom/extension-api.ts +44 -0
- package/dist/extensions/pi-intercom/format-context.test.ts +31 -0
- package/dist/extensions/pi-intercom/format-context.ts +32 -0
- package/dist/extensions/pi-intercom/index.ts +742 -145
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +2646 -0
- package/dist/extensions/pi-intercom/package.json +15 -5
- package/dist/extensions/pi-intercom/reply-tracker.test.ts +134 -0
- package/dist/extensions/pi-intercom/reply-tracker.ts +31 -13
- package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +13 -11
- package/dist/extensions/pi-intercom/test/inline-message.test.ts +184 -0
- package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -0
- package/dist/extensions/pi-intercom/types.ts +94 -4
- package/dist/extensions/pi-intercom/ui/compose.ts +8 -4
- package/dist/extensions/pi-intercom/ui/inline-message.ts +61 -25
- package/dist/extensions/pi-intercom/ui/session-list.ts +7 -3
- package/dist/extensions/pi-subagents/CHANGELOG.md +85 -0
- package/dist/extensions/pi-subagents/LICENSE +21 -0
- package/dist/extensions/pi-subagents/README.md +146 -52
- package/dist/extensions/pi-subagents/agents/architect.md +1 -0
- package/dist/extensions/pi-subagents/agents/builder.md +1 -0
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +3 -3
- package/dist/extensions/pi-subagents/prompts/review-loop.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +21 -989
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +256 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +430 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +144 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +282 -0
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +71 -30
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +4 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +118 -9
- package/dist/extensions/pi-subagents/src/agents/skills.ts +14 -12
- package/dist/extensions/pi-subagents/src/api/delegation.ts +3 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +16 -12
- package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +17 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +17 -7
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +248 -6
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +31 -6
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +8 -7
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +9 -4
- package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +33 -4
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +63 -16
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +24 -13
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -2
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +48 -5
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +68 -1
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +29 -4
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +29 -6
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +29 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +372 -28
- package/dist/extensions/pi-subagents/src/runs/foreground/async-stop-action.ts +65 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +143 -43
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +527 -247
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +42 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +426 -123
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +15 -7
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +51 -19
- package/dist/extensions/pi-subagents/src/runs/shared/chain-outputs.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +44 -11
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +8 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +97 -20
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +43 -12
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +52 -4
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +19 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +45 -9
- package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +31 -1
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +101 -0
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/usage-budget.ts +65 -0
- package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +26 -1
- package/dist/extensions/pi-subagents/src/shared/settings.ts +17 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +155 -10
- package/dist/extensions/pi-subagents/src/shared/utils.ts +73 -4
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +5 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +46 -4
- package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +5 -3
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +87 -24
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +182 -9
- package/dist/extensions/pi-subagents/src/tui/render.ts +27 -28
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +14 -7
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +5 -4
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +170 -17
- package/dist/extensions/pi-subagents/src/watchdog/scope.ts +62 -0
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +41 -1
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +10 -0
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +348 -7
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +4 -2
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +51 -2
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +26 -1
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +213 -13
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +44 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +118 -12
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +850 -7
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +112 -23
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +17 -5
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +2 -0
- package/dist/extensions/pi-subagents/test/support/register-loader.mjs +3 -3
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +128 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +47 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +60 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +102 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +6 -0
- package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +48 -48
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +4 -2
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +176 -8
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +164 -5
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +41 -1
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +11 -11
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +18 -2
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +20 -1
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +30 -2
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +10 -2
- package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +46 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +13 -1
- package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +29 -4
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +223 -4
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +30 -0
- package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +34 -2
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +78 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +41 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +77 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +3 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +210 -1
- package/dist/extensions/pi-subagents/test/unit/watchdog-scope.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +32 -0
- package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +43 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/custom-message.d.ts +3 -1
- package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/custom-message.js +10 -2
- package/dist/modes/interactive/components/custom-message.js.map +1 -1
- package/dist/modes/interactive/components/extension-editor.d.ts +1 -2
- package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-editor.js +16 -46
- package/dist/modes/interactive/components/extension-editor.js.map +1 -1
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +4 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/scoped-models-selector.js +22 -13
- package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
- package/dist/modes/interactive/external-editor.d.ts +12 -0
- package/dist/modes/interactive/external-editor.d.ts.map +1 -0
- package/dist/modes/interactive/external-editor.js +37 -0
- package/dist/modes/interactive/external-editor.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +72 -64
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +14 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/clipboard.d.ts.map +1 -1
- package/dist/utils/clipboard.js +19 -8
- package/dist/utils/clipboard.js.map +1 -1
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +1 -1
- package/dist/utils/version-check.js.map +1 -1
- package/docs/compaction.md +1 -1
- package/docs/custom-provider.md +14 -5
- package/docs/environment-variables.md +86 -0
- package/docs/extensions.md +15 -5
- package/docs/index.md +1 -0
- package/docs/models.md +9 -2
- package/docs/providers.md +21 -2
- package/docs/rpc.md +23 -6
- package/docs/session-format.md +2 -0
- package/docs/settings.md +1 -1
- package/docs/usage.md +0 -13
- package/examples/extensions/custom-compaction.ts +5 -2
- package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/handoff.ts +11 -3
- package/examples/extensions/message-renderer.ts +3 -3
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/summarize.ts +5 -2
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/examples/sdk/12-full-control.ts +3 -1
- package/package.json +15 -6
- package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
|
@@ -81,6 +81,22 @@ interface ArtifactPaths {
|
|
|
81
81
|
metadataPath?: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
interface LaunchResolvedExtensions {
|
|
85
|
+
version?: number;
|
|
86
|
+
source?: string;
|
|
87
|
+
disableAmbientExtensions?: boolean;
|
|
88
|
+
runtime?: string[];
|
|
89
|
+
configured?: string[];
|
|
90
|
+
effective?: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface RuntimeAcknowledgedExtensions {
|
|
94
|
+
version?: number;
|
|
95
|
+
source?: string;
|
|
96
|
+
ids?: string[];
|
|
97
|
+
omitted?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
interface RunSyncResult {
|
|
85
101
|
exitCode: number;
|
|
86
102
|
agent: string;
|
|
@@ -99,6 +115,7 @@ interface RunSyncResult {
|
|
|
99
115
|
transcriptPath?: string;
|
|
100
116
|
transcriptError?: string;
|
|
101
117
|
finalOutput?: string;
|
|
118
|
+
processSignal?: string | null;
|
|
102
119
|
interrupted?: boolean;
|
|
103
120
|
timedOut?: boolean;
|
|
104
121
|
turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
|
|
@@ -121,6 +138,8 @@ interface RunSyncResult {
|
|
|
121
138
|
verifyRuns?: Array<{ status?: string }>;
|
|
122
139
|
runtimeChecks?: Array<{ id?: string; status?: string; message?: string }>;
|
|
123
140
|
};
|
|
141
|
+
launchResolvedExtensions?: LaunchResolvedExtensions;
|
|
142
|
+
runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions;
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
interface MockPiCallRecord {
|
|
@@ -230,6 +249,7 @@ interface ExecutorModule {
|
|
|
230
249
|
execute: (...args: unknown[]) => Promise<ExecutorToolResult>;
|
|
231
250
|
executeDelegated: (...args: unknown[]) => Promise<ExecutorToolResult>;
|
|
232
251
|
};
|
|
252
|
+
DEFAULT_FOREGROUND_TIMEOUT_MS?: number;
|
|
233
253
|
}
|
|
234
254
|
|
|
235
255
|
const execution = await tryImport<ExecutionModule>("./src/runs/foreground/execution.ts");
|
|
@@ -572,7 +592,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
572
592
|
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
|
|
573
593
|
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
574
594
|
requestId: "registered-a",
|
|
575
|
-
agent: "
|
|
595
|
+
agent: "builder",
|
|
576
596
|
task: "First registered delegated call",
|
|
577
597
|
context: "fresh",
|
|
578
598
|
cwd: tempDir,
|
|
@@ -581,7 +601,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
581
601
|
extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
|
|
582
602
|
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
583
603
|
requestId: "registered-b",
|
|
584
|
-
agent: "
|
|
604
|
+
agent: "commentator",
|
|
585
605
|
task: "Second registered delegated call",
|
|
586
606
|
context: "fresh",
|
|
587
607
|
cwd: tempDir,
|
|
@@ -693,7 +713,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
693
713
|
requestId: "registered-v2-a",
|
|
694
714
|
ownerRunId: "owner-v2",
|
|
695
715
|
nodeId: "node-a",
|
|
696
|
-
agent: "
|
|
716
|
+
agent: "builder",
|
|
697
717
|
task: "Return literal JSON-looking text",
|
|
698
718
|
context: "fresh",
|
|
699
719
|
cwd: tempDir,
|
|
@@ -706,7 +726,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
706
726
|
requestId: "registered-v2-b",
|
|
707
727
|
ownerRunId: "owner-v2",
|
|
708
728
|
nodeId: "node-b",
|
|
709
|
-
agent: "
|
|
729
|
+
agent: "commentator",
|
|
710
730
|
task: "Run the second logical node",
|
|
711
731
|
context: "fresh",
|
|
712
732
|
cwd: tempDir,
|
|
@@ -745,6 +765,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
745
765
|
assert.equal(response.ownerRunId, "owner-v2");
|
|
746
766
|
assert.equal(response.model, "mock/test-model:high");
|
|
747
767
|
assert.equal(response.thinking, "high");
|
|
768
|
+
assert.match(response.launchContractDigest ?? "", /^[0-9a-f]{64}$/);
|
|
748
769
|
}
|
|
749
770
|
const literalResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === literalJsonText);
|
|
750
771
|
assert.ok(literalResponse);
|
|
@@ -1049,6 +1070,58 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1049
1070
|
assert.deepEqual(result.details?.totalCost, { inputTokens: 100, outputTokens: 50, costUsd: 0.001 });
|
|
1050
1071
|
});
|
|
1051
1072
|
|
|
1073
|
+
it("blocks later foreground chain children when hard reported usage is exhausted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1074
|
+
mockPi.onCall({ output: "first result" });
|
|
1075
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
|
|
1076
|
+
|
|
1077
|
+
const result = await executor.execute(
|
|
1078
|
+
"foreground-usage-budget",
|
|
1079
|
+
{
|
|
1080
|
+
chain: [
|
|
1081
|
+
{ agent: "echo", task: "First task" },
|
|
1082
|
+
{ agent: "second", task: "Second task" },
|
|
1083
|
+
],
|
|
1084
|
+
usageBudget: { tokens: { hard: 10 } },
|
|
1085
|
+
},
|
|
1086
|
+
new AbortController().signal,
|
|
1087
|
+
undefined,
|
|
1088
|
+
makeMinimalCtx(tempDir),
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
assert.equal(result.isError, true);
|
|
1092
|
+
assert.match(result.content[0]?.text ?? "", /Usage budget exhausted/);
|
|
1093
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1094
|
+
assert.equal(result.details?.usageBudget?.exhausted, true);
|
|
1095
|
+
assert.equal(result.details?.usageBudget?.reason, "tokens");
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it("blocks queued foreground parallel children when hard reported usage is exhausted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1099
|
+
mockPi.onCall({ output: "first result" });
|
|
1100
|
+
const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
|
|
1101
|
+
|
|
1102
|
+
const result = await executor.execute(
|
|
1103
|
+
"foreground-parallel-usage-budget",
|
|
1104
|
+
{
|
|
1105
|
+
tasks: [
|
|
1106
|
+
{ agent: "echo", task: "First task" },
|
|
1107
|
+
{ agent: "second", task: "Second task" },
|
|
1108
|
+
],
|
|
1109
|
+
concurrency: 1,
|
|
1110
|
+
usageBudget: { tokens: { hard: 10 } },
|
|
1111
|
+
},
|
|
1112
|
+
new AbortController().signal,
|
|
1113
|
+
undefined,
|
|
1114
|
+
makeMinimalCtx(tempDir),
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
assert.equal(result.isError, undefined);
|
|
1118
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1119
|
+
assert.equal(result.details?.results.length, 2);
|
|
1120
|
+
assert.equal(result.details?.results[1]?.skipped, true);
|
|
1121
|
+
assert.match(result.details?.results[1]?.error ?? "", /Usage budget exhausted/);
|
|
1122
|
+
assert.equal(result.details?.usageBudget?.exhausted, true);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1052
1125
|
it("fails implementation runs that complete without mutation attempts", async () => {
|
|
1053
1126
|
mockPi.onCall({ output: "Validation:\nlet rawFilename = params.filename.trim();" });
|
|
1054
1127
|
const agents = [makeAgent("worker")];
|
|
@@ -1318,6 +1391,17 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1318
1391
|
assert.equal(result.finalOutput, "Applied edit");
|
|
1319
1392
|
});
|
|
1320
1393
|
|
|
1394
|
+
it("resolves explicit agent aliases to canonical execution names", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1395
|
+
mockPi.onCall({ output: "Implemented" });
|
|
1396
|
+
const executor = makeExecutor([makeAgent("worker", { aliases: ["developer"], completionGuard: false })]);
|
|
1397
|
+
|
|
1398
|
+
const result = await executor.execute("single", { agent: "developer", task: "Implement" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
|
|
1399
|
+
|
|
1400
|
+
assert.equal(result.isError, undefined);
|
|
1401
|
+
assert.equal(result.details?.results[0]?.agent, "worker");
|
|
1402
|
+
assert.match(result.content[0]?.text ?? "", /Implemented/);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1321
1405
|
it("returns error for unknown agent", async () => {
|
|
1322
1406
|
const agents = makeAgentConfigs(["echo"]);
|
|
1323
1407
|
const result = await runSync(tempDir, agents, "nonexistent", "Do something", {});
|
|
@@ -1416,6 +1500,114 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1416
1500
|
assert.ok(result.error?.includes("Something went wrong"));
|
|
1417
1501
|
});
|
|
1418
1502
|
|
|
1503
|
+
it("retries a zero-activity startup exit on the same model", async () => {
|
|
1504
|
+
mockPi.onCall({ exitCode: 1 });
|
|
1505
|
+
mockPi.onCall({ output: "Recovered after startup race" });
|
|
1506
|
+
const agents = [makeAgent("worker", {
|
|
1507
|
+
model: "openai/gpt-5-mini",
|
|
1508
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
1509
|
+
})];
|
|
1510
|
+
|
|
1511
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
1512
|
+
runId: "startup-retry-sync",
|
|
1513
|
+
acceptance: false,
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
assert.equal(result.exitCode, 0);
|
|
1517
|
+
assert.equal(result.model, "openai/gpt-5-mini");
|
|
1518
|
+
assert.equal(result.finalOutput, "Recovered after startup race");
|
|
1519
|
+
assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
|
|
1520
|
+
assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, true]);
|
|
1521
|
+
assert.match(result.progress.recentOutput.join("\n"), /\[startup-retry\].*same model/i);
|
|
1522
|
+
assert.equal(result.progress.recentOutput.filter((line) => line.startsWith("[startup-retry]")).length, 1);
|
|
1523
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it("does not retry a non-zero exit after tool activity", async () => {
|
|
1527
|
+
mockPi.onCall({ jsonl: [events.toolStart("read", { path: "package.json" })], exitCode: 1 });
|
|
1528
|
+
mockPi.onCall({ output: "must not run" });
|
|
1529
|
+
const agents = [makeAgent("worker", {
|
|
1530
|
+
model: "openai/gpt-5-mini",
|
|
1531
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
1532
|
+
})];
|
|
1533
|
+
|
|
1534
|
+
const result = await runSync(tempDir, agents, "worker", "Read a file", {
|
|
1535
|
+
runId: "startup-no-retry-after-tool",
|
|
1536
|
+
acceptance: false,
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
assert.equal(result.exitCode, 1);
|
|
1540
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
1541
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("does not retry a signaled child exit", { skip: process.platform === "win32" ? "POSIX child signal reporting is unavailable on Windows" : false }, async () => {
|
|
1545
|
+
mockPi.onCall({ signal: "SIGKILL" });
|
|
1546
|
+
mockPi.onCall({ output: "must not run" });
|
|
1547
|
+
const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
|
|
1548
|
+
|
|
1549
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
1550
|
+
runId: "startup-no-retry-after-signal",
|
|
1551
|
+
acceptance: false,
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
assert.equal(result.exitCode, 1);
|
|
1555
|
+
assert.equal(result.processSignal, "SIGKILL");
|
|
1556
|
+
assert.equal(result.error, "Subagent process terminated by signal SIGKILL.");
|
|
1557
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
1558
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it("prefers signal termination errors over stderr tails", { skip: process.platform === "win32" ? "POSIX child signal reporting is unavailable on Windows" : false }, async () => {
|
|
1562
|
+
mockPi.onCall({ stderr: "INFO benign startup line\n", signal: "SIGTERM" });
|
|
1563
|
+
const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
|
|
1564
|
+
|
|
1565
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
1566
|
+
runId: "signal-error-over-stderr",
|
|
1567
|
+
acceptance: false,
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
assert.equal(result.exitCode, 1);
|
|
1571
|
+
assert.equal(result.processSignal, "SIGTERM");
|
|
1572
|
+
assert.equal(result.error, "Subagent process terminated by signal SIGTERM.");
|
|
1573
|
+
assert.doesNotMatch(result.error ?? "", /INFO benign startup line/);
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it("does not retry a child exit with raw stdout diagnostics", async () => {
|
|
1577
|
+
mockPi.onCall({ stdoutRaw: "configuration failed before protocol startup\n", exitCode: 1 });
|
|
1578
|
+
mockPi.onCall({ output: "must not run" });
|
|
1579
|
+
const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
|
|
1580
|
+
|
|
1581
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
1582
|
+
runId: "startup-no-retry-after-stdout-diagnostic",
|
|
1583
|
+
acceptance: false,
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
assert.equal(result.exitCode, 1);
|
|
1587
|
+
assert.match(result.error ?? "", /configuration failed before protocol startup/);
|
|
1588
|
+
assert.equal(result.modelAttempts?.length, 1);
|
|
1589
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it("reports an actionable error after startup retries are exhausted", async () => {
|
|
1593
|
+
mockPi.onCall({ exitCode: 1 });
|
|
1594
|
+
const agents = [makeAgent("worker", {
|
|
1595
|
+
model: "openai/gpt-5-mini",
|
|
1596
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
1597
|
+
})];
|
|
1598
|
+
|
|
1599
|
+
const result = await runSync(tempDir, agents, "worker", "Do work", {
|
|
1600
|
+
runId: "startup-retry-exhausted-sync",
|
|
1601
|
+
acceptance: false,
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
assert.equal(result.exitCode, 1);
|
|
1605
|
+
assert.equal(result.modelAttempts?.length, 4);
|
|
1606
|
+
assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
|
|
1607
|
+
assert.match(result.error ?? "", /failed to start after 4 attempts.*concurrent Pi startup race/i);
|
|
1608
|
+
assert.equal(mockPi.callCount(), 4);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1419
1611
|
it("handles long tasks via temp file (ENAMETOOLONG prevention)", async () => {
|
|
1420
1612
|
mockPi.onCall({ output: "Got it" });
|
|
1421
1613
|
const longTask = "Analyze ".repeat(2000); // ~16KB
|
|
@@ -1534,6 +1726,42 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
1534
1726
|
assert.equal(result.usage.output, 50); // from mock
|
|
1535
1727
|
});
|
|
1536
1728
|
|
|
1729
|
+
it("advances to a fallback model after a recovered startup race and provider failure", async () => {
|
|
1730
|
+
mockPi.onCall({ exitCode: 1 });
|
|
1731
|
+
mockPi.onCall({
|
|
1732
|
+
jsonl: [{
|
|
1733
|
+
type: "message_end",
|
|
1734
|
+
message: {
|
|
1735
|
+
role: "assistant",
|
|
1736
|
+
content: [{ type: "text", text: "temporary provider failure" }],
|
|
1737
|
+
model: "openai/gpt-5-mini",
|
|
1738
|
+
errorMessage: "rate limit exceeded",
|
|
1739
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
1740
|
+
},
|
|
1741
|
+
}],
|
|
1742
|
+
exitCode: 1,
|
|
1743
|
+
});
|
|
1744
|
+
mockPi.onCall({ output: "Recovered on fallback" });
|
|
1745
|
+
const agents = [makeAgent("echo", {
|
|
1746
|
+
model: "openai/gpt-5-mini",
|
|
1747
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
1748
|
+
})];
|
|
1749
|
+
|
|
1750
|
+
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
1751
|
+
runId: "startup-then-fallback-sync",
|
|
1752
|
+
acceptance: false,
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
assert.equal(result.exitCode, 0);
|
|
1756
|
+
assert.equal(result.model, "anthropic/claude-sonnet-4");
|
|
1757
|
+
assert.deepEqual(result.attemptedModels, [
|
|
1758
|
+
"openai/gpt-5-mini",
|
|
1759
|
+
"anthropic/claude-sonnet-4",
|
|
1760
|
+
]);
|
|
1761
|
+
assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, false, true]);
|
|
1762
|
+
assert.equal(mockPi.callCount(), 3);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1537
1765
|
it("retries with fallback models on retryable provider failures", async () => {
|
|
1538
1766
|
mockPi.onCall({
|
|
1539
1767
|
jsonl: [{
|
|
@@ -2020,8 +2248,12 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2020
2248
|
});
|
|
2021
2249
|
|
|
2022
2250
|
it("writes artifacts when configured", async () => {
|
|
2023
|
-
mockPi.onCall({
|
|
2024
|
-
|
|
2251
|
+
mockPi.onCall({
|
|
2252
|
+
output: "Result text",
|
|
2253
|
+
runtimeAcknowledgedExtensions: { version: 1, source: "child-runtime", ids: ["ext.ok"], omitted: 0 },
|
|
2254
|
+
});
|
|
2255
|
+
const privateExtension = path.join(tempDir, "extensions", "private-extension.ts");
|
|
2256
|
+
const agents = [makeAgent("echo", { extensions: [privateExtension] })];
|
|
2025
2257
|
const artifactsDir = path.join(tempDir, "artifacts");
|
|
2026
2258
|
|
|
2027
2259
|
const result = await runSync(tempDir, agents, "echo", "Task", {
|
|
@@ -2041,8 +2273,14 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2041
2273
|
assert.match(transcript.at(-1)?.text ?? "", /^Result text/);
|
|
2042
2274
|
assert.equal(result.transcriptError, undefined);
|
|
2043
2275
|
assert.ok(fs.existsSync(artifactsDir), "artifacts dir should exist");
|
|
2044
|
-
const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string };
|
|
2276
|
+
const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string; launchResolvedExtensions?: LaunchResolvedExtensions; runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions };
|
|
2045
2277
|
assert.equal(metadata.launchContractDigest, result.launchContractDigest);
|
|
2278
|
+
assert.equal(result.launchResolvedExtensions?.source, "launch-resolved");
|
|
2279
|
+
assert.equal(result.launchResolvedExtensions?.disableAmbientExtensions, true);
|
|
2280
|
+
assert.deepEqual(metadata.launchResolvedExtensions, result.launchResolvedExtensions);
|
|
2281
|
+
assert.deepEqual(result.runtimeAcknowledgedExtensions, { version: 1, source: "child-runtime", ids: ["ext.ok"], omitted: 0 });
|
|
2282
|
+
assert.deepEqual(metadata.runtimeAcknowledgedExtensions, result.runtimeAcknowledgedExtensions);
|
|
2283
|
+
assert.ok(!JSON.stringify(result.launchResolvedExtensions).includes(tempDir), "projection should not expose raw extension paths");
|
|
2046
2284
|
});
|
|
2047
2285
|
|
|
2048
2286
|
it("routes foreground artifacts to the configured session directory", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
@@ -2331,6 +2569,53 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
2331
2569
|
assert.equal(mockPi.callCount(), 0);
|
|
2332
2570
|
});
|
|
2333
2571
|
|
|
2572
|
+
it("applies the foreground timeout default without overriding explicit or agent values", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2573
|
+
mockPi.onCall({ output: "package default" });
|
|
2574
|
+
mockPi.onCall({ output: "explicit timeout" });
|
|
2575
|
+
mockPi.onCall({ output: "max runtime alias" });
|
|
2576
|
+
mockPi.onCall({ output: "agent timeout" });
|
|
2577
|
+
|
|
2578
|
+
const defaultExecutor = makeExecutor();
|
|
2579
|
+
const defaultResult = await defaultExecutor.execute(
|
|
2580
|
+
"foreground-timeout-default",
|
|
2581
|
+
{ agent: "echo", task: "Task" },
|
|
2582
|
+
new AbortController().signal,
|
|
2583
|
+
undefined,
|
|
2584
|
+
makeMinimalCtx(tempDir),
|
|
2585
|
+
);
|
|
2586
|
+
assert.equal(defaultResult.details?.timeoutMs, executorMod?.DEFAULT_FOREGROUND_TIMEOUT_MS);
|
|
2587
|
+
assert.equal(defaultResult.details?.timeoutMs, 30 * 60 * 1000);
|
|
2588
|
+
|
|
2589
|
+
const explicitResult = await defaultExecutor.execute(
|
|
2590
|
+
"foreground-timeout-explicit",
|
|
2591
|
+
{ agent: "echo", task: "Task", async: false, timeoutMs: 2_000 },
|
|
2592
|
+
new AbortController().signal,
|
|
2593
|
+
undefined,
|
|
2594
|
+
makeMinimalCtx(tempDir),
|
|
2595
|
+
);
|
|
2596
|
+
assert.equal(explicitResult.details?.timeoutMs, 2_000);
|
|
2597
|
+
|
|
2598
|
+
const aliasResult = await defaultExecutor.execute(
|
|
2599
|
+
"foreground-timeout-alias",
|
|
2600
|
+
{ agent: "echo", task: "Task", async: false, maxRuntimeMs: 3_000 },
|
|
2601
|
+
new AbortController().signal,
|
|
2602
|
+
undefined,
|
|
2603
|
+
makeMinimalCtx(tempDir),
|
|
2604
|
+
);
|
|
2605
|
+
assert.equal(aliasResult.details?.timeoutMs, 3_000);
|
|
2606
|
+
|
|
2607
|
+
const agentResult = await makeExecutor([
|
|
2608
|
+
makeAgent("echo", { defaultTimeoutMs: 4_000 }),
|
|
2609
|
+
]).execute(
|
|
2610
|
+
"foreground-timeout-agent-default",
|
|
2611
|
+
{ agent: "echo", task: "Task", async: false },
|
|
2612
|
+
new AbortController().signal,
|
|
2613
|
+
undefined,
|
|
2614
|
+
makeMinimalCtx(tempDir),
|
|
2615
|
+
);
|
|
2616
|
+
assert.equal(agentResult.details?.timeoutMs, 4_000);
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2334
2619
|
it("applies agent frontmatter defaults to single-agent launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
2335
2620
|
const executor = makeExecutor([
|
|
2336
2621
|
makeAgent("echo", {
|
|
@@ -3006,6 +3291,464 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3006
3291
|
assert.match(result.finalOutput ?? "", /Interrupted/);
|
|
3007
3292
|
});
|
|
3008
3293
|
|
|
3294
|
+
it("supports synchronous user detach and rejects duplicate and late detach calls", async () => {
|
|
3295
|
+
mockPi.onCall({ steps: [
|
|
3296
|
+
{ delay: 500, jsonl: [events.assistantMessage("completed after user detach")] },
|
|
3297
|
+
] });
|
|
3298
|
+
let detachActive: ((reason?: string) => boolean) | undefined;
|
|
3299
|
+
let detachAccepted = false;
|
|
3300
|
+
let duplicateAccepted = true;
|
|
3301
|
+
let recoveredResult: RunSyncResult | undefined;
|
|
3302
|
+
|
|
3303
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Keep working", {
|
|
3304
|
+
runId: "user-foreground-detach",
|
|
3305
|
+
acceptance: false,
|
|
3306
|
+
onDetachReady: (detach: (reason?: string) => boolean) => {
|
|
3307
|
+
detachActive = detach;
|
|
3308
|
+
detachAccepted = detach("user request");
|
|
3309
|
+
duplicateAccepted = detach("user request");
|
|
3310
|
+
},
|
|
3311
|
+
onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
|
|
3312
|
+
});
|
|
3313
|
+
|
|
3314
|
+
assert.equal(detachAccepted, true);
|
|
3315
|
+
assert.equal(duplicateAccepted, false);
|
|
3316
|
+
assert.equal(recoveredResult, undefined, "foreground result should return before the child completes");
|
|
3317
|
+
assert.equal(result.exitCode, -2);
|
|
3318
|
+
assert.equal(result.detached, true);
|
|
3319
|
+
assert.equal(result.detachedReason, "user request");
|
|
3320
|
+
assert.equal(result.finalOutput, "Detached at user request before task completion.");
|
|
3321
|
+
assert.equal(result.processSignal, undefined);
|
|
3322
|
+
|
|
3323
|
+
for (let attempt = 0; attempt < 100 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3324
|
+
assert.ok(recoveredResult);
|
|
3325
|
+
assert.equal(recoveredResult.exitCode, 0);
|
|
3326
|
+
assert.equal(recoveredResult.processSignal, undefined);
|
|
3327
|
+
assert.equal(recoveredResult.finalOutput, "completed after user detach");
|
|
3328
|
+
assert.equal(detachActive?.("user request"), false, "detach must reject calls after child exit");
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
it("produces the same authoritative terminal result attached and detached", async () => {
|
|
3332
|
+
mockPi.onCall({ output: "authoritative answer" });
|
|
3333
|
+
mockPi.onCall({ steps: [{ delay: 75, jsonl: [events.assistantMessage("authoritative answer")] }] });
|
|
3334
|
+
const agents = makeAgentConfigs(["echo"]);
|
|
3335
|
+
const attached = await runSync(tempDir, agents, "echo", "Equivalent task", {
|
|
3336
|
+
runId: "attached-authoritative-result",
|
|
3337
|
+
acceptance: false,
|
|
3338
|
+
});
|
|
3339
|
+
let terminal: RunSyncResult | undefined;
|
|
3340
|
+
const receipt = await runSync(tempDir, agents, "echo", "Equivalent task", {
|
|
3341
|
+
runId: "detached-authoritative-result",
|
|
3342
|
+
acceptance: false,
|
|
3343
|
+
onDetachReady: (detach) => assert.equal(detach("user request"), true),
|
|
3344
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3345
|
+
});
|
|
3346
|
+
assert.equal(receipt.detached, true);
|
|
3347
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3348
|
+
assert.ok(terminal);
|
|
3349
|
+
assert.deepEqual(
|
|
3350
|
+
{
|
|
3351
|
+
exitCode: terminal.exitCode,
|
|
3352
|
+
finalOutput: terminal.finalOutput,
|
|
3353
|
+
usage: terminal.usage,
|
|
3354
|
+
progressStatus: terminal.progress.status,
|
|
3355
|
+
acceptanceStatus: terminal.acceptance?.status,
|
|
3356
|
+
},
|
|
3357
|
+
{
|
|
3358
|
+
exitCode: attached.exitCode,
|
|
3359
|
+
finalOutput: attached.finalOutput,
|
|
3360
|
+
usage: attached.usage,
|
|
3361
|
+
progressStatus: attached.progress.status,
|
|
3362
|
+
acceptanceStatus: attached.acceptance?.status,
|
|
3363
|
+
},
|
|
3364
|
+
);
|
|
3365
|
+
assert.equal(terminal.detached, undefined);
|
|
3366
|
+
assert.equal(terminal.detachedReason, "user request");
|
|
3367
|
+
});
|
|
3368
|
+
|
|
3369
|
+
it("isolates every nested detach receipt field from terminal completion and later sanitization", async () => {
|
|
3370
|
+
const receiptReport = [
|
|
3371
|
+
"receipt snapshot",
|
|
3372
|
+
"```acceptance-report",
|
|
3373
|
+
JSON.stringify({
|
|
3374
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "receipt evidence" }],
|
|
3375
|
+
changedFiles: ["src/receipt.ts"],
|
|
3376
|
+
testsAddedOrUpdated: ["test/receipt.test.ts"],
|
|
3377
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
3378
|
+
residualRisks: [],
|
|
3379
|
+
noStagedFiles: true,
|
|
3380
|
+
}),
|
|
3381
|
+
"```",
|
|
3382
|
+
].join("\n");
|
|
3383
|
+
const terminalReport = [
|
|
3384
|
+
"terminal answer",
|
|
3385
|
+
"```acceptance-report",
|
|
3386
|
+
JSON.stringify({
|
|
3387
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "terminal isolation verified" }],
|
|
3388
|
+
changedFiles: ["src/receipt.ts"],
|
|
3389
|
+
testsAddedOrUpdated: ["test/receipt.test.ts"],
|
|
3390
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
3391
|
+
residualRisks: [],
|
|
3392
|
+
noStagedFiles: true,
|
|
3393
|
+
}),
|
|
3394
|
+
"```",
|
|
3395
|
+
].join("\n");
|
|
3396
|
+
mockPi.onCall({ steps: [
|
|
3397
|
+
{ jsonl: [{
|
|
3398
|
+
type: "message_end",
|
|
3399
|
+
message: {
|
|
3400
|
+
role: "assistant",
|
|
3401
|
+
content: [{ type: "text", text: receiptReport }],
|
|
3402
|
+
model: "mock/test-model",
|
|
3403
|
+
stopReason: "toolUse",
|
|
3404
|
+
usage: { input: 7, output: 3, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
3405
|
+
},
|
|
3406
|
+
}] },
|
|
3407
|
+
{ delay: 300, jsonl: [events.assistantMessage(terminalReport)] },
|
|
3408
|
+
] });
|
|
3409
|
+
let detach: ((reason?: string) => boolean) | undefined;
|
|
3410
|
+
let detached = false;
|
|
3411
|
+
let terminal: RunSyncResult | undefined;
|
|
3412
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Keep the receipt isolated", {
|
|
3413
|
+
runId: "detached-deep-receipt-isolation",
|
|
3414
|
+
agentContract: { version: 1 },
|
|
3415
|
+
acceptance: {
|
|
3416
|
+
level: "checked",
|
|
3417
|
+
criteria: [{
|
|
3418
|
+
id: "criterion-1",
|
|
3419
|
+
must: "Keep detach receipt state isolated",
|
|
3420
|
+
evidence: ["changed-files", "tests-added", "commands-run", "residual-risks", "no-staged-files"],
|
|
3421
|
+
}],
|
|
3422
|
+
},
|
|
3423
|
+
onDetachReady: (detachAttempt) => { detach = detachAttempt; },
|
|
3424
|
+
onUpdate: (update: { content?: Array<{ text?: string }> }) => {
|
|
3425
|
+
if (detached || !update.content?.[0]?.text?.includes("receipt snapshot")) return;
|
|
3426
|
+
detached = detach?.("user request") === true;
|
|
3427
|
+
},
|
|
3428
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3429
|
+
});
|
|
3430
|
+
|
|
3431
|
+
assert.equal(receipt.detached, true);
|
|
3432
|
+
const receiptMessages = receipt.messages as Array<{
|
|
3433
|
+
role?: string;
|
|
3434
|
+
model?: string;
|
|
3435
|
+
content: Array<{ type?: string; text?: string; callerOwned?: boolean }>;
|
|
3436
|
+
}>;
|
|
3437
|
+
const callerOwnedReceiptText = `caller-owned mutation\n${receiptReport}`;
|
|
3438
|
+
(receipt.agentContract as unknown as { version: number }).version = 999;
|
|
3439
|
+
receiptMessages[0]!.model = "caller-owned/model";
|
|
3440
|
+
receiptMessages[0]!.content[0]!.text = callerOwnedReceiptText;
|
|
3441
|
+
receiptMessages[0]!.content[0]!.callerOwned = true;
|
|
3442
|
+
receiptMessages[0]!.content.push({ type: "text", text: "caller-only content" });
|
|
3443
|
+
receiptMessages.push({ role: "assistant", model: "caller-only/model", content: [{ type: "text", text: "caller-only message" }] });
|
|
3444
|
+
const mutableAcceptance = receipt.acceptance as unknown as {
|
|
3445
|
+
status: string;
|
|
3446
|
+
effectiveAcceptance: { level: string; criteria: Array<{ must: string }> };
|
|
3447
|
+
criteria: Array<{ must: string }>;
|
|
3448
|
+
};
|
|
3449
|
+
mutableAcceptance.status = "rejected";
|
|
3450
|
+
mutableAcceptance.effectiveAcceptance.level = "none";
|
|
3451
|
+
mutableAcceptance.effectiveAcceptance.criteria[0]!.must = "caller corrupted effective criterion";
|
|
3452
|
+
mutableAcceptance.criteria[0]!.must = "caller corrupted ledger criterion";
|
|
3453
|
+
receipt.progress.status = "failed";
|
|
3454
|
+
(receipt.progress as unknown as { recentOutput: string[] }).recentOutput.push("caller-only progress");
|
|
3455
|
+
receipt.usage.turns = 999;
|
|
3456
|
+
receipt.usage.input = 999;
|
|
3457
|
+
receipt.attemptedModels = ["caller-only/model"];
|
|
3458
|
+
receipt.modelAttempts = [{ success: false, exitCode: 99, error: "caller-only attempt" }];
|
|
3459
|
+
receipt.effects = { fileMutation: { status: "missing", expected: true, attempted: false, message: "caller-only effect" } };
|
|
3460
|
+
receipt.execution = { status: "failed", success: false, exitCode: 99 };
|
|
3461
|
+
receipt.review = { status: "blockers" };
|
|
3462
|
+
|
|
3463
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3464
|
+
assert.ok(terminal);
|
|
3465
|
+
assert.equal(terminal.exitCode, 0);
|
|
3466
|
+
assert.equal(terminal.finalOutput, "terminal answer");
|
|
3467
|
+
assert.deepEqual(terminal.agentContract, { version: 1 });
|
|
3468
|
+
assert.equal(terminal.acceptance?.status, "checked");
|
|
3469
|
+
assert.equal(terminal.acceptance?.runtimeChecks.every((check) => check.status === "passed"), true);
|
|
3470
|
+
assert.equal(terminal.progress.status, "completed");
|
|
3471
|
+
assert.deepEqual(terminal.usage, { turns: 2, input: 107, output: 53, cacheRead: 0, cacheWrite: 0, cost: 0.002 });
|
|
3472
|
+
assert.deepEqual(terminal.attemptedModels, ["mock/test-model"]);
|
|
3473
|
+
assert.deepEqual(terminal.modelAttempts?.map((attempt) => ({ success: attempt.success, exitCode: attempt.exitCode })), [{ success: true, exitCode: 0 }]);
|
|
3474
|
+
assert.equal(terminal.execution?.status, "completed");
|
|
3475
|
+
assert.equal(terminal.execution?.success, true);
|
|
3476
|
+
assert.equal(terminal.review?.status, "not-requested");
|
|
3477
|
+
assert.deepEqual(terminal.effects, {});
|
|
3478
|
+
assert.doesNotMatch(JSON.stringify(terminal.messages), /acceptance-report/);
|
|
3479
|
+
assert.equal(receiptMessages[0]!.content[0]!.text, callerOwnedReceiptText, "terminal report sanitization must not mutate the caller receipt");
|
|
3480
|
+
assert.equal(receiptMessages[0]!.content[0]!.callerOwned, true);
|
|
3481
|
+
assert.equal(receiptMessages.length, 2);
|
|
3482
|
+
});
|
|
3483
|
+
|
|
3484
|
+
it("keeps the full fallback loop and authoritative aggregation alive after detach", async () => {
|
|
3485
|
+
mockPi.onCall({
|
|
3486
|
+
jsonl: [{
|
|
3487
|
+
type: "message_end",
|
|
3488
|
+
message: {
|
|
3489
|
+
role: "assistant",
|
|
3490
|
+
content: [{ type: "text", text: "temporary provider failure" }],
|
|
3491
|
+
model: "openai/gpt-5-mini",
|
|
3492
|
+
errorMessage: "rate limit exceeded",
|
|
3493
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
3494
|
+
},
|
|
3495
|
+
}],
|
|
3496
|
+
exitCode: 1,
|
|
3497
|
+
});
|
|
3498
|
+
mockPi.onCall({ output: "Recovered on detached fallback" });
|
|
3499
|
+
const agents = [makeAgent("echo", {
|
|
3500
|
+
model: "openai/gpt-5-mini",
|
|
3501
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
3502
|
+
})];
|
|
3503
|
+
let terminal: RunSyncResult | undefined;
|
|
3504
|
+
|
|
3505
|
+
const receipt = await runSync(tempDir, agents, "echo", "Task", {
|
|
3506
|
+
runId: "detached-fallback-loop",
|
|
3507
|
+
acceptance: false,
|
|
3508
|
+
onDetachReady: (detach) => assert.equal(detach("user request"), true),
|
|
3509
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3510
|
+
});
|
|
3511
|
+
|
|
3512
|
+
assert.equal(receipt.detached, true);
|
|
3513
|
+
for (let attempt = 0; attempt < 300 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3514
|
+
assert.ok(terminal);
|
|
3515
|
+
assert.equal(terminal.detached, undefined, "terminal status must not remain detached");
|
|
3516
|
+
assert.equal(terminal.detachedReason, "user request");
|
|
3517
|
+
assert.equal(terminal.exitCode, 0);
|
|
3518
|
+
assert.equal(terminal.finalOutput, "Recovered on detached fallback");
|
|
3519
|
+
assert.deepEqual(terminal.attemptedModels, ["openai/gpt-5-mini", "anthropic/claude-sonnet-4"]);
|
|
3520
|
+
assert.deepEqual(terminal.modelAttempts?.map((attempt) => attempt.success), [false, true]);
|
|
3521
|
+
assert.equal(terminal.usage.turns, 2);
|
|
3522
|
+
assert.equal(mockPi.callCount(), 2);
|
|
3523
|
+
});
|
|
3524
|
+
|
|
3525
|
+
it("terminalizes a post-receipt completion pipeline throw exactly once with strict projections", async () => {
|
|
3526
|
+
mockPi.onCall({ steps: [{ delay: 75, jsonl: [events.assistantMessage("answer before callback failure")] }] });
|
|
3527
|
+
let terminal: RunSyncResult | undefined;
|
|
3528
|
+
let callbackCount = 0;
|
|
3529
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3530
|
+
runId: "detached-completion-pipeline-throw",
|
|
3531
|
+
acceptance: { level: "checked", criteria: ["result is checked"] },
|
|
3532
|
+
agentContract: { version: 1 },
|
|
3533
|
+
onDetachReady: (detach) => {
|
|
3534
|
+
assert.equal(detach("user request"), true);
|
|
3535
|
+
},
|
|
3536
|
+
onUpdate: (update: { details?: { progress?: Array<{ status?: string }> } }) => {
|
|
3537
|
+
if (update.details?.progress?.[0]?.status === "completed") throw new Error("terminal consumer update failed");
|
|
3538
|
+
},
|
|
3539
|
+
onDetachedExit: (result) => {
|
|
3540
|
+
callbackCount++;
|
|
3541
|
+
terminal = result as RunSyncResult;
|
|
3542
|
+
},
|
|
3543
|
+
});
|
|
3544
|
+
assert.equal(receipt.detached, true);
|
|
3545
|
+
(receipt.agentContract as unknown as { version: number }).version = 999;
|
|
3546
|
+
receipt.messages.push({ role: "assistant", content: [{ type: "text", text: "caller-only fallback message" }] });
|
|
3547
|
+
const mutableAcceptance = receipt.acceptance as unknown as {
|
|
3548
|
+
status: string;
|
|
3549
|
+
effectiveAcceptance: { level: string; criteria: Array<{ must: string }> };
|
|
3550
|
+
};
|
|
3551
|
+
mutableAcceptance.status = "accepted";
|
|
3552
|
+
mutableAcceptance.effectiveAcceptance.level = "none";
|
|
3553
|
+
mutableAcceptance.effectiveAcceptance.criteria[0]!.must = "caller-only fallback criterion";
|
|
3554
|
+
receipt.progress.status = "completed";
|
|
3555
|
+
receipt.usage.turns = 999;
|
|
3556
|
+
receipt.usage.input = 999;
|
|
3557
|
+
receipt.attemptedModels = ["caller-only/fallback"];
|
|
3558
|
+
receipt.modelAttempts = [{ success: true, exitCode: 0 }];
|
|
3559
|
+
receipt.effects = { fileMutation: { status: "observed", expected: false, attempted: true, message: "caller-only fallback effect" } };
|
|
3560
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3561
|
+
assert.ok(terminal);
|
|
3562
|
+
assert.equal(callbackCount, 1);
|
|
3563
|
+
assert.equal(terminal.exitCode, 1);
|
|
3564
|
+
assert.equal(terminal.detached, undefined);
|
|
3565
|
+
assert.equal(terminal.detachedReason, "user request");
|
|
3566
|
+
assert.equal(terminal.progress?.status, "failed");
|
|
3567
|
+
assert.equal(terminal.acceptance?.status, "rejected");
|
|
3568
|
+
assert.equal(terminal.acceptance?.runtimeChecks?.[0]?.id, "completion-pipeline");
|
|
3569
|
+
assert.equal(terminal.execution?.status, "failed");
|
|
3570
|
+
assert.equal(terminal.execution?.success, false);
|
|
3571
|
+
assert.equal(terminal.review?.status, "not-requested");
|
|
3572
|
+
assert.deepEqual(terminal.agentContract, { version: 1 });
|
|
3573
|
+
assert.deepEqual(terminal.effects, {});
|
|
3574
|
+
assert.equal(terminal.usage.turns, 0);
|
|
3575
|
+
assert.equal(terminal.attemptedModels, undefined);
|
|
3576
|
+
assert.equal(terminal.modelAttempts, undefined);
|
|
3577
|
+
assert.doesNotMatch(JSON.stringify(terminal.messages), /caller-only fallback message/);
|
|
3578
|
+
assert.match(terminal.error ?? "", /Detached completion pipeline failed after receipt/);
|
|
3579
|
+
});
|
|
3580
|
+
|
|
3581
|
+
it("contains a synchronous onDetachReady throw and completes attached", async () => {
|
|
3582
|
+
mockPi.onCall({ output: "completed while attached" });
|
|
3583
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3584
|
+
runId: "throwing-detach-ready-consumer",
|
|
3585
|
+
acceptance: false,
|
|
3586
|
+
onDetachReady: () => {
|
|
3587
|
+
throw new Error("bad detach consumer");
|
|
3588
|
+
},
|
|
3589
|
+
});
|
|
3590
|
+
assert.equal(result.exitCode, 0);
|
|
3591
|
+
assert.equal(result.detached, undefined);
|
|
3592
|
+
assert.equal(result.finalOutput, "completed while attached");
|
|
3593
|
+
assert.equal(result.progress.recentOutput.some((line) => /Foreground detach callback failed: bad detach consumer/.test(line)), true);
|
|
3594
|
+
});
|
|
3595
|
+
|
|
3596
|
+
it("reports expected artifact post-processing I/O failures without rejecting", async () => {
|
|
3597
|
+
mockPi.onCall({ steps: [
|
|
3598
|
+
{ jsonl: [events.toolStart("read", { path: "README.md" })] },
|
|
3599
|
+
{ delay: 50, jsonl: [events.assistantMessage("artifact answer")] },
|
|
3600
|
+
] });
|
|
3601
|
+
const artifactsDir = path.join(tempDir, "artifact-output-failure");
|
|
3602
|
+
let sabotaged = false;
|
|
3603
|
+
const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3604
|
+
runId: "artifact-output-result-field",
|
|
3605
|
+
acceptance: false,
|
|
3606
|
+
artifactsDir,
|
|
3607
|
+
artifactConfig: { enabled: true },
|
|
3608
|
+
onUpdate: (update: { details?: { results?: Array<{ artifactPaths?: { outputPath?: string } }> } }) => {
|
|
3609
|
+
const outputPath = update.details?.results?.[0]?.artifactPaths?.outputPath;
|
|
3610
|
+
if (sabotaged || !outputPath) return;
|
|
3611
|
+
sabotaged = true;
|
|
3612
|
+
fs.mkdirSync(outputPath);
|
|
3613
|
+
},
|
|
3614
|
+
});
|
|
3615
|
+
assert.equal(sabotaged, true);
|
|
3616
|
+
assert.equal(result.exitCode, 0);
|
|
3617
|
+
assert.match(result.outputSaveError ?? "", /Artifact output post-processing failed/);
|
|
3618
|
+
});
|
|
3619
|
+
|
|
3620
|
+
it("publishes detach despite best-effort receipt metadata persistence failure", async () => {
|
|
3621
|
+
mockPi.onCall({ steps: [{ delay: 100, jsonl: [events.assistantMessage("completed after metadata recovery")] }] });
|
|
3622
|
+
const artifactsDir = path.join(tempDir, "receipt-metadata-failure");
|
|
3623
|
+
let terminal: RunSyncResult | undefined;
|
|
3624
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3625
|
+
runId: "detach-receipt-metadata-failure",
|
|
3626
|
+
acceptance: false,
|
|
3627
|
+
artifactsDir,
|
|
3628
|
+
artifactConfig: { enabled: true },
|
|
3629
|
+
onDetachReady: (detach) => {
|
|
3630
|
+
fs.rmSync(artifactsDir, { recursive: true, force: true });
|
|
3631
|
+
fs.writeFileSync(artifactsDir, "block metadata", "utf-8");
|
|
3632
|
+
assert.equal(detach("user request"), true);
|
|
3633
|
+
fs.rmSync(artifactsDir, { force: true });
|
|
3634
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
3635
|
+
},
|
|
3636
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3637
|
+
});
|
|
3638
|
+
assert.equal(receipt.detached, true);
|
|
3639
|
+
assert.ok(receipt.metadataSaveError, "receipt should record best-effort metadata persistence failure");
|
|
3640
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3641
|
+
assert.equal(terminal?.exitCode, 0);
|
|
3642
|
+
});
|
|
3643
|
+
|
|
3644
|
+
it("contains a throwing detached-exit callback", async () => {
|
|
3645
|
+
mockPi.onCall({ steps: [{ delay: 50, jsonl: [events.assistantMessage("done")] }] });
|
|
3646
|
+
let callbackCount = 0;
|
|
3647
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3648
|
+
runId: "throwing-detached-exit-callback",
|
|
3649
|
+
acceptance: false,
|
|
3650
|
+
onDetachReady: (detach) => assert.equal(detach("user request"), true),
|
|
3651
|
+
onDetachedExit: () => {
|
|
3652
|
+
callbackCount++;
|
|
3653
|
+
throw new Error("consumer callback failed");
|
|
3654
|
+
},
|
|
3655
|
+
});
|
|
3656
|
+
assert.equal(receipt.detached, true);
|
|
3657
|
+
for (let attempt = 0; attempt < 100 && callbackCount === 0; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3658
|
+
assert.equal(callbackCount, 1);
|
|
3659
|
+
});
|
|
3660
|
+
|
|
3661
|
+
it("skips acceptance evaluation when an explicitly interrupted detached result settles", async () => {
|
|
3662
|
+
mockPi.onCall({ steps: [{ delay: 10_000, jsonl: [events.assistantMessage("too late")] }] });
|
|
3663
|
+
const interrupt = new AbortController();
|
|
3664
|
+
let terminal: RunSyncResult | undefined;
|
|
3665
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Task", {
|
|
3666
|
+
runId: "detached-interrupted-acceptance",
|
|
3667
|
+
acceptance: { level: "checked", criteria: ["result is checked"] },
|
|
3668
|
+
interruptSignal: interrupt.signal,
|
|
3669
|
+
onDetachReady: (detach) => {
|
|
3670
|
+
assert.equal(detach("user request"), true);
|
|
3671
|
+
setTimeout(() => interrupt.abort(), 25);
|
|
3672
|
+
},
|
|
3673
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3674
|
+
});
|
|
3675
|
+
assert.equal(receipt.detached, true);
|
|
3676
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3677
|
+
assert.ok(terminal);
|
|
3678
|
+
assert.equal(terminal.interrupted, true);
|
|
3679
|
+
assert.equal(terminal.exitCode, 0);
|
|
3680
|
+
assert.equal(terminal.acceptance?.status, "pending");
|
|
3681
|
+
assert.equal(terminal.acceptance?.runtimeChecks[0]?.status, "not-applicable");
|
|
3682
|
+
assert.equal(terminal.error, undefined);
|
|
3683
|
+
});
|
|
3684
|
+
|
|
3685
|
+
it("linearizes originating abort against detach and keeps explicit interrupt routable afterward", async () => {
|
|
3686
|
+
mockPi.onCall({ steps: [{ delay: 10_000, jsonl: [events.assistantMessage("too late")] }] });
|
|
3687
|
+
const origin = new AbortController();
|
|
3688
|
+
const interrupt = new AbortController();
|
|
3689
|
+
let terminal: RunSyncResult | undefined;
|
|
3690
|
+
const receipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Keep working", {
|
|
3691
|
+
runId: "detach-origin-abort-race",
|
|
3692
|
+
acceptance: false,
|
|
3693
|
+
signal: origin.signal,
|
|
3694
|
+
interruptSignal: interrupt.signal,
|
|
3695
|
+
onDetachReady: (detach) => {
|
|
3696
|
+
assert.equal(detach("user request"), true);
|
|
3697
|
+
origin.abort();
|
|
3698
|
+
setTimeout(() => interrupt.abort(), 50);
|
|
3699
|
+
},
|
|
3700
|
+
onDetachedExit: (result) => { terminal = result as RunSyncResult; },
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
assert.equal(receipt.detached, true);
|
|
3704
|
+
for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3705
|
+
assert.ok(terminal);
|
|
3706
|
+
assert.equal(terminal.interrupted, true, "explicit control interrupt must remain active after detach");
|
|
3707
|
+
assert.equal(terminal.processSignal, "SIGINT");
|
|
3708
|
+
assert.equal(terminal.detached, undefined);
|
|
3709
|
+
});
|
|
3710
|
+
|
|
3711
|
+
it("lets an already-observed originating abort win over detach", async () => {
|
|
3712
|
+
mockPi.onCall({ delay: 10_000 });
|
|
3713
|
+
const origin = new AbortController();
|
|
3714
|
+
let detachAccepted = true;
|
|
3715
|
+
const result = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Abort first", {
|
|
3716
|
+
runId: "origin-abort-wins-detach",
|
|
3717
|
+
signal: origin.signal,
|
|
3718
|
+
onDetachReady: (detach) => {
|
|
3719
|
+
origin.abort();
|
|
3720
|
+
detachAccepted = detach("user request");
|
|
3721
|
+
},
|
|
3722
|
+
});
|
|
3723
|
+
assert.equal(detachAccepted, false);
|
|
3724
|
+
assert.equal(result.detached, undefined);
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
it("keeps the configured runtime timeout active after user detach", async () => {
|
|
3728
|
+
mockPi.onCall({ delay: 10_000 });
|
|
3729
|
+
let recoveredResult: RunSyncResult | undefined;
|
|
3730
|
+
const startedAt = Date.now();
|
|
3731
|
+
|
|
3732
|
+
const result = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Do not run forever", {
|
|
3733
|
+
runId: "user-detach-timeout",
|
|
3734
|
+
timeoutMs: 150,
|
|
3735
|
+
acceptance: false,
|
|
3736
|
+
onDetachReady: (detach: (reason?: string) => boolean) => {
|
|
3737
|
+
assert.equal(detach("user request"), true);
|
|
3738
|
+
},
|
|
3739
|
+
onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
|
|
3740
|
+
});
|
|
3741
|
+
|
|
3742
|
+
assert.equal(result.detached, true);
|
|
3743
|
+
assert.ok(Date.now() - startedAt < 1_000, "detach should release the foreground waiter promptly");
|
|
3744
|
+
for (let attempt = 0; attempt < 300 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
3745
|
+
assert.ok(recoveredResult, "configured timeout should terminate and recover the detached child");
|
|
3746
|
+
assert.equal(recoveredResult.timedOut, true);
|
|
3747
|
+
assert.equal(recoveredResult.error, "Subagent timed out after 150ms.");
|
|
3748
|
+
assert.equal(recoveredResult.progress.status, "failed");
|
|
3749
|
+
assert.ok(Date.now() - startedAt < 5_000, "detached child should remain bounded by runtime enforcement");
|
|
3750
|
+
});
|
|
3751
|
+
|
|
3009
3752
|
for (const toolName of ["intercom", "contact_supervisor"]) {
|
|
3010
3753
|
it(`detaches cleanly on ${toolName} handoff without aborting the child process`, async () => {
|
|
3011
3754
|
const eventBus = createEventBus();
|
|
@@ -3052,6 +3795,106 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
|
|
|
3052
3795
|
});
|
|
3053
3796
|
}
|
|
3054
3797
|
|
|
3798
|
+
it("reports intercom detach race losses and repeated requests as not accepted", async () => {
|
|
3799
|
+
const abortBus = createEventBus();
|
|
3800
|
+
const abortResponses: boolean[] = [];
|
|
3801
|
+
abortBus.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => abortResponses.push((payload as { accepted: boolean }).accepted));
|
|
3802
|
+
mockPi.onCall({ steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }, { delay: 10_000 }] });
|
|
3803
|
+
const origin = new AbortController();
|
|
3804
|
+
let requested = false;
|
|
3805
|
+
const abortedResult = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3806
|
+
runId: "intercom-abort-race-loss",
|
|
3807
|
+
allowIntercomDetach: true,
|
|
3808
|
+
intercomEvents: abortBus,
|
|
3809
|
+
signal: origin.signal,
|
|
3810
|
+
onUpdate: (update) => {
|
|
3811
|
+
if (requested || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
|
|
3812
|
+
requested = true;
|
|
3813
|
+
origin.abort();
|
|
3814
|
+
abortBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "abort-race" });
|
|
3815
|
+
},
|
|
3816
|
+
});
|
|
3817
|
+
assert.equal(abortedResult.detached, undefined);
|
|
3818
|
+
assert.deepEqual(abortResponses, [false]);
|
|
3819
|
+
|
|
3820
|
+
const repeatedBus = createEventBus();
|
|
3821
|
+
const repeatedResponses: boolean[] = [];
|
|
3822
|
+
repeatedBus.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => repeatedResponses.push((payload as { accepted: boolean }).accepted));
|
|
3823
|
+
mockPi.onCall({ steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }, { delay: 50, jsonl: [events.assistantMessage("done")] }] });
|
|
3824
|
+
let repeated = false;
|
|
3825
|
+
const repeatedReceipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
|
|
3826
|
+
runId: "intercom-repeated-detach",
|
|
3827
|
+
allowIntercomDetach: true,
|
|
3828
|
+
intercomEvents: repeatedBus,
|
|
3829
|
+
onUpdate: (update) => {
|
|
3830
|
+
if (repeated || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
|
|
3831
|
+
repeated = true;
|
|
3832
|
+
repeatedBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "first" });
|
|
3833
|
+
repeatedBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "second" });
|
|
3834
|
+
},
|
|
3835
|
+
});
|
|
3836
|
+
assert.equal(repeatedReceipt.detached, true);
|
|
3837
|
+
assert.deepEqual(repeatedResponses, [true, false]);
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
it("does not launch retries or fallbacks after intercom detach and keeps timeout enforcement", async () => {
|
|
3841
|
+
const fallbackBus = createEventBus();
|
|
3842
|
+
mockPi.onCall({
|
|
3843
|
+
steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }],
|
|
3844
|
+
stderr: "rate limit exceeded",
|
|
3845
|
+
exitCode: 1,
|
|
3846
|
+
});
|
|
3847
|
+
mockPi.onCall({ output: "must not launch" });
|
|
3848
|
+
let resolveFallbackTerminal!: (result: RunSyncResult) => void;
|
|
3849
|
+
const fallbackTerminal = new Promise<RunSyncResult>((resolve) => { resolveFallbackTerminal = resolve; });
|
|
3850
|
+
let fallbackRequested = false;
|
|
3851
|
+
const receipt = await runSync(tempDir, [makeAgent("echo", { model: "openai/gpt-5-mini", fallbackModels: ["anthropic/claude-sonnet-4"] })], "echo", "Task", {
|
|
3852
|
+
runId: "intercom-no-fallback",
|
|
3853
|
+
acceptance: false,
|
|
3854
|
+
allowIntercomDetach: true,
|
|
3855
|
+
intercomEvents: fallbackBus,
|
|
3856
|
+
onUpdate: (update) => {
|
|
3857
|
+
if (fallbackRequested || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
|
|
3858
|
+
fallbackRequested = true;
|
|
3859
|
+
fallbackBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "no-fallback" });
|
|
3860
|
+
},
|
|
3861
|
+
onDetachedExit: (result) => { resolveFallbackTerminal(result as RunSyncResult); },
|
|
3862
|
+
});
|
|
3863
|
+
assert.equal(receipt.detached, true);
|
|
3864
|
+
const fallbackResult = await fallbackTerminal;
|
|
3865
|
+
assert.equal(mockPi.callCount(), 1);
|
|
3866
|
+
assert.equal(fallbackResult.exitCode, 1);
|
|
3867
|
+
|
|
3868
|
+
const timeoutBus = createEventBus();
|
|
3869
|
+
mockPi.reset();
|
|
3870
|
+
mockPi.onCall({ delay: 10_000 });
|
|
3871
|
+
let resolveTimeoutTerminal!: (result: RunSyncResult) => void;
|
|
3872
|
+
const timeoutTerminal = new Promise<RunSyncResult>((resolve) => { resolveTimeoutTerminal = resolve; });
|
|
3873
|
+
let timeoutRequested = false;
|
|
3874
|
+
const timeoutReceipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Task", {
|
|
3875
|
+
runId: "intercom-timeout-enforced",
|
|
3876
|
+
acceptance: false,
|
|
3877
|
+
timeoutMs: 125,
|
|
3878
|
+
allowIntercomDetach: true,
|
|
3879
|
+
intercomEvents: timeoutBus,
|
|
3880
|
+
onDetachReady: () => {
|
|
3881
|
+
if (timeoutRequested) return;
|
|
3882
|
+
timeoutRequested = true;
|
|
3883
|
+
timeoutBus.emit(INTERCOM_DETACH_REQUEST_EVENT, {
|
|
3884
|
+
requestId: "timeout",
|
|
3885
|
+
runId: "intercom-timeout-enforced",
|
|
3886
|
+
agent: "slow",
|
|
3887
|
+
childIndex: 0,
|
|
3888
|
+
});
|
|
3889
|
+
},
|
|
3890
|
+
onDetachedExit: (result) => { resolveTimeoutTerminal(result as RunSyncResult); },
|
|
3891
|
+
});
|
|
3892
|
+
assert.equal(timeoutReceipt.detached, true);
|
|
3893
|
+
const timeoutResult = await timeoutTerminal;
|
|
3894
|
+
assert.equal(timeoutResult.timedOut, true);
|
|
3895
|
+
assert.equal(timeoutResult.exitCode, 1);
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3055
3898
|
it("enforces the stdout protocol limit after foreground detachment", async () => {
|
|
3056
3899
|
const eventBus = createEventBus();
|
|
3057
3900
|
mockPi.onCall({ steps: [
|