@selesai/code 0.13.15 → 0.13.18
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/CHANGELOG.md +37 -0
- package/README.md +2 -14
- package/dist/bun/cli.d.ts +3 -1
- package/dist/bun/cli.js +3 -9
- package/dist/bun/{register-bedrock.js → runtime-setup.js} +5 -0
- package/dist/bun/sandbox-env-setup.d.ts +1 -0
- package/dist/bun/sandbox-env-setup.js +3 -0
- package/dist/cli/auth-command.d.ts +23 -0
- package/dist/cli/auth-command.js +102 -0
- package/dist/cli/config-selector.js +2 -1
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +11 -0
- package/dist/cli.js +2 -14
- package/dist/config.d.ts +3 -2
- package/dist/config.js +9 -4
- package/dist/core/agent-session-runtime.js +15 -7
- package/dist/core/agent-session.d.ts +2 -6
- package/dist/core/agent-session.js +15 -4
- package/dist/core/compaction/branch-summarization.d.ts +1 -1
- package/dist/core/compaction/branch-summarization.js +2 -1
- package/dist/core/extensions/loader.js +3 -0
- package/dist/core/extensions/types.d.ts +16 -2
- package/dist/core/http-dispatcher.js +2 -0
- package/dist/core/keybindings.d.ts +5 -0
- package/dist/core/keybindings.js +4 -0
- package/dist/core/messages.d.ts +1 -1
- package/dist/core/model-config.d.ts +15 -0
- package/dist/core/model-config.js +3 -0
- package/dist/core/model-runtime.d.ts +1 -0
- package/dist/core/model-runtime.js +5 -2
- package/dist/core/provider-composer.js +8 -2
- package/dist/core/session-export.d.ts +3 -0
- package/dist/core/session-export.js +31 -0
- package/dist/core/session-manager.d.ts +3 -2
- package/dist/core/session-manager.js +40 -13
- package/dist/core/skills.d.ts +1 -1
- package/dist/core/skills.js +4 -2
- package/dist/core/system-prompt.js +10 -10
- package/dist/core/tools/bash.js +3 -117
- package/dist/core/tools/edit.d.ts +1 -12
- package/dist/core/tools/edit.js +5 -163
- package/dist/core/tools/find.js +4 -55
- package/dist/core/tools/grep.js +4 -60
- package/dist/core/tools/ls.js +4 -49
- package/dist/core/tools/read.d.ts +4 -0
- package/dist/core/tools/read.js +10 -122
- package/dist/core/tools/renderers/bash.d.ts +11 -0
- package/dist/core/tools/renderers/bash.js +129 -0
- package/dist/core/tools/renderers/edit.d.ts +22 -0
- package/dist/core/tools/renderers/edit.js +166 -0
- package/dist/core/tools/renderers/find.d.ts +9 -0
- package/dist/core/tools/renderers/find.js +63 -0
- package/dist/core/tools/renderers/grep.d.ts +9 -0
- package/dist/core/tools/renderers/grep.js +68 -0
- package/dist/core/tools/renderers/index.d.ts +27 -0
- package/dist/core/tools/renderers/index.js +46 -0
- package/dist/core/tools/renderers/ls.d.ts +9 -0
- package/dist/core/tools/renderers/ls.js +57 -0
- package/dist/core/tools/renderers/read.d.ts +10 -0
- package/dist/core/tools/renderers/read.js +128 -0
- package/dist/core/tools/renderers/write.d.ts +9 -0
- package/dist/core/tools/renderers/write.js +152 -0
- package/dist/core/tools/write.js +5 -146
- package/dist/extensions/capability-gateway/index.ts +12 -1
- package/dist/extensions/capability-gateway/integration.test.ts +23 -0
- package/dist/extensions/pi-hermes-memory/CHANGELOG.md +18 -0
- package/dist/extensions/pi-hermes-memory/README.md +16 -12
- package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +1 -1
- package/dist/extensions/pi-hermes-memory/package-lock.json +587 -62
- package/dist/extensions/pi-hermes-memory/package.json +4 -4
- package/dist/extensions/pi-hermes-memory/src/config.ts +32 -0
- package/dist/extensions/pi-hermes-memory/src/constants.ts +12 -2
- package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +109 -51
- package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +9 -3
- package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +257 -98
- package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +9 -2
- package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +5 -0
- package/dist/extensions/pi-hermes-memory/src/index.ts +22 -2
- package/dist/extensions/pi-hermes-memory/src/paths.ts +1 -1
- package/dist/extensions/pi-hermes-memory/src/store/db.ts +6 -0
- package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +58 -1
- package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +60 -8
- package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +169 -7
- package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +7 -21
- package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +134 -15
- package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +4 -2
- package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +24 -9
- package/dist/extensions/pi-hermes-memory/src/types.ts +11 -0
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +42 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +159 -3
- package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +452 -0
- package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +39 -0
- package/dist/extensions/pi-hermes-memory/tests/integration/corruption-recovery-integration.test.ts +91 -0
- package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +29 -0
- package/dist/extensions/pi-hermes-memory/tests/store/fts-query.test.ts +116 -0
- package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +163 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +233 -0
- package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +12 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +175 -0
- package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +2 -2
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +22 -0
- package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +37 -1
- package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
- package/dist/extensions/pi-intercom/README.md +16 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +19 -2
- package/dist/extensions/pi-intercom/broker/spawn.ts +4 -4
- package/dist/extensions/pi-intercom/index.ts +1 -1
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +121 -2
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/project-agent.test.ts +1 -1
- package/dist/extensions/pi-intercom/project-agent.ts +1 -6
- package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +7 -6
- package/dist/extensions/pi-rewind-hook/index.test.ts +7 -0
- package/dist/extensions/pi-subagents/.github/workflows/test.yml +36 -4
- package/dist/extensions/pi-subagents/CHANGELOG.md +120 -0
- package/dist/extensions/pi-subagents/README.md +3 -3
- package/dist/extensions/pi-subagents/agents/researcher.md +23 -13
- package/dist/extensions/pi-subagents/agents/reviewer.md +1 -1
- package/dist/extensions/pi-subagents/agents/scout.md +1 -1
- package/dist/extensions/pi-subagents/docs/agents.md +32 -16
- package/dist/extensions/pi-subagents/docs/configuration.md +41 -15
- package/dist/extensions/pi-subagents/docs/extension-api.md +109 -6
- package/dist/extensions/pi-subagents/docs/missions.md +2 -0
- package/dist/extensions/pi-subagents/docs/models.md +58 -1
- package/dist/extensions/pi-subagents/docs/observability.md +50 -12
- package/dist/extensions/pi-subagents/docs/tool-reference.md +17 -10
- package/dist/extensions/pi-subagents/docs/watchdog.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +26 -9
- package/dist/extensions/pi-subagents/package-lock.json +747 -177
- package/dist/extensions/pi-subagents/package.json +6 -4
- package/dist/extensions/pi-subagents/runner-server-preload.mjs +13 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +21 -4
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/dist/extensions/pi-subagents/src/agents/advertised-agent-prompt.ts +63 -0
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +61 -16
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +2 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +8 -0
- package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +0 -1
- package/dist/extensions/pi-subagents/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/dist/extensions/pi-subagents/src/api/preflight.ts +7 -4
- package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -2
- package/dist/extensions/pi-subagents/src/api/workflow-resources.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +4 -2
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +2 -10
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +9 -11
- package/dist/extensions/pi-subagents/src/extension/index.ts +99 -10
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +13 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +7 -23
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +11 -8
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +25 -7
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +73 -0
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +228 -136
- package/dist/extensions/pi-subagents/src/intercom/supervisor-ui.ts +244 -0
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +37 -16
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +18 -18
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +51 -25
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +46 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +14 -3
- package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +9 -0
- package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +10 -12
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -15
- package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +40 -29
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +8 -0
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +79 -247
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +88 -12
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +6 -6
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +24 -24
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +642 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +75 -6
- package/dist/extensions/pi-subagents/src/runs/background/runner-aliases.ts +163 -0
- package/dist/extensions/pi-subagents/src/runs/background/runner-child-launch.ts +86 -0
- package/dist/extensions/pi-subagents/src/runs/background/runner-child-sessions.ts +31 -0
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +18 -4
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +597 -1092
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +20 -17
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +296 -394
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +630 -283
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/dist/extensions/pi-subagents/src/runs/foreground/workflow-foreground-steering.ts +78 -98
- package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +16 -3
- package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +47 -47
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +1 -2
- package/dist/extensions/pi-subagents/src/runs/shared/child-hooks.ts +174 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +14 -3
- package/dist/extensions/pi-subagents/src/runs/shared/child-launch.ts +319 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-lifecycle.ts +25 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-runtime-config.ts +126 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-session.ts +373 -0
- package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +530 -0
- package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +16 -0
- package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +11 -11
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +30 -20
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +84 -15
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +88 -12
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +37 -53
- package/dist/extensions/pi-subagents/src/runs/shared/nested-path.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/orca-progress-tabs.ts +18 -7
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +57 -12
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +3 -4
- package/dist/extensions/pi-subagents/src/runs/shared/permissions.ts +0 -11
- package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/dist/extensions/pi-subagents/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +8 -21
- package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +3 -30
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +17 -4
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +99 -386
- package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +18 -62
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +0 -14
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +31 -9
- package/dist/extensions/pi-subagents/src/runs/shared/worktree-setup-command.ts +190 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +506 -226
- package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +1 -1
- package/dist/extensions/pi-subagents/src/shared/jsonl-writer.ts +11 -0
- package/dist/extensions/pi-subagents/src/shared/model-response-aliases.ts +13 -0
- package/dist/extensions/pi-subagents/src/shared/thinking-ceiling.ts +0 -6
- package/dist/extensions/pi-subagents/src/shared/types.ts +145 -89
- package/dist/extensions/pi-subagents/src/shared/utils.ts +13 -6
- package/dist/extensions/pi-subagents/src/shared/watch-strategy.ts +2 -0
- package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +18 -13
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +0 -6
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +1 -1
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +11 -6
- package/dist/extensions/pi-subagents/src/tui/render.ts +277 -46
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +0 -1
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +12 -12
- package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +3 -3
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +116 -15
- package/dist/extensions/pi-subagents/src/workflows/workflow-checklist.ts +15 -18
- package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +57 -8
- package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +19 -19
- package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +3 -3
- package/dist/extensions/pi-subagents/src/workflows/workflow-resources.ts +97 -22
- package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +3 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +336 -21
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-1.test.ts +1181 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-2.test.ts +2240 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +2268 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-4.test.ts +1512 -0
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +231 -16
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +4 -18
- package/dist/extensions/pi-subagents/test/integration/foreign-workflow-steering.test.ts +95 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +9 -15
- package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +374 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -10
- package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +18 -6
- package/dist/extensions/pi-subagents/test/integration/recovered-assistant-error.test.ts +151 -0
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +42 -1
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +150 -25
- package/dist/extensions/pi-subagents/test/integration/result-publication.test.ts +354 -0
- package/dist/extensions/pi-subagents/test/integration/shared-cwd-terminal-evidence.test.ts +117 -0
- package/dist/extensions/pi-subagents/test/integration/single-execution.part-1.test.ts +3678 -0
- package/dist/extensions/pi-subagents/test/integration/{single-execution.test.ts → single-execution.part-2.test.ts} +500 -4207
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -2
- package/dist/extensions/pi-subagents/test/integration/workflow-result-publication.test.ts +127 -0
- package/dist/extensions/pi-subagents/test/integration/workflow-steer-inbox.test.ts +365 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-child.ts +31 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-clean-install.mjs +74 -0
- package/dist/extensions/pi-subagents/test/smoke/pi085-extension.ts +16 -0
- package/dist/extensions/pi-subagents/test/support/async-execution-fixture.ts +699 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +418 -0
- package/dist/extensions/pi-subagents/test/support/foreign-workflow-steer-host.mjs +13 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +30 -76
- package/dist/extensions/pi-subagents/test/support/notify-diagnostics-fixture.ts +84 -0
- package/dist/extensions/pi-subagents/test/support/result-publication-capacity-preload.mjs +70 -0
- package/dist/extensions/pi-subagents/test/support/runner-child-session-factory.ts +17 -0
- package/dist/extensions/pi-subagents/test/support/single-execution-fixture.ts +390 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance-compaction.test.ts +120 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +24 -3
- package/dist/extensions/pi-subagents/test/unit/advertised-agent-prompt.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/unit/advertised-agent-refresh.test.ts +163 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -31
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +78 -3
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +2 -8
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +73 -66
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +51 -1
- package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +49 -1
- package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +9 -8
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +10 -7
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +48 -6
- package/dist/extensions/pi-subagents/test/unit/child-runtime-config.test.ts +90 -0
- package/dist/extensions/pi-subagents/test/unit/{pi-args-permission-system.test.ts → child-tool-plan-permission-system.test.ts} +48 -86
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +18 -18
- package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +9 -9
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +8 -7
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +225 -65
- package/dist/extensions/pi-subagents/test/unit/extension-bindings.test.ts +17 -22
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +11 -1
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +47 -3
- package/dist/extensions/pi-subagents/test/unit/foreign-workflow-steering.test.ts +74 -0
- package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +23 -29
- package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +96 -4
- package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +4 -4
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +116 -55
- package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +58 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +104 -3
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/mission-store.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +102 -51
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +196 -6
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +181 -38
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +62 -70
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +58 -53
- package/dist/extensions/pi-subagents/test/unit/notify-diagnostics.test.ts +42 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +84 -0
- package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +13 -5
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +21 -5
- package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +5 -4
- package/dist/extensions/pi-subagents/test/unit/permissions.test.ts +2 -6
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +28 -2
- package/dist/extensions/pi-subagents/test/unit/pi-web-session-liveness.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +38 -1
- package/dist/extensions/pi-subagents/test/unit/prompt-audit-optional-peers.test.ts +43 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +24 -2
- package/dist/extensions/pi-subagents/test/unit/readonly-drain-observation.test.ts +174 -0
- package/dist/extensions/pi-subagents/test/unit/readonly-model-continuation.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/readonly-session-evidence.test.ts +1329 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +29 -80
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +11 -8
- package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/retained-nested-route-tracker.test.ts +175 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +38 -12
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +2 -5
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +89 -1
- package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +2 -16
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +27 -3
- package/dist/extensions/pi-subagents/test/unit/scheduled-supervisor-demand.test.ts +215 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +15 -11
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +237 -1
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +2 -5
- package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +14 -1
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +130 -739
- package/dist/extensions/pi-subagents/test/unit/supervisor-ask-registration.test.ts +682 -0
- package/dist/extensions/pi-subagents/test/unit/supervisor-ui.test.ts +137 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +1 -11
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +23 -3
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +8 -1
- package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +19 -1
- package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +72 -4
- package/dist/extensions/pi-subagents/test/unit/workflow-checklist.test.ts +17 -3
- package/dist/extensions/pi-subagents/test/unit/workflow-child-summary.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +12 -2
- package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +46 -6
- package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +3 -3
- package/dist/extensions/pi-subagents/test/unit/workflow-resources.test.ts +133 -1
- package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +53 -2
- package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +222 -14
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +526 -85
- package/dist/extensions/pi-web-agent/src/backends/config.ts +1 -0
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +5 -3
- package/dist/extensions/pi-web-agent/src/search/tokenin.ts +7 -3
- package/dist/extensions/pi-web-agent/src/types.ts +1 -1
- package/dist/extensions/pi-zentui/README.md +17 -0
- package/dist/extensions/pi-zentui/assets/screenshots/footer-status-hyperlink.png +0 -0
- package/dist/extensions/pi-zentui/docs/configuration.md +71 -2
- package/dist/extensions/pi-zentui/extensions/zentui/config.ts +11 -0
- package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +14 -0
- package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +29 -13
- package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +5 -4
- package/dist/extensions/pi-zentui/extensions/zentui/footer-text.ts +11 -0
- package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +11 -10
- package/dist/extensions/pi-zentui/extensions/zentui/index.ts +77 -3
- package/dist/extensions/pi-zentui/extensions/zentui/presets.ts +77 -0
- package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +103 -2
- package/dist/extensions/pi-zentui/extensions/zentui/working-line-extension-segments.ts +115 -0
- package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +87 -5
- package/dist/extensions/pi-zentui/package-lock.json +277 -517
- package/dist/extensions/pi-zentui/package.json +7 -4
- package/dist/extensions/pi-zentui/test/config-presets.test.ts +238 -0
- package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +24 -1
- package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +570 -2
- package/dist/extensions/pi-zentui/test/extension-status.test.ts +90 -0
- package/dist/extensions/pi-zentui/test/footer-hyperlink.test.ts +243 -0
- package/dist/extensions/pi-zentui/test/package-contents.mjs +1 -0
- package/dist/extensions/pi-zentui/test/presets.test.ts +121 -0
- package/dist/extensions/pi-zentui/test/settings-command.test.ts +205 -1
- package/dist/extensions/pi-zentui/test/working-line-extension-segments.test.ts +139 -0
- package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +237 -3
- package/dist/extensions/pi-zentui/test/working-line.test.ts +94 -2
- package/dist/index.d.ts +1 -1
- package/dist/main.js +4 -1
- package/dist/modes/interactive/chat-viewport.d.ts +19 -0
- package/dist/modes/interactive/chat-viewport.js +27 -0
- package/dist/modes/interactive/components/assistant-message.d.ts +6 -2
- package/dist/modes/interactive/components/assistant-message.js +30 -13
- package/dist/modes/interactive/components/custom-editor.d.ts +10 -1
- package/dist/modes/interactive/components/custom-editor.js +39 -1
- package/dist/modes/interactive/components/first-time-setup.js +1 -1
- package/dist/modes/interactive/components/index.d.ts +1 -1
- package/dist/modes/interactive/components/markdown-transform.d.ts +2 -0
- package/dist/modes/interactive/components/markdown-transform.js +18 -0
- package/dist/modes/interactive/components/model-selector.js +19 -17
- package/dist/modes/interactive/components/scoped-models-selector.js +21 -23
- package/dist/modes/interactive/components/settings-selector.d.ts +7 -1
- package/dist/modes/interactive/components/settings-selector.js +128 -20
- package/dist/modes/interactive/components/settings-submenu.d.ts +71 -0
- package/dist/modes/interactive/components/settings-submenu.js +164 -0
- package/dist/modes/interactive/components/status-indicator.d.ts +3 -1
- package/dist/modes/interactive/components/status-indicator.js +10 -3
- package/dist/modes/interactive/components/thinking-selector.d.ts +15 -3
- package/dist/modes/interactive/components/thinking-selector.js +75 -18
- package/dist/modes/interactive/components/tool-execution.d.ts +21 -4
- package/dist/modes/interactive/components/tool-execution.js +35 -33
- package/dist/modes/interactive/components/trust-selector.js +2 -2
- package/dist/modes/interactive/interactive-mode.d.ts +13 -15
- package/dist/modes/interactive/interactive-mode.js +144 -80
- package/dist/modes/interactive/session-share.d.ts +15 -0
- package/dist/modes/interactive/session-share.js +183 -0
- package/dist/modes/interactive/theme/dark.json +2 -1
- package/dist/modes/interactive/theme/light.json +2 -1
- package/dist/modes/interactive/theme/theme-controller.d.ts +1 -0
- package/dist/modes/interactive/theme/theme-controller.js +5 -0
- package/dist/modes/interactive/theme/theme-json.d.ts +83 -0
- package/dist/modes/interactive/theme/theme-json.js +129 -0
- package/dist/modes/interactive/theme/theme-schema.json +9 -1
- package/dist/modes/interactive/theme/theme.d.ts +14 -5
- package/dist/modes/interactive/theme/theme.js +18 -119
- package/dist/modes/interactive/tui-renderer.d.ts +20 -0
- package/dist/modes/interactive/tui-renderer.js +65 -0
- package/dist/utils/exif-orientation.js +2 -3
- package/dist/utils/management-http.d.ts +24 -0
- package/dist/utils/management-http.js +53 -0
- package/dist/utils/text.d.ts +7 -0
- package/dist/utils/text.js +8 -0
- package/dist/utils/tools-manager.d.ts +1 -0
- package/dist/utils/tools-manager.js +43 -21
- package/docs/docs.json +0 -4
- package/package.json +4 -4
- package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +0 -415
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +0 -1059
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +0 -116
- package/dist/extensions/pi-subagents/src/shared/post-exit-stdio-guard.ts +0 -85
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +0 -224
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +0 -6986
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +0 -447
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +0 -185
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +0 -311
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +0 -84
- package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +0 -202
- package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +0 -101
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +0 -2347
- package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +0 -104
- package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +0 -49
- package/dist/skills/imagegen-frontend-mobile/SKILL.md +0 -1465
- package/dist/skills/imagegen-frontend-web/SKILL.md +0 -987
- package/dist/skills/implanger/SKILL.md +0 -70
- package/dist/skills/output/SKILL.md +0 -49
- package/dist/skills/planger/SKILL.md +0 -167
- package/dist/skills/workflow/SKILL.md +0 -95
- package/docs/workflows.md +0 -18
- /package/dist/bun/{register-bedrock.d.ts → runtime-setup.d.ts} +0 -0
|
@@ -0,0 +1,2240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for async (background) agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Tests the async support utilities: jiti availability check,
|
|
5
|
+
* status file reading/caching.
|
|
6
|
+
*
|
|
7
|
+
* Requires pi packages to be importable. Skips gracefully if unavailable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir, resolveMockPiCallArgs } from "../support/helpers.ts";
|
|
15
|
+
import { deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, requestAsyncSteer } from "../../src/runs/background/control-channel.ts";
|
|
16
|
+
import { writeAtomicJson } from "../../src/shared/atomic-json.ts";
|
|
17
|
+
import { SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION } from "../../src/shared/types.ts";
|
|
18
|
+
import type { AsyncResultPayload, AsyncStatusPayload, MockPiCallRecord } from "../support/async-execution-fixture.ts";
|
|
19
|
+
import {
|
|
20
|
+
installAsyncExecutionHooks, waitForMockPiRuntime, mockAssistantMessage,
|
|
21
|
+
available, isAsyncAvailable, executeAsyncSingle, executeAsyncChain, readStatus,
|
|
22
|
+
pruneStatusCacheForAsyncRoot, ASYNC_DIR, RESULTS_DIR, createSubagentExecutor,
|
|
23
|
+
createRepo, waitForAsyncResultFile, waitForAsyncEvent, waitForAsyncState,
|
|
24
|
+
waitForMockPiCall, readMockPiArgs, readMockPiArgsMatching, tempDir, mockPi,
|
|
25
|
+
makeAsyncExecutor, readAsyncPayload,
|
|
26
|
+
} from "../support/async-execution-fixture.ts";
|
|
27
|
+
|
|
28
|
+
describe("async execution utilities", { skip: !available ? "pi packages not available" : undefined }, () => {
|
|
29
|
+
installAsyncExecutionHooks();
|
|
30
|
+
|
|
31
|
+
it("keeps named output references literal in async single tasks", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
32
|
+
const task = "Reply with OK. You may reference {outputs.name} if it helps.";
|
|
33
|
+
mockPi.onCall({ output: "OK" });
|
|
34
|
+
const id = `async-single-literal-output-ref-${Date.now().toString(36)}`;
|
|
35
|
+
const result = executeAsyncSingle(id, {
|
|
36
|
+
agent: "worker",
|
|
37
|
+
task,
|
|
38
|
+
agentConfig: makeAgent("worker"),
|
|
39
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
40
|
+
artifactConfig: {
|
|
41
|
+
enabled: false,
|
|
42
|
+
includeInput: false,
|
|
43
|
+
includeOutput: false,
|
|
44
|
+
includeJsonl: false,
|
|
45
|
+
includeMetadata: false,
|
|
46
|
+
cleanupDays: 7,
|
|
47
|
+
},
|
|
48
|
+
shareEnabled: false,
|
|
49
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
50
|
+
maxSubagentDepth: 2,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(result.isError, undefined);
|
|
54
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
55
|
+
assert.match(call.args.at(-1) ?? "", /\{outputs\.name\}/);
|
|
56
|
+
const payload = await readAsyncPayload(id);
|
|
57
|
+
assert.equal(payload.success, true);
|
|
58
|
+
assert.equal(payload.results[0]?.output, "OK");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("spawns the async runner with node when process.execPath is not node", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
62
|
+
const originalExecPath = process.execPath;
|
|
63
|
+
process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi");
|
|
64
|
+
try {
|
|
65
|
+
mockPi.onCall({ output: "non-node exec async done" });
|
|
66
|
+
const id = `async-non-node-exec-${Date.now().toString(36)}`;
|
|
67
|
+
const result = executeAsyncSingle(id, {
|
|
68
|
+
agent: "worker",
|
|
69
|
+
task: "Say non-node exec async done. Do not edit files.",
|
|
70
|
+
agentConfig: makeAgent("worker"),
|
|
71
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
72
|
+
artifactConfig: {
|
|
73
|
+
enabled: false,
|
|
74
|
+
includeInput: false,
|
|
75
|
+
includeOutput: false,
|
|
76
|
+
includeJsonl: false,
|
|
77
|
+
includeMetadata: false,
|
|
78
|
+
cleanupDays: 7,
|
|
79
|
+
},
|
|
80
|
+
shareEnabled: false,
|
|
81
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
82
|
+
maxSubagentDepth: 2,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.equal(result.isError, undefined);
|
|
86
|
+
const resultPath = await waitForAsyncResultFile(id, 30_000);
|
|
87
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
88
|
+
assert.equal(payload.success, true);
|
|
89
|
+
assert.equal(payload.results[0]?.output, "non-node exec async done");
|
|
90
|
+
} finally {
|
|
91
|
+
process.execPath = originalExecPath;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("falls back to PATH node when node-like process.execPath is stale", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
96
|
+
const originalExecPath = process.execPath;
|
|
97
|
+
process.execPath = path.join(tempDir, "deleted-node-install", "bin", process.platform === "win32" ? "node.exe" : "node");
|
|
98
|
+
try {
|
|
99
|
+
mockPi.onCall({ output: "stale node exec async done" });
|
|
100
|
+
const id = `async-stale-node-exec-${Date.now().toString(36)}`;
|
|
101
|
+
const result = executeAsyncSingle(id, {
|
|
102
|
+
agent: "worker",
|
|
103
|
+
task: "Say stale node exec async done. Do not edit files.",
|
|
104
|
+
agentConfig: makeAgent("worker"),
|
|
105
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
106
|
+
artifactConfig: {
|
|
107
|
+
enabled: false,
|
|
108
|
+
includeInput: false,
|
|
109
|
+
includeOutput: false,
|
|
110
|
+
includeJsonl: false,
|
|
111
|
+
includeMetadata: false,
|
|
112
|
+
cleanupDays: 7,
|
|
113
|
+
},
|
|
114
|
+
shareEnabled: false,
|
|
115
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
116
|
+
maxSubagentDepth: 2,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.equal(result.isError, undefined);
|
|
120
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
121
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
122
|
+
assert.equal(payload.success, true);
|
|
123
|
+
assert.equal(payload.results[0]?.output, "stale node exec async done");
|
|
124
|
+
} finally {
|
|
125
|
+
process.execPath = originalExecPath;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("readStatus returns null for missing directory", () => {
|
|
130
|
+
const status = readStatus("/nonexistent/path/abc123");
|
|
131
|
+
assert.equal(status, null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("readStatus parses valid status file", () => {
|
|
135
|
+
const dir = createTempDir();
|
|
136
|
+
try {
|
|
137
|
+
const statusData = {
|
|
138
|
+
runId: "test-123",
|
|
139
|
+
state: "running",
|
|
140
|
+
mode: "single",
|
|
141
|
+
startedAt: Date.now(),
|
|
142
|
+
lastUpdate: Date.now(),
|
|
143
|
+
steps: [{ agent: "test", status: "running" }],
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData));
|
|
146
|
+
|
|
147
|
+
const status = readStatus(dir);
|
|
148
|
+
assert.ok(status, "should parse status");
|
|
149
|
+
assert.equal(status.runId, "test-123");
|
|
150
|
+
assert.equal(status.state, "running");
|
|
151
|
+
assert.equal(status.mode, "single");
|
|
152
|
+
} finally {
|
|
153
|
+
removeTempDir(dir);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("interrupts every active async parallel child", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "cross-process interrupt delivery unreliable on Windows CI" : undefined }, async () => {
|
|
158
|
+
mockPi.onCall({ delay: 5_000, output: "one done" });
|
|
159
|
+
mockPi.onCall({ delay: 5_000, output: "two done" });
|
|
160
|
+
mockPi.onCall({ delay: 5_000, output: "three done" });
|
|
161
|
+
const id = `async-interrupt-parallel-${Date.now().toString(36)}`;
|
|
162
|
+
executeAsyncChain(id, {
|
|
163
|
+
chain: [{
|
|
164
|
+
parallel: [
|
|
165
|
+
{ agent: "one", task: "Wait" },
|
|
166
|
+
{ agent: "two", task: "Wait" },
|
|
167
|
+
{ agent: "three", task: "Wait" },
|
|
168
|
+
],
|
|
169
|
+
concurrency: 3,
|
|
170
|
+
}],
|
|
171
|
+
resultMode: "parallel",
|
|
172
|
+
agents: [makeAgent("one"), makeAgent("two"), makeAgent("three")],
|
|
173
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
174
|
+
artifactConfig: {
|
|
175
|
+
enabled: false,
|
|
176
|
+
includeInput: false,
|
|
177
|
+
includeOutput: false,
|
|
178
|
+
includeJsonl: false,
|
|
179
|
+
includeMetadata: false,
|
|
180
|
+
cleanupDays: 7,
|
|
181
|
+
},
|
|
182
|
+
shareEnabled: false,
|
|
183
|
+
maxSubagentDepth: 2,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await waitForMockPiCall(mockPi, 2, 10_000);
|
|
187
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
188
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
189
|
+
const statusBeforeInterrupt = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload & { pid?: number };
|
|
190
|
+
deliverInterruptRequest({ asyncDir, pid: statusBeforeInterrupt.pid, source: "test" });
|
|
191
|
+
|
|
192
|
+
const resultPath = await waitForAsyncResultFile(id, 30_000);
|
|
193
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
194
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
195
|
+
assert.equal(payload.state, "paused");
|
|
196
|
+
assert.equal(payload.success, false);
|
|
197
|
+
assert.deepEqual(status.steps?.map((step) => step.status), ["paused", "paused", "paused"]);
|
|
198
|
+
assert.equal(mockPi.callCount(), 3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("delivers inbox steer requests to the background child session", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
202
|
+
const release = path.join(tempDir, "steer-release");
|
|
203
|
+
mockPi.onCall({ steps: [{ waitForPath: release, jsonl: [events.assistantMessage("steered result")] }] });
|
|
204
|
+
const id = `async-inbox-steer-${Date.now().toString(36)}`;
|
|
205
|
+
executeAsyncSingle(id, {
|
|
206
|
+
agent: "worker",
|
|
207
|
+
task: "Wait for guidance",
|
|
208
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
209
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-inbox-steer" },
|
|
210
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
211
|
+
shareEnabled: false,
|
|
212
|
+
maxSubagentDepth: 2,
|
|
213
|
+
});
|
|
214
|
+
await waitForMockPiCall(mockPi, 0, 10_000);
|
|
215
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
216
|
+
requestAsyncSteer(asyncDir, { message: "Focus on the tests.", id: "steer-1", ts: Date.now() });
|
|
217
|
+
requestAsyncSteer(asyncDir, { message: "Then update the docs.", id: "steer-2", ts: Date.now() + 1, mode: "follow_up" });
|
|
218
|
+
type SteeringTargets = { steering?: { recent: Array<{ id: string; targets: Array<{ index: number; state: string }> }> } };
|
|
219
|
+
const status = await waitForAsyncState(id, (candidate) => {
|
|
220
|
+
const recent = (candidate as SteeringTargets).steering?.recent ?? [];
|
|
221
|
+
return recent.some((request) => request.id === "steer-1" && request.targets[0]?.state === "delivered")
|
|
222
|
+
&& recent.some((request) => request.id === "steer-2" && request.targets[0]?.state === "queued");
|
|
223
|
+
}) as AsyncStatusPayload & SteeringTargets;
|
|
224
|
+
assert.equal(status.state, "running");
|
|
225
|
+
const steers = fs.readFileSync(path.join(mockPi.dir, "steers.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line) as { text: string; mode: string });
|
|
226
|
+
assert.deepEqual(steers.map((steer) => steer.mode), ["steer", "followUp"]);
|
|
227
|
+
assert.match(steers[0]?.text ?? "", /Mid-run steering from the parent orchestrator:\n\nFocus on the tests\./);
|
|
228
|
+
assert.match(steers[1]?.text ?? "", /Queued follow-up from the parent orchestrator:\n\nThen update the docs\./);
|
|
229
|
+
fs.writeFileSync(release, "go");
|
|
230
|
+
const payload = await readAsyncPayload(id);
|
|
231
|
+
assert.equal(payload.success, true);
|
|
232
|
+
assert.equal(payload.results[0]?.output, "steered result");
|
|
233
|
+
const eventsText = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8");
|
|
234
|
+
assert.match(eventsText, /"type":"subagent\.steer\.delivered"[^\n]*"requestId":"steer-1"/);
|
|
235
|
+
assert.match(eventsText, /"type":"subagent\.steer\.queued"[^\n]*"requestId":"steer-2"/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("journals terminal child status events for running async child stops", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "cross-process stop delivery unreliable on Windows CI" : undefined }, async () => {
|
|
239
|
+
mockPi.onCall({ delay: 5_000, output: "one late" });
|
|
240
|
+
mockPi.onCall({ delay: 250, output: "two done" });
|
|
241
|
+
const id = `async-child-stop-parallel-${Date.now().toString(36)}`;
|
|
242
|
+
executeAsyncChain(id, {
|
|
243
|
+
chain: [{
|
|
244
|
+
parallel: [
|
|
245
|
+
{ agent: "one", task: "Wait" },
|
|
246
|
+
{ agent: "two", task: "Finish" },
|
|
247
|
+
],
|
|
248
|
+
concurrency: 2,
|
|
249
|
+
}],
|
|
250
|
+
resultMode: "parallel",
|
|
251
|
+
agents: [makeAgent("one"), makeAgent("two")],
|
|
252
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
253
|
+
artifactConfig: {
|
|
254
|
+
enabled: false,
|
|
255
|
+
includeInput: false,
|
|
256
|
+
includeOutput: false,
|
|
257
|
+
includeJsonl: false,
|
|
258
|
+
includeMetadata: false,
|
|
259
|
+
cleanupDays: 7,
|
|
260
|
+
},
|
|
261
|
+
shareEnabled: false,
|
|
262
|
+
maxSubagentDepth: 2,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await waitForMockPiCall(mockPi, 1, 10_000);
|
|
266
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
267
|
+
const statusBeforeStop = await waitForAsyncState(id, (candidate) => candidate.steps?.[0]?.status === "running" && typeof candidate.pid === "number");
|
|
268
|
+
deliverStopRequest({ asyncDir, pid: statusBeforeStop.pid, source: "test", targetIndex: 0, childId: "step:0" });
|
|
269
|
+
|
|
270
|
+
await waitForAsyncResultFile(id, 30_000);
|
|
271
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state !== "running");
|
|
272
|
+
assert.equal(status.steps?.[0]?.status, "stopped");
|
|
273
|
+
const childStatusEvents = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8")
|
|
274
|
+
.trim()
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string });
|
|
277
|
+
assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "step:0" && event.status === "stopping"));
|
|
278
|
+
assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "step:0" && event.status === "stopped"));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("marks async parallel runs that exceed timeoutMs as timed out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
282
|
+
mockPi.onCall({ delay: 5_000, output: "one done" });
|
|
283
|
+
mockPi.onCall({ delay: 5_000, output: "two done" });
|
|
284
|
+
// This fixture owns real parallel deadline enforcement; dirty recovery is
|
|
285
|
+
// covered below with mutation ordered before an explicit timeout request.
|
|
286
|
+
const repo = createRepo("pi-subagents-parallel-timeout-");
|
|
287
|
+
try {
|
|
288
|
+
const id = `async-timeout-parallel-${Date.now().toString(36)}`;
|
|
289
|
+
executeAsyncChain(id, {
|
|
290
|
+
chain: [{
|
|
291
|
+
parallel: [
|
|
292
|
+
{ agent: "one", task: "Wait" },
|
|
293
|
+
{ agent: "two", task: "Wait" },
|
|
294
|
+
],
|
|
295
|
+
concurrency: 2,
|
|
296
|
+
}],
|
|
297
|
+
resultMode: "parallel",
|
|
298
|
+
agents: [makeAgent("one"), makeAgent("two")],
|
|
299
|
+
ctx: { pi: { events: { emit() {} } }, cwd: repo, currentSessionId: "session-1" },
|
|
300
|
+
artifactConfig: {
|
|
301
|
+
enabled: false,
|
|
302
|
+
includeInput: false,
|
|
303
|
+
includeOutput: false,
|
|
304
|
+
includeJsonl: false,
|
|
305
|
+
includeMetadata: false,
|
|
306
|
+
cleanupDays: 7,
|
|
307
|
+
},
|
|
308
|
+
shareEnabled: false,
|
|
309
|
+
maxSubagentDepth: 2,
|
|
310
|
+
timeoutMs: 1_500,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const resultPath = await waitForAsyncResultFile(id, 8_000);
|
|
314
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
315
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed", 30_000);
|
|
316
|
+
assert.equal(payload.state, "failed");
|
|
317
|
+
assert.equal(payload.success, false);
|
|
318
|
+
assert.equal(payload.exitCode, 1);
|
|
319
|
+
assert.equal(payload.timeoutMs, 1_500);
|
|
320
|
+
assert.equal(payload.timedOut, true);
|
|
321
|
+
assert.match(payload.summary ?? "", /Subagent timed out after 1500ms\./);
|
|
322
|
+
assert.equal(status.state, "failed");
|
|
323
|
+
assert.equal(status.timeoutMs, 1_500);
|
|
324
|
+
assert.equal(status.timedOut, true);
|
|
325
|
+
assert.match(status.error ?? "", /Subagent timed out after 1500ms\./);
|
|
326
|
+
assert.deepEqual(status.steps?.map((step) => step.status), ["failed", "failed"]);
|
|
327
|
+
assert.deepEqual(status.steps?.map((step) => step.timedOut), [true, true]);
|
|
328
|
+
assert.deepEqual(status.steps?.map((step) => step.error), ["Subagent timed out after 1500ms.", "Subagent timed out after 1500ms."]);
|
|
329
|
+
assert.deepEqual(status.steps?.map((step) => step.timeoutRecovery?.changedFiles), [[], []]);
|
|
330
|
+
assert.deepEqual(payload.results.map((result) => result.timedOut), [true, true]);
|
|
331
|
+
assert.deepEqual(payload.results.map((result) => result.timeoutRecovery?.changedFiles), [[], []]);
|
|
332
|
+
assert.ok(payload.results.every((result) => /Recovery summary:/.test(result.output ?? "")));
|
|
333
|
+
assert.equal(mockPi.callCount(), 2);
|
|
334
|
+
} finally {
|
|
335
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("enforces an agent-level timeout on an async serial child without a composite deadline", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
340
|
+
mockPi.onCall({ delay: 5_000, output: "too late" });
|
|
341
|
+
const id = `async-child-timeout-chain-${Date.now().toString(36)}`;
|
|
342
|
+
executeAsyncChain(id, {
|
|
343
|
+
chain: [{ agent: "slow", task: "Wait" }],
|
|
344
|
+
agents: [makeAgent("slow", { defaultTimeoutMs: 150 })],
|
|
345
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
346
|
+
artifactConfig: {
|
|
347
|
+
enabled: false,
|
|
348
|
+
includeInput: false,
|
|
349
|
+
includeOutput: false,
|
|
350
|
+
includeJsonl: false,
|
|
351
|
+
includeMetadata: false,
|
|
352
|
+
cleanupDays: 7,
|
|
353
|
+
},
|
|
354
|
+
shareEnabled: false,
|
|
355
|
+
maxSubagentDepth: 2,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const payload = await readAsyncPayload(id);
|
|
359
|
+
assert.equal(payload.timeoutMs, undefined, "composite parent must remain unbounded by default");
|
|
360
|
+
assert.equal(payload.state, "failed");
|
|
361
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
362
|
+
assert.equal(payload.results[0]?.error, "Subagent timed out after 150ms.");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("classifies a timed-out dirty child with a missing requested report as recovery-needed", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
366
|
+
const repo = createRepo("pi-subagents-timeout-recovery-");
|
|
367
|
+
const changedPath = path.join(repo, "input.md");
|
|
368
|
+
const partialChange = "partial child change\n";
|
|
369
|
+
mockPi.onCall({
|
|
370
|
+
writeFiles: [{ path: changedPath, content: partialChange }],
|
|
371
|
+
steps: [{ waitForPath: path.join(tempDir, "unreleased-dirty-child") }],
|
|
372
|
+
});
|
|
373
|
+
try {
|
|
374
|
+
const id = `async-timeout-recovery-${Date.now().toString(36)}`;
|
|
375
|
+
const outputPath = path.join(repo, "missing-report.md");
|
|
376
|
+
executeAsyncChain(id, {
|
|
377
|
+
chain: [{ agent: "slow", task: "Write the requested report" }],
|
|
378
|
+
agents: [makeAgent("slow", { output: outputPath, outputMode: "file-only" })],
|
|
379
|
+
ctx: { pi: { events: { emit() {} } }, cwd: repo, currentSessionId: "session-1" },
|
|
380
|
+
artifactConfig: {
|
|
381
|
+
enabled: true,
|
|
382
|
+
includeInput: false,
|
|
383
|
+
includeOutput: true,
|
|
384
|
+
includeJsonl: true,
|
|
385
|
+
includeMetadata: true,
|
|
386
|
+
cleanupDays: 7,
|
|
387
|
+
},
|
|
388
|
+
shareEnabled: false,
|
|
389
|
+
maxSubagentDepth: 2,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// The child writes after its mutation baseline, then stays blocked.
|
|
393
|
+
// Observe that write before requesting timeout so recovery cannot
|
|
394
|
+
// collect evidence before the mutation, regardless of startup speed.
|
|
395
|
+
await waitForAsyncState(id, (candidate) =>
|
|
396
|
+
candidate.steps?.[0]?.status === "running"
|
|
397
|
+
&& fs.readFileSync(changedPath, "utf-8") === partialChange);
|
|
398
|
+
deliverTimeoutRequest({ asyncDir: path.join(ASYNC_DIR, id), source: "test" });
|
|
399
|
+
const payload = await readAsyncPayload(id);
|
|
400
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
401
|
+
const result = payload.results[0];
|
|
402
|
+
const recovery = result?.timeoutRecovery;
|
|
403
|
+
const statusRecovery = status.steps?.[0]?.timeoutRecovery;
|
|
404
|
+
assert.equal(payload.success, false);
|
|
405
|
+
assert.equal(payload.state, "failed");
|
|
406
|
+
assert.equal(payload.timedOut, true);
|
|
407
|
+
assert.equal(result?.timedOut, true);
|
|
408
|
+
assert.equal(result?.success, false);
|
|
409
|
+
assert.equal(fs.existsSync(outputPath), false);
|
|
410
|
+
assert.deepEqual(result?.timeoutRecovery?.changedFiles, ["input.md"]);
|
|
411
|
+
assert.equal(recovery?.recoveryNeeded, true);
|
|
412
|
+
assert.equal(recovery?.reason, "timed-out-with-dirty-worktree");
|
|
413
|
+
assert.equal(recovery?.reportStatus, "missing");
|
|
414
|
+
assert.match(result?.timeoutRecovery?.message ?? "", /changed tracked files: input\.md/);
|
|
415
|
+
assert.match(recovery?.message ?? "", /requested report: missing/i);
|
|
416
|
+
assert.match(result?.output ?? "", /review (?:the )?diff and artifacts before resuming.*dependent stages/i);
|
|
417
|
+
assert.match(result?.output ?? "", /Recovery summary:/);
|
|
418
|
+
assert.match(result?.output ?? "", /Warning: Inspect partial changes before retrying/);
|
|
419
|
+
assert.equal(status.state, "failed");
|
|
420
|
+
assert.equal(status.timedOut, true);
|
|
421
|
+
assert.equal(status.steps?.[0]?.status, "failed");
|
|
422
|
+
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
423
|
+
assert.deepEqual(statusRecovery?.changedFiles, ["input.md"]);
|
|
424
|
+
assert.equal(statusRecovery?.recoveryNeeded, true);
|
|
425
|
+
assert.equal(statusRecovery?.reportStatus, "missing");
|
|
426
|
+
} finally {
|
|
427
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("kills a wedged tool at the per-tool timeout with a tool-specific error before the run-level timeout", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
432
|
+
// Wedge: tool_execution_start fires, then the mock holds (long delay) so
|
|
433
|
+
// tool_execution_end never arrives — output would keep flowing in a real
|
|
434
|
+
// wedge, which is exactly why a run-level stall detector can't see it.
|
|
435
|
+
mockPi.onCall({
|
|
436
|
+
steps: [
|
|
437
|
+
{ jsonl: [events.toolStart("bash")] },
|
|
438
|
+
{ delay: 30_000 }, // hold far past the per-tool budget; no tool_execution_end
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
const id = `async-tool-timeout-${Date.now().toString(36)}`;
|
|
442
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "1000";
|
|
443
|
+
try {
|
|
444
|
+
executeAsyncChain(id, {
|
|
445
|
+
chain: [{ agent: "one", task: "Wait" }],
|
|
446
|
+
agents: [makeAgent("one")],
|
|
447
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
448
|
+
artifactConfig: {
|
|
449
|
+
enabled: false,
|
|
450
|
+
includeInput: false,
|
|
451
|
+
includeOutput: false,
|
|
452
|
+
includeJsonl: false,
|
|
453
|
+
includeMetadata: false,
|
|
454
|
+
cleanupDays: 7,
|
|
455
|
+
},
|
|
456
|
+
shareEnabled: false,
|
|
457
|
+
maxSubagentDepth: 2,
|
|
458
|
+
timeoutMs: 8_000, // run-level budget is longer; the per-tool timer must fire first
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const payload = await readAsyncPayload(id);
|
|
462
|
+
assert.equal(payload.state, "failed");
|
|
463
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
464
|
+
assert.match(payload.results[0]?.error ?? "", /Tool 'bash' exceeded its timeout of 1000ms\./);
|
|
465
|
+
} finally {
|
|
466
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("background keeps a terminal answer authoritative over an earlier tool timeout", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
471
|
+
mockPi.onCall({
|
|
472
|
+
steps: [
|
|
473
|
+
{ jsonl: [events.toolStart("bash")] },
|
|
474
|
+
{ delay: 50, jsonl: [events.assistantMessage("Done")] },
|
|
475
|
+
],
|
|
476
|
+
keepAliveAfterFinalMessageMs: 1_500,
|
|
477
|
+
});
|
|
478
|
+
const id = `async-terminal-tool-timeout-${Date.now().toString(36)}`;
|
|
479
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "600";
|
|
480
|
+
try {
|
|
481
|
+
executeAsyncChain(id, {
|
|
482
|
+
chain: [{ agent: "one", task: "Do work" }],
|
|
483
|
+
agents: [makeAgent("one")],
|
|
484
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
485
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
486
|
+
shareEnabled: false,
|
|
487
|
+
maxSubagentDepth: 2,
|
|
488
|
+
timeoutMs: 5_000,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const payload = await readAsyncPayload(id);
|
|
492
|
+
assert.equal(payload.success, true);
|
|
493
|
+
assert.equal(payload.results[0]?.timedOut, undefined);
|
|
494
|
+
assert.equal(payload.results[0]?.output, "Done");
|
|
495
|
+
} finally {
|
|
496
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("keeps an earlier wedged tool armed when another tool starts and ends", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
501
|
+
mockPi.onCall({
|
|
502
|
+
steps: [
|
|
503
|
+
{ jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: {} }] },
|
|
504
|
+
{ delay: 50, jsonl: [
|
|
505
|
+
{ type: "tool_execution_start", toolCallId: "read-1", toolName: "read", args: { path: "README.md" } },
|
|
506
|
+
{ type: "tool_execution_end", toolCallId: "read-1", toolName: "read" },
|
|
507
|
+
] },
|
|
508
|
+
{ delay: 30_000 },
|
|
509
|
+
],
|
|
510
|
+
});
|
|
511
|
+
const id = `async-tool-timeout-overlap-${Date.now().toString(36)}`;
|
|
512
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "1000";
|
|
513
|
+
try {
|
|
514
|
+
executeAsyncChain(id, {
|
|
515
|
+
chain: [{ agent: "one", task: "Wait" }],
|
|
516
|
+
agents: [makeAgent("one")],
|
|
517
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
518
|
+
artifactConfig: {
|
|
519
|
+
enabled: false,
|
|
520
|
+
includeInput: false,
|
|
521
|
+
includeOutput: false,
|
|
522
|
+
includeJsonl: false,
|
|
523
|
+
includeMetadata: false,
|
|
524
|
+
cleanupDays: 7,
|
|
525
|
+
},
|
|
526
|
+
shareEnabled: false,
|
|
527
|
+
maxSubagentDepth: 2,
|
|
528
|
+
timeoutMs: 8_000,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const payload = await readAsyncPayload(id);
|
|
532
|
+
assert.equal(payload.state, "failed");
|
|
533
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
534
|
+
assert.match(payload.results[0]?.error ?? "", /Tool 'bash' exceeded its timeout of 1000ms\./);
|
|
535
|
+
} finally {
|
|
536
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("lets the shorter run-level deadline win over a per-tool timeout", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
541
|
+
mockPi.onCall({
|
|
542
|
+
steps: [
|
|
543
|
+
{ jsonl: [events.toolStart("bash")] },
|
|
544
|
+
{ delay: 30_000 },
|
|
545
|
+
],
|
|
546
|
+
});
|
|
547
|
+
const id = `async-tool-timeout-run-budget-${Date.now().toString(36)}`;
|
|
548
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "1000";
|
|
549
|
+
try {
|
|
550
|
+
executeAsyncChain(id, {
|
|
551
|
+
chain: [{ agent: "one", task: "Wait" }],
|
|
552
|
+
agents: [makeAgent("one")],
|
|
553
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
554
|
+
artifactConfig: {
|
|
555
|
+
enabled: false,
|
|
556
|
+
includeInput: false,
|
|
557
|
+
includeOutput: false,
|
|
558
|
+
includeJsonl: false,
|
|
559
|
+
includeMetadata: false,
|
|
560
|
+
cleanupDays: 7,
|
|
561
|
+
},
|
|
562
|
+
shareEnabled: false,
|
|
563
|
+
maxSubagentDepth: 2,
|
|
564
|
+
timeoutMs: 300,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const payload = await readAsyncPayload(id);
|
|
568
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
569
|
+
assert.equal(payload.results[0]?.error, "Subagent timed out after 300ms.");
|
|
570
|
+
} finally {
|
|
571
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("does not kill a tool that completes before the per-tool timeout", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
576
|
+
mockPi.onCall({
|
|
577
|
+
jsonl: [
|
|
578
|
+
events.toolStart("bash"),
|
|
579
|
+
events.toolEnd("bash"),
|
|
580
|
+
events.assistantMessage("done"),
|
|
581
|
+
{ type: "agent_end", willRetry: false },
|
|
582
|
+
{ type: "agent_settled" },
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
const id = `async-tool-complete-${Date.now().toString(36)}`;
|
|
586
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "1000";
|
|
587
|
+
try {
|
|
588
|
+
executeAsyncChain(id, {
|
|
589
|
+
chain: [{ agent: "one", task: "Done" }],
|
|
590
|
+
agents: [makeAgent("one")],
|
|
591
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
592
|
+
artifactConfig: {
|
|
593
|
+
enabled: false,
|
|
594
|
+
includeInput: false,
|
|
595
|
+
includeOutput: false,
|
|
596
|
+
includeJsonl: false,
|
|
597
|
+
includeMetadata: false,
|
|
598
|
+
cleanupDays: 7,
|
|
599
|
+
},
|
|
600
|
+
shareEnabled: false,
|
|
601
|
+
maxSubagentDepth: 2,
|
|
602
|
+
timeoutMs: 8_000,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const payload = await readAsyncPayload(id);
|
|
606
|
+
assert.equal(payload.state, "complete");
|
|
607
|
+
assert.notEqual(payload.results[0]?.timedOut, true);
|
|
608
|
+
} finally {
|
|
609
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("does not apply the per-tool timeout to supervisor tools (contact_supervisor/intercom)", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
614
|
+
// contact_supervisor holds ~1.5s — longer than the 1s per-tool budget — but
|
|
615
|
+
// is allowlisted, so the per-tool timer must not fire.
|
|
616
|
+
mockPi.onCall({
|
|
617
|
+
steps: [
|
|
618
|
+
{
|
|
619
|
+
delay: 1500,
|
|
620
|
+
jsonl: [
|
|
621
|
+
events.toolStart("contact_supervisor"),
|
|
622
|
+
events.toolEnd("contact_supervisor"),
|
|
623
|
+
events.assistantMessage("done"),
|
|
624
|
+
{ type: "agent_end", willRetry: false },
|
|
625
|
+
{ type: "agent_settled" },
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
});
|
|
630
|
+
const id = `async-tool-allowlist-${Date.now().toString(36)}`;
|
|
631
|
+
process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS = "1000";
|
|
632
|
+
try {
|
|
633
|
+
executeAsyncChain(id, {
|
|
634
|
+
chain: [{ agent: "one", task: "Ask" }],
|
|
635
|
+
agents: [makeAgent("one")],
|
|
636
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
637
|
+
artifactConfig: {
|
|
638
|
+
enabled: false,
|
|
639
|
+
includeInput: false,
|
|
640
|
+
includeOutput: false,
|
|
641
|
+
includeJsonl: false,
|
|
642
|
+
includeMetadata: false,
|
|
643
|
+
cleanupDays: 7,
|
|
644
|
+
},
|
|
645
|
+
shareEnabled: false,
|
|
646
|
+
maxSubagentDepth: 2,
|
|
647
|
+
timeoutMs: 8_000,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const payload = await readAsyncPayload(id);
|
|
651
|
+
assert.equal(payload.state, "complete");
|
|
652
|
+
assert.notEqual(payload.results[0]?.timedOut, true);
|
|
653
|
+
} finally {
|
|
654
|
+
delete process.env.SELESAI_SUBAGENT_TOOL_TIMEOUT_MS;
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("enforces child timeouts on async parallel tasks without a composite deadline", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
659
|
+
mockPi.onCall({ delay: 5_000, output: "one too late" });
|
|
660
|
+
mockPi.onCall({ delay: 5_000, output: "two too late" });
|
|
661
|
+
const id = `async-child-timeout-parallel-${Date.now().toString(36)}`;
|
|
662
|
+
executeAsyncChain(id, {
|
|
663
|
+
chain: [{
|
|
664
|
+
parallel: [
|
|
665
|
+
{ agent: "slow-one", task: "Wait" },
|
|
666
|
+
{ agent: "slow-two", task: "Wait" },
|
|
667
|
+
],
|
|
668
|
+
concurrency: 2,
|
|
669
|
+
}],
|
|
670
|
+
resultMode: "parallel",
|
|
671
|
+
agents: [
|
|
672
|
+
makeAgent("slow-one", { defaultTimeoutMs: 150 }),
|
|
673
|
+
makeAgent("slow-two", { defaultTimeoutMs: 200 }),
|
|
674
|
+
],
|
|
675
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
676
|
+
artifactConfig: {
|
|
677
|
+
enabled: false,
|
|
678
|
+
includeInput: false,
|
|
679
|
+
includeOutput: false,
|
|
680
|
+
includeJsonl: false,
|
|
681
|
+
includeMetadata: false,
|
|
682
|
+
cleanupDays: 7,
|
|
683
|
+
},
|
|
684
|
+
shareEnabled: false,
|
|
685
|
+
maxSubagentDepth: 2,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const payload = await readAsyncPayload(id);
|
|
689
|
+
assert.equal(payload.timeoutMs, undefined, "composite parent must remain unbounded by default");
|
|
690
|
+
assert.equal(payload.state, "failed");
|
|
691
|
+
assert.deepEqual(payload.results.map((result) => result.timedOut), [true, true]);
|
|
692
|
+
assert.deepEqual(payload.results.map((result) => result.error), ["Subagent timed out after 150ms.", "Subagent timed out after 200ms."]);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("hard-kills async children that ignore timeout SIGTERM", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
696
|
+
mockPi.onCall({ delay: 60_000, ignoreSigterm: true, output: "too late" });
|
|
697
|
+
const id = `async-timeout-hard-kill-${Date.now().toString(36)}`;
|
|
698
|
+
const timeoutMs = process.platform === "win32" ? 5_000 : 1_500;
|
|
699
|
+
const startedAt = Date.now();
|
|
700
|
+
executeAsyncSingle(id, {
|
|
701
|
+
agent: "stubborn",
|
|
702
|
+
task: "Ignore soft termination",
|
|
703
|
+
agentConfig: makeAgent("stubborn", { model: "primary-model", fallbackModels: ["fallback-model"] }),
|
|
704
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
705
|
+
artifactConfig: {
|
|
706
|
+
enabled: false,
|
|
707
|
+
includeInput: false,
|
|
708
|
+
includeOutput: false,
|
|
709
|
+
includeJsonl: false,
|
|
710
|
+
includeMetadata: false,
|
|
711
|
+
cleanupDays: 7,
|
|
712
|
+
},
|
|
713
|
+
shareEnabled: false,
|
|
714
|
+
maxSubagentDepth: 2,
|
|
715
|
+
timeoutMs,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
await waitForMockPiCall(mockPi, 0, 10_000);
|
|
719
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
720
|
+
const elapsedMs = Date.now() - startedAt;
|
|
721
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
722
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
723
|
+
assert.equal(payload.state, "failed");
|
|
724
|
+
assert.equal(payload.timedOut, true);
|
|
725
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
726
|
+
assert.equal(payload.results[0]?.error, `Subagent timed out after ${timeoutMs}ms.`);
|
|
727
|
+
assert.equal(status.timedOut, true);
|
|
728
|
+
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
729
|
+
assert.ok(elapsedMs < timeoutMs + 4_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`);
|
|
730
|
+
assert.equal(mockPi.callCount(), 1);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("cancels async acceptance verification when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
734
|
+
mockPi.onCall({ output: "implementation complete" });
|
|
735
|
+
const id = `async-timeout-acceptance-${Date.now().toString(36)}`;
|
|
736
|
+
const timeoutMs = 1_000;
|
|
737
|
+
const startedAt = Date.now();
|
|
738
|
+
executeAsyncSingle(id, {
|
|
739
|
+
agent: "worker",
|
|
740
|
+
task: "Implement with verified acceptance",
|
|
741
|
+
agentConfig: makeAgent("worker"),
|
|
742
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
743
|
+
artifactConfig: {
|
|
744
|
+
enabled: true,
|
|
745
|
+
includeInput: false,
|
|
746
|
+
includeOutput: false,
|
|
747
|
+
includeJsonl: false,
|
|
748
|
+
includeMetadata: true,
|
|
749
|
+
cleanupDays: 7,
|
|
750
|
+
},
|
|
751
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
752
|
+
shareEnabled: false,
|
|
753
|
+
maxSubagentDepth: 2,
|
|
754
|
+
timeoutMs,
|
|
755
|
+
acceptance: {
|
|
756
|
+
level: "verified",
|
|
757
|
+
verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 30000)"`, timeoutMs: 60_000 }],
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const resultPath = await waitForAsyncResultFile(id, 5_000);
|
|
762
|
+
const elapsedMs = Date.now() - startedAt;
|
|
763
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
764
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
765
|
+
assert.equal(payload.state, "failed");
|
|
766
|
+
assert.equal(payload.timedOut, true);
|
|
767
|
+
assert.equal(payload.results[0]?.timedOut, true);
|
|
768
|
+
assert.equal(payload.results[0]?.acceptance?.status, "rejected");
|
|
769
|
+
assert.equal(payload.results[0]?.acceptance?.runtimeChecks?.[0]?.id, "timeout");
|
|
770
|
+
assert.equal(status.steps?.[0]?.timedOut, true);
|
|
771
|
+
const metadataPath = payload.results[0]?.artifactPaths?.metadataPath;
|
|
772
|
+
assert.ok(metadataPath);
|
|
773
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as { acceptance?: { status?: string; runtimeChecks?: Array<{ id?: string }> } };
|
|
774
|
+
assert.equal(metadata.acceptance?.status, "rejected");
|
|
775
|
+
assert.equal(metadata.acceptance?.runtimeChecks?.[0]?.id, "timeout");
|
|
776
|
+
assert.ok(elapsedMs < timeoutMs + 4_000, `timeout should cancel acceptance verification well before the verify command completes, elapsed ${elapsedMs}ms`);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("async launch messages tell the parent not to sleep-poll", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
780
|
+
const artifactConfig = {
|
|
781
|
+
enabled: false,
|
|
782
|
+
includeInput: false,
|
|
783
|
+
includeOutput: false,
|
|
784
|
+
includeJsonl: false,
|
|
785
|
+
includeMetadata: false,
|
|
786
|
+
cleanupDays: 7,
|
|
787
|
+
};
|
|
788
|
+
const emitted: Array<{ channel: string; data: unknown }> = [];
|
|
789
|
+
const commonParams = {
|
|
790
|
+
ctx: {
|
|
791
|
+
pi: { events: { emit(channel: string, data: unknown) { emitted.push({ channel, data }); } } },
|
|
792
|
+
cwd: tempDir,
|
|
793
|
+
currentSessionId: "session-1",
|
|
794
|
+
},
|
|
795
|
+
artifactConfig,
|
|
796
|
+
shareEnabled: false,
|
|
797
|
+
maxSubagentDepth: 2,
|
|
798
|
+
};
|
|
799
|
+
const startedEvent = (id: string): { task?: string; goal?: string } => {
|
|
800
|
+
const event = emitted.find((entry) => entry.channel === SUBAGENT_ASYNC_STARTED_EVENT && (entry.data as { id?: string }).id === id);
|
|
801
|
+
assert.ok(event, `missing async-started event for ${id}`);
|
|
802
|
+
return event.data as { task?: string; goal?: string };
|
|
803
|
+
};
|
|
804
|
+
mockPi.onCall({ output: "single done" });
|
|
805
|
+
const singleId = `async-handoff-single-${Date.now().toString(36)}`;
|
|
806
|
+
const wrappedTask = `Fork preamble: ${"execution ".repeat(20)}`;
|
|
807
|
+
const rawGoal = `Caller-facing goal: ${"raw ".repeat(40)}`;
|
|
808
|
+
const singleResult = executeAsyncSingle(singleId, {
|
|
809
|
+
agent: "worker",
|
|
810
|
+
task: wrappedTask,
|
|
811
|
+
goal: rawGoal,
|
|
812
|
+
agentConfig: makeAgent("worker"),
|
|
813
|
+
...commonParams,
|
|
814
|
+
});
|
|
815
|
+
assert.match(singleResult.content[0]?.text ?? "", /Async: worker \[/);
|
|
816
|
+
assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
817
|
+
assert.match(singleResult.content[0]?.text ?? "", /Use bg_wait only/i);
|
|
818
|
+
assert.match(singleResult.content[0]?.text ?? "", /non-interactive run: Pi auto-drains current-session subagent work at agent_end/);
|
|
819
|
+
assert.equal(startedEvent(singleId).task, "[prompt redacted]");
|
|
820
|
+
assert.equal(startedEvent(singleId).goal, "[prompt redacted]");
|
|
821
|
+
await waitForAsyncResultFile(singleId, 30_000);
|
|
822
|
+
|
|
823
|
+
mockPi.onCall({ output: "interactive done" });
|
|
824
|
+
const interactiveId = `async-handoff-interactive-${Date.now().toString(36)}`;
|
|
825
|
+
const interactiveResult = executeAsyncSingle(interactiveId, {
|
|
826
|
+
agent: "worker",
|
|
827
|
+
task: "Interactive handoff",
|
|
828
|
+
agentConfig: makeAgent("worker"),
|
|
829
|
+
...commonParams,
|
|
830
|
+
ctx: { ...commonParams.ctx, interactive: true },
|
|
831
|
+
});
|
|
832
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /interactive session/);
|
|
833
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /return control to the user/);
|
|
834
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /does not need a wait call/);
|
|
835
|
+
assert.match(interactiveResult.content[0]?.text ?? "", /native completion notification/);
|
|
836
|
+
assert.doesNotMatch(interactiveResult.content[0]?.text ?? "", /bg_wait\(\{ id:/);
|
|
837
|
+
assert.doesNotMatch(interactiveResult.content[0]?.text ?? "", /auto-drain/);
|
|
838
|
+
await waitForAsyncResultFile(interactiveId, 30_000);
|
|
839
|
+
|
|
840
|
+
mockPi.onCall({ output: "parallel one done" });
|
|
841
|
+
mockPi.onCall({ output: "parallel two done" });
|
|
842
|
+
const parallelId = `async-handoff-parallel-${Date.now().toString(36)}`;
|
|
843
|
+
const parallelResult = executeAsyncChain(parallelId, {
|
|
844
|
+
chain: [{ parallel: [{ agent: "worker", task: "Do one" }, { agent: "reviewer", task: "Do two" }] }],
|
|
845
|
+
resultMode: "parallel",
|
|
846
|
+
agents: [makeAgent("worker"), makeAgent("reviewer")],
|
|
847
|
+
...commonParams,
|
|
848
|
+
});
|
|
849
|
+
assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/);
|
|
850
|
+
assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
851
|
+
assert.match(parallelResult.content[0]?.text ?? "", /Use bg_wait only/i);
|
|
852
|
+
assert.equal(startedEvent(parallelId).goal, "[prompt redacted]");
|
|
853
|
+
const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000);
|
|
854
|
+
const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string };
|
|
855
|
+
assert.equal(parallelPayload.mode, "parallel");
|
|
856
|
+
assert.equal(parallelPayload.agent, "parallel:worker+reviewer");
|
|
857
|
+
|
|
858
|
+
mockPi.onCall({ output: "chain done" });
|
|
859
|
+
const chainId = `async-handoff-chain-${Date.now().toString(36)}`;
|
|
860
|
+
const chainGoal = `Coordinate the complete workflow ${"goal ".repeat(30)}`;
|
|
861
|
+
const chainChildTask = `Do chained work ${"child ".repeat(15)}`;
|
|
862
|
+
const chainResult = executeAsyncChain(chainId, {
|
|
863
|
+
task: chainGoal,
|
|
864
|
+
chain: [{ agent: "worker", task: chainChildTask }],
|
|
865
|
+
agents: [makeAgent("worker")],
|
|
866
|
+
...commonParams,
|
|
867
|
+
});
|
|
868
|
+
assert.match(chainResult.content[0]?.text ?? "", /Async chain:/);
|
|
869
|
+
assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
|
|
870
|
+
const chainEvent = startedEvent(chainId);
|
|
871
|
+
assert.equal(chainEvent.task, "[prompt redacted]");
|
|
872
|
+
assert.equal(chainEvent.goal, "[prompt redacted]");
|
|
873
|
+
await waitForAsyncResultFile(chainId, 10_000);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("fails background chains when requested extension tools are unavailable", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
877
|
+
mockPi.onCall({ output: "Model incorrectly claimed success", missingTools: ["fixture_search"] });
|
|
878
|
+
const id = `async-missing-extension-tool-${Date.now().toString(36)}`;
|
|
879
|
+
|
|
880
|
+
executeAsyncChain(id, {
|
|
881
|
+
chain: [{ agent: "extension-worker", task: "Use fixture search" }],
|
|
882
|
+
agents: [makeAgent("extension-worker", { tools: ["read", "fixture_search"] })],
|
|
883
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
884
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
885
|
+
shareEnabled: false,
|
|
886
|
+
maxSubagentDepth: 2,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
890
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
891
|
+
assert.equal(payload.success, false);
|
|
892
|
+
assert.equal(payload.state, "failed");
|
|
893
|
+
assert.match(payload.results[0]?.error ?? "", /requested unavailable child tools: fixture_search/);
|
|
894
|
+
assert.match(payload.results[0]?.error ?? "", /subagentOnlyExtensions/);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it("records blocked mutation effects when background implementation tools are missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
898
|
+
mockPi.onCall({ output: "I cannot edit because fixture_search is missing", missingTools: ["fixture_search"] });
|
|
899
|
+
const id = `async-missing-implementation-tool-${Date.now().toString(36)}`;
|
|
900
|
+
|
|
901
|
+
executeAsyncSingle(id, {
|
|
902
|
+
agent: "worker",
|
|
903
|
+
task: "Implement the requested source fix",
|
|
904
|
+
agentConfig: makeAgent("worker", { tools: ["read", "fixture_search"] }),
|
|
905
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
906
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
907
|
+
shareEnabled: false,
|
|
908
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
909
|
+
maxSubagentDepth: 2,
|
|
910
|
+
acceptance: false,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
914
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
915
|
+
const statusPayload = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
916
|
+
|
|
917
|
+
assert.equal(payload.success, false);
|
|
918
|
+
assert.equal(payload.state, "failed");
|
|
919
|
+
assert.match(payload.results[0]?.error ?? "", /requested unavailable child tools: fixture_search/);
|
|
920
|
+
assert.doesNotMatch(payload.results[0]?.error ?? "", /completed without making edits/);
|
|
921
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.status, "blocked");
|
|
922
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.expected, true);
|
|
923
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.attempted, false);
|
|
924
|
+
assert.match(payload.results[0]?.effects?.fileMutation?.message ?? "", /requested unavailable child tools: fixture_search/);
|
|
925
|
+
assert.equal(statusPayload.steps?.[0]?.effects?.fileMutation?.status, "blocked");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("applies agent acceptance roles to inferred async acceptance", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
929
|
+
mockPi.onCall({ output: "exploration complete" });
|
|
930
|
+
const executor = makeAsyncExecutor([makeAgent("worker", { acceptanceRole: "read-only" })]);
|
|
931
|
+
|
|
932
|
+
const result = await executor.execute(
|
|
933
|
+
"async-agent-acceptance-role",
|
|
934
|
+
{ agent: "worker", task: "Explore the authentication flow", async: true, clarify: false },
|
|
935
|
+
new AbortController().signal,
|
|
936
|
+
undefined,
|
|
937
|
+
makeMinimalCtx(tempDir),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const asyncId = result.details?.asyncId;
|
|
941
|
+
assert.ok(asyncId, "expected asyncId");
|
|
942
|
+
const payload = await readAsyncPayload(asyncId);
|
|
943
|
+
assert.equal(payload.results[0]?.acceptance?.effectiveAcceptance.level, "none");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
it("infers async chain acceptance after expanding top-level task templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
949
|
+
mockPi.onCall({ output: "patched" });
|
|
950
|
+
mockPi.onCall({ output: "reviewed" });
|
|
951
|
+
|
|
952
|
+
const patchId = `async-role-task-template-patch-${Date.now().toString(36)}`;
|
|
953
|
+
executeAsyncChain(patchId, {
|
|
954
|
+
task: "Patch src/auth.ts",
|
|
955
|
+
chain: [{ agent: "explorer", task: "{task}" }],
|
|
956
|
+
agents: [makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
957
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-patch" },
|
|
958
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
959
|
+
shareEnabled: false,
|
|
960
|
+
maxSubagentDepth: 2,
|
|
961
|
+
});
|
|
962
|
+
const patchPayload = await readAsyncPayload(patchId);
|
|
963
|
+
assert.equal(patchPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "checked");
|
|
964
|
+
|
|
965
|
+
const reviewId = `async-role-task-template-review-${Date.now().toString(36)}`;
|
|
966
|
+
executeAsyncChain(reviewId, {
|
|
967
|
+
task: "Review only; do not edit files",
|
|
968
|
+
chain: [{ agent: "implementer", task: "{task}" }],
|
|
969
|
+
agents: [makeAgent("implementer", { acceptanceRole: "writer" })],
|
|
970
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-review" },
|
|
971
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
972
|
+
shareEnabled: false,
|
|
973
|
+
maxSubagentDepth: 2,
|
|
974
|
+
});
|
|
975
|
+
const reviewPayload = await readAsyncPayload(reviewId);
|
|
976
|
+
assert.equal(reviewPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "none");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
it("async chain static parallel namespaces inherited default outputs", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
988
|
+
mockPi.onCall({ matchArgIncludes: "Write first", output: "chain first report" });
|
|
989
|
+
mockPi.onCall({ matchArgIncludes: "Write second", output: "chain second report" });
|
|
990
|
+
const id = `async-chain-parallel-output-${Date.now().toString(36)}`;
|
|
991
|
+
const launch = executeAsyncChain(id, {
|
|
992
|
+
chain: [{ parallel: [{ agent: "worker", task: "Write first" }, { agent: "worker", task: "Write second" }] }],
|
|
993
|
+
agents: [makeAgent("worker", { output: "context.md" })],
|
|
994
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-chain-output" },
|
|
995
|
+
artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
996
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
997
|
+
shareEnabled: false,
|
|
998
|
+
maxSubagentDepth: 2,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
assert.equal(launch.isError, undefined);
|
|
1002
|
+
const payload = await readAsyncPayload(id);
|
|
1003
|
+
assert.equal(payload.success, true);
|
|
1004
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
|
|
1005
|
+
const authoritativePaths = [
|
|
1006
|
+
path.join(outputDir, "parallel-0", "0-worker", "context.md"),
|
|
1007
|
+
path.join(outputDir, "parallel-0", "1-worker", "context.md"),
|
|
1008
|
+
];
|
|
1009
|
+
assert.equal(fs.readFileSync(authoritativePaths[0]!, "utf-8"), "chain first report");
|
|
1010
|
+
assert.equal(fs.readFileSync(authoritativePaths[1]!, "utf-8"), "chain second report");
|
|
1011
|
+
const artifactPaths = payload.results.map((result) => result.artifactPaths?.outputPath);
|
|
1012
|
+
assert.ok(artifactPaths[0] && artifactPaths[1]);
|
|
1013
|
+
assert.notEqual(artifactPaths[0], artifactPaths[1]);
|
|
1014
|
+
assert.equal(fs.readFileSync(artifactPaths[0], "utf-8"), "chain first report");
|
|
1015
|
+
assert.equal(fs.readFileSync(artifactPaths[1], "utf-8"), "chain second report");
|
|
1016
|
+
const calls = fs.readdirSync(mockPi.dir).filter((name) => name.startsWith("call-")).sort();
|
|
1017
|
+
const taskArgs = calls.map((name) => {
|
|
1018
|
+
const call = JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord;
|
|
1019
|
+
return resolveMockPiCallArgs(call).at(-1) ?? "";
|
|
1020
|
+
});
|
|
1021
|
+
assert.ok(taskArgs.find((task) => task.includes("Write first"))?.includes(path.join("parallel-0", "0-worker", "context.md")));
|
|
1022
|
+
assert.ok(taskArgs.find((task) => task.includes("Write second"))?.includes(path.join("parallel-0", "1-worker", "context.md")));
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("async single preserves checked evidence while independent review is pending", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1026
|
+
mockPi.onCall({
|
|
1027
|
+
output: [
|
|
1028
|
+
"implemented",
|
|
1029
|
+
"```acceptance-report",
|
|
1030
|
+
JSON.stringify({
|
|
1031
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }],
|
|
1032
|
+
changedFiles: ["src/file.ts"],
|
|
1033
|
+
testsAddedOrUpdated: ["test/file.test.ts"],
|
|
1034
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
1035
|
+
validationOutput: ["passed"],
|
|
1036
|
+
residualRisks: [],
|
|
1037
|
+
noStagedFiles: true,
|
|
1038
|
+
notes: "done",
|
|
1039
|
+
}),
|
|
1040
|
+
"```",
|
|
1041
|
+
].join("\n"),
|
|
1042
|
+
});
|
|
1043
|
+
const artifactConfig = {
|
|
1044
|
+
enabled: false,
|
|
1045
|
+
includeInput: false,
|
|
1046
|
+
includeOutput: false,
|
|
1047
|
+
includeJsonl: false,
|
|
1048
|
+
includeMetadata: false,
|
|
1049
|
+
cleanupDays: 7,
|
|
1050
|
+
};
|
|
1051
|
+
const id = `async-acceptance-${Date.now().toString(36)}`;
|
|
1052
|
+
executeAsyncSingle(id, {
|
|
1053
|
+
agent: "worker",
|
|
1054
|
+
task: "Implement acceptance-covered fix",
|
|
1055
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
1056
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-acceptance" },
|
|
1057
|
+
artifactConfig,
|
|
1058
|
+
shareEnabled: false,
|
|
1059
|
+
maxSubagentDepth: 2,
|
|
1060
|
+
acceptance: { level: "checked", criteria: ["Patch bug"], review: { agent: "reviewer", required: true } },
|
|
1061
|
+
});
|
|
1062
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1063
|
+
const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1064
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1065
|
+
|
|
1066
|
+
assert.equal(result.success, true);
|
|
1067
|
+
assert.equal(result.results[0]?.acceptance?.status, "review-required");
|
|
1068
|
+
assert.equal(result.results[0]?.acceptance?.evidenceStatus, "checked");
|
|
1069
|
+
assert.ok(result.results[0]?.acceptance?.childReport);
|
|
1070
|
+
assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "review-required");
|
|
1071
|
+
assert.equal(status.steps?.[0]?.acceptance?.status, "review-required");
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
it("async chains reject malformed named output references before spawning", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1077
|
+
const id = `async-malformed-output-ref-${Date.now().toString(36)}`;
|
|
1078
|
+
const result = executeAsyncChain(id, {
|
|
1079
|
+
chain: [{ agent: "consumer", task: "Use {outputs.bad-name}" }],
|
|
1080
|
+
agents: [makeAgent("consumer")],
|
|
1081
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-malformed" },
|
|
1082
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1083
|
+
shareEnabled: false,
|
|
1084
|
+
maxSubagentDepth: 2,
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
assert.equal(result.isError, true);
|
|
1088
|
+
assert.match(result.content[0]?.text ?? "", /Invalid chain output reference '\{outputs\.bad-name\}'/);
|
|
1089
|
+
assert.equal(mockPi.callCount(), 0);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it("async chains persist structured outputs, named outputs, and graph labels", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1093
|
+
const schema = {
|
|
1094
|
+
type: "object",
|
|
1095
|
+
required: ["value"],
|
|
1096
|
+
properties: { value: { type: "string" } },
|
|
1097
|
+
};
|
|
1098
|
+
mockPi.onCall({ structuredOutput: { value: "Alpha structured" } });
|
|
1099
|
+
mockPi.onCall({ output: "used named output" });
|
|
1100
|
+
const id = `async-structured-chain-${Date.now().toString(36)}`;
|
|
1101
|
+
const result = executeAsyncChain(id, {
|
|
1102
|
+
chain: [
|
|
1103
|
+
{
|
|
1104
|
+
agent: "producer",
|
|
1105
|
+
task: "Produce data",
|
|
1106
|
+
phase: "Collect",
|
|
1107
|
+
label: "Produce structured data",
|
|
1108
|
+
as: "data",
|
|
1109
|
+
outputSchema: schema,
|
|
1110
|
+
},
|
|
1111
|
+
{ agent: "consumer", task: "Use {outputs.data}", phase: "Use", label: "Consume data" },
|
|
1112
|
+
],
|
|
1113
|
+
agents: [makeAgent("producer"), makeAgent("consumer")],
|
|
1114
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-structured" },
|
|
1115
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1116
|
+
shareEnabled: false,
|
|
1117
|
+
maxSubagentDepth: 2,
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
assert.ok(!result.isError);
|
|
1121
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1122
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1123
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "complete");
|
|
1124
|
+
assert.deepEqual(payload.results[0]?.structuredOutput, { value: "Alpha structured" });
|
|
1125
|
+
assert.deepEqual(payload.outputs?.data?.structured, { value: "Alpha structured" });
|
|
1126
|
+
assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Alpha structured/);
|
|
1127
|
+
assert.equal(status.steps?.[0]?.label, "Produce structured data");
|
|
1128
|
+
assert.equal(status.steps?.[0]?.phase, "Collect");
|
|
1129
|
+
assert.equal(status.steps?.[0]?.outputName, "data");
|
|
1130
|
+
assert.equal(status.steps?.[0]?.structured, true);
|
|
1131
|
+
assert.equal(payload.workflowGraph?.nodes?.[0]?.label, "Produce structured data");
|
|
1132
|
+
assert.equal(payload.workflowGraph?.nodes?.[0]?.outputName, "data");
|
|
1133
|
+
assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed");
|
|
1134
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed");
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("async chains can start parallel, funnel into one step, then fan back out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1138
|
+
mockPi.onCall({ matchArgIncludes: "Scout API", output: "Scout A async findings" });
|
|
1139
|
+
mockPi.onCall({ matchArgIncludes: "Scout UI", output: "Scout B async findings" });
|
|
1140
|
+
mockPi.onCall({ matchArgIncludes: "Synthesize:", output: "Async funnel synthesis" });
|
|
1141
|
+
mockPi.onCall({ matchArgIncludes: "Review funnel A:", output: "Async reviewer A done" });
|
|
1142
|
+
mockPi.onCall({ matchArgIncludes: "Review funnel B:", output: "Async reviewer B done" });
|
|
1143
|
+
const id = `async-parallel-funnel-fanout-${Date.now().toString(36)}`;
|
|
1144
|
+
const result = executeAsyncChain(id, {
|
|
1145
|
+
chain: [
|
|
1146
|
+
{
|
|
1147
|
+
parallel: [
|
|
1148
|
+
{ agent: "scout-a", task: "Scout API" },
|
|
1149
|
+
{ agent: "scout-b", task: "Scout UI" },
|
|
1150
|
+
],
|
|
1151
|
+
concurrency: 2,
|
|
1152
|
+
},
|
|
1153
|
+
{ agent: "synthesizer", task: "Synthesize:\n{previous}" },
|
|
1154
|
+
{
|
|
1155
|
+
parallel: [
|
|
1156
|
+
{ agent: "review-a", task: "Review funnel A:\n{previous}" },
|
|
1157
|
+
{ agent: "review-b", task: "Review funnel B:\n{previous}" },
|
|
1158
|
+
],
|
|
1159
|
+
concurrency: 2,
|
|
1160
|
+
},
|
|
1161
|
+
],
|
|
1162
|
+
agents: [makeAgent("scout-a"), makeAgent("scout-b"), makeAgent("synthesizer"), makeAgent("review-a"), makeAgent("review-b")],
|
|
1163
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-parallel-funnel-fanout" },
|
|
1164
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1165
|
+
shareEnabled: false,
|
|
1166
|
+
maxSubagentDepth: 2,
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
assert.ok(!result.isError, `should launch: ${JSON.stringify(result.content)}`);
|
|
1170
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1171
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1172
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "complete");
|
|
1173
|
+
assert.equal(payload.success, true);
|
|
1174
|
+
assert.deepEqual(payload.results.map((entry) => entry.output), [
|
|
1175
|
+
"Scout A async findings",
|
|
1176
|
+
"Scout B async findings",
|
|
1177
|
+
"Async funnel synthesis",
|
|
1178
|
+
"Async reviewer A done",
|
|
1179
|
+
"Async reviewer B done",
|
|
1180
|
+
]);
|
|
1181
|
+
assert.deepEqual(status.steps?.map((step) => step.status), ["complete", "complete", "complete", "complete", "complete"]);
|
|
1182
|
+
assert.deepEqual(status.parallelGroups, [
|
|
1183
|
+
{ start: 0, count: 2, stepIndex: 0 },
|
|
1184
|
+
{ start: 3, count: 2, stepIndex: 2 },
|
|
1185
|
+
]);
|
|
1186
|
+
const funnelTask = readMockPiArgsMatching(mockPi, "Synthesize:").at(-1) ?? "";
|
|
1187
|
+
assert.match(funnelTask, /=== Parallel Task 1 \(scout-a\) ===/);
|
|
1188
|
+
assert.match(funnelTask, /Scout A async findings/);
|
|
1189
|
+
assert.match(funnelTask, /=== Parallel Task 2 \(scout-b\) ===/);
|
|
1190
|
+
assert.match(funnelTask, /Scout B async findings/);
|
|
1191
|
+
assert.match(readMockPiArgsMatching(mockPi, "Review funnel A:").at(-1) ?? "", /Review funnel A:\nAsync funnel synthesis/);
|
|
1192
|
+
assert.match(readMockPiArgsMatching(mockPi, "Review funnel B:").at(-1) ?? "", /Review funnel B:\nAsync funnel synthesis/);
|
|
1193
|
+
assert.equal(payload.workflowGraph?.nodes?.[0]?.kind, "parallel-group");
|
|
1194
|
+
assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed");
|
|
1195
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "step");
|
|
1196
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed");
|
|
1197
|
+
assert.equal(payload.workflowGraph?.nodes?.[2]?.kind, "parallel-group");
|
|
1198
|
+
assert.equal(payload.workflowGraph?.nodes?.[2]?.status, "completed");
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it("async dynamic status shows a placeholder before materialization", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1202
|
+
mockPi.onCall({ delay: 800, output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1203
|
+
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1204
|
+
mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
|
|
1205
|
+
mockPi.onCall({ output: "used reviews" });
|
|
1206
|
+
const id = `async-dynamic-placeholder-${Date.now().toString(36)}`;
|
|
1207
|
+
const result = executeAsyncChain(id, {
|
|
1208
|
+
chain: [
|
|
1209
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1210
|
+
{
|
|
1211
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1212
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", label: "Review {target.path}", outputSchema: { type: "object" } },
|
|
1213
|
+
collect: { as: "reviews" },
|
|
1214
|
+
concurrency: 1,
|
|
1215
|
+
},
|
|
1216
|
+
{ agent: "consumer", task: "Use {outputs.reviews}" },
|
|
1217
|
+
],
|
|
1218
|
+
agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
|
|
1219
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-placeholder" },
|
|
1220
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1221
|
+
shareEnabled: false,
|
|
1222
|
+
maxSubagentDepth: 2,
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
assert.ok(!result.isError);
|
|
1226
|
+
const statusPath = path.join(ASYNC_DIR, id, "status.json");
|
|
1227
|
+
const deadline = Date.now() + 5_000;
|
|
1228
|
+
let status: AsyncStatusPayload | undefined;
|
|
1229
|
+
while (!status) {
|
|
1230
|
+
if (Date.now() > deadline) assert.fail(`Timed out waiting for async status file: ${statusPath}`);
|
|
1231
|
+
if (fs.existsSync(statusPath)) status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
1232
|
+
else await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1233
|
+
}
|
|
1234
|
+
assert.deepEqual(status.steps?.map((step) => step.agent), ["producer", "expand:reviewer", "consumer"]);
|
|
1235
|
+
assert.equal(status.steps?.[1]?.label, "Review {target.path}");
|
|
1236
|
+
assert.equal(status.steps?.[1]?.outputName, "reviews");
|
|
1237
|
+
assert.deepEqual(status.parallelGroups, [{ start: 1, count: 1, stepIndex: 1 }]);
|
|
1238
|
+
|
|
1239
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1240
|
+
const finalStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
1241
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1242
|
+
assert.equal(payload.success, true);
|
|
1243
|
+
assert.deepEqual(finalStatus.steps?.map((step) => step.agent), ["producer", "reviewer", "reviewer", "consumer"]);
|
|
1244
|
+
assert.deepEqual(finalStatus.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it("async chains expand dynamic fanout and persist collected output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1248
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1249
|
+
mockPi.onCall({ matchArgIncludes: "Review src/a.ts", output: "review-a", structuredOutput: { ok: "a" } });
|
|
1250
|
+
mockPi.onCall({ matchArgIncludes: "Review src/b.ts", output: "review-b", structuredOutput: { ok: "b" } });
|
|
1251
|
+
mockPi.onCall({ output: "used reviews" });
|
|
1252
|
+
const id = `async-dynamic-chain-${Date.now().toString(36)}`;
|
|
1253
|
+
const result = executeAsyncChain(id, {
|
|
1254
|
+
chain: [
|
|
1255
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1256
|
+
{
|
|
1257
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1258
|
+
parallel: {
|
|
1259
|
+
agent: "reviewer",
|
|
1260
|
+
task: "Review {target.path}",
|
|
1261
|
+
outputSchema: { type: "object" },
|
|
1262
|
+
},
|
|
1263
|
+
collect: { as: "reviews" },
|
|
1264
|
+
concurrency: 1,
|
|
1265
|
+
},
|
|
1266
|
+
{ agent: "consumer", task: "Use {outputs.reviews}" },
|
|
1267
|
+
],
|
|
1268
|
+
agents: [makeAgent("producer"), makeAgent("reviewer", { output: "context.md" }), makeAgent("consumer")],
|
|
1269
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
|
|
1270
|
+
artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1271
|
+
artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
|
|
1272
|
+
shareEnabled: false,
|
|
1273
|
+
maxSubagentDepth: 2,
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
assert.ok(!result.isError);
|
|
1277
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1278
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1279
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1280
|
+
assert.equal(payload.success, true);
|
|
1281
|
+
assert.equal(mockPi.callCount(), 4);
|
|
1282
|
+
assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Review src\/a\.ts/);
|
|
1283
|
+
assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", /Review src\/b\.ts/);
|
|
1284
|
+
assert.match(readMockPiArgs(mockPi, 3).at(-1) ?? "", /"key":"src\/a\.ts"/);
|
|
1285
|
+
assert.deepEqual(status.steps?.slice(1, 3).map((step) => step.sessionName), ["reviewer: Review src/a.ts", "reviewer: Review src/b.ts"]);
|
|
1286
|
+
assert.deepEqual(payload.results.slice(1, 3).map((result) => result.sessionName), ["reviewer: Review src/a.ts", "reviewer: Review src/b.ts"]);
|
|
1287
|
+
const collected = payload.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>;
|
|
1288
|
+
assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]);
|
|
1289
|
+
assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]);
|
|
1290
|
+
const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
|
|
1291
|
+
const dynamicOutputPaths = [
|
|
1292
|
+
path.join(outputDir, "dynamic-1", "0-reviewer", "context.md"),
|
|
1293
|
+
path.join(outputDir, "dynamic-1", "1-reviewer", "context.md"),
|
|
1294
|
+
];
|
|
1295
|
+
assert.equal(fs.readFileSync(dynamicOutputPaths[0]!, "utf-8"), "review-a");
|
|
1296
|
+
assert.equal(fs.readFileSync(dynamicOutputPaths[1]!, "utf-8"), "review-b");
|
|
1297
|
+
const reviewerArtifacts = payload.results.slice(1, 3).map((result) => result.artifactPaths?.outputPath);
|
|
1298
|
+
assert.ok(reviewerArtifacts[0] && reviewerArtifacts[1]);
|
|
1299
|
+
assert.notEqual(reviewerArtifacts[0], reviewerArtifacts[1]);
|
|
1300
|
+
assert.equal(fs.readFileSync(reviewerArtifacts[0], "utf-8"), "review-a");
|
|
1301
|
+
assert.equal(fs.readFileSync(reviewerArtifacts[1], "utf-8"), "review-b");
|
|
1302
|
+
assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", new RegExp(dynamicOutputPaths[0]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1303
|
+
assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", new RegExp(dynamicOutputPaths[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1304
|
+
assert.equal(status.steps?.length, 4);
|
|
1305
|
+
assert.deepEqual(status.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
|
|
1306
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "dynamic-parallel-group");
|
|
1307
|
+
assert.deepEqual(payload.workflowGraph?.nodes?.[1]?.children?.map((child) => child.itemKey), ["src/a.ts", "src/b.ts"]);
|
|
1308
|
+
assert.equal(payload.workflowGraph?.nodes?.[2]?.flatIndex, 3);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it("async dynamic fanout blocks queued children when hard reported usage is exhausted", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1312
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1313
|
+
mockPi.onCall({ matchArgIncludes: "Review src/a.ts", output: "review-a", structuredOutput: { ok: "a" } });
|
|
1314
|
+
const id = `async-dynamic-usage-budget-${Date.now().toString(36)}`;
|
|
1315
|
+
executeAsyncChain(id, {
|
|
1316
|
+
chain: [
|
|
1317
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1318
|
+
{
|
|
1319
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1320
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } },
|
|
1321
|
+
collect: { as: "reviews" },
|
|
1322
|
+
concurrency: 1,
|
|
1323
|
+
},
|
|
1324
|
+
],
|
|
1325
|
+
usageBudget: { tokens: { hard: 200 } },
|
|
1326
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1327
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-budget" },
|
|
1328
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1329
|
+
shareEnabled: false,
|
|
1330
|
+
maxSubagentDepth: 2,
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const payload = await readAsyncPayload(id);
|
|
1334
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1335
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1336
|
+
assert.equal(payload.success, false);
|
|
1337
|
+
assert.equal(payload.usageBudget?.exhausted, true);
|
|
1338
|
+
assert.equal(status.steps?.[1]?.status, "complete");
|
|
1339
|
+
assert.equal(status.steps?.[2]?.status, "failed");
|
|
1340
|
+
assert.match(status.steps?.[2]?.error ?? "", /Usage budget exhausted/);
|
|
1341
|
+
assert.equal(payload.results.find((result) => result.agent === "reviewer" && result.skipped)?.skipped, true);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("rejects a shared explicit output before dynamic fanout children start", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1345
|
+
mockPi.onCall({ matchArgIncludes: "Produce targets", output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1346
|
+
const id = `async-dynamic-explicit-output-${Date.now().toString(36)}`;
|
|
1347
|
+
const launch = executeAsyncChain(id, {
|
|
1348
|
+
chain: [
|
|
1349
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1350
|
+
{
|
|
1351
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", maxItems: 2 },
|
|
1352
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", output: "shared.md" },
|
|
1353
|
+
collect: { as: "reviews" },
|
|
1354
|
+
concurrency: 2,
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1358
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-explicit-output" },
|
|
1359
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1360
|
+
shareEnabled: false,
|
|
1361
|
+
maxSubagentDepth: 2,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
assert.equal(launch.isError, undefined);
|
|
1365
|
+
const payload = await readAsyncPayload(id);
|
|
1366
|
+
assert.equal(payload.success, false);
|
|
1367
|
+
const error = payload.results.find((result) => result.error)?.error ?? "";
|
|
1368
|
+
assert.match(error, /materialized 2 items that resolve output to the same path/);
|
|
1369
|
+
assert.match(error, /shared\.md/);
|
|
1370
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
it("async dynamic fanout applies fork session files and thinking overrides to materialized children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1374
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1375
|
+
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1376
|
+
mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
|
|
1377
|
+
const id = `async-dynamic-fork-thinking-${Date.now().toString(36)}`;
|
|
1378
|
+
const sessionA = path.join(tempDir, "dynamic-a.jsonl");
|
|
1379
|
+
const sessionB = path.join(tempDir, "dynamic-b.jsonl");
|
|
1380
|
+
const result = executeAsyncChain(id, {
|
|
1381
|
+
chain: [
|
|
1382
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1383
|
+
{
|
|
1384
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
|
|
1385
|
+
parallel: {
|
|
1386
|
+
agent: "reviewer",
|
|
1387
|
+
task: "Review {target.path}",
|
|
1388
|
+
label: "Review {target.path}",
|
|
1389
|
+
outputSchema: { type: "object" },
|
|
1390
|
+
},
|
|
1391
|
+
collect: { as: "reviews" },
|
|
1392
|
+
concurrency: 1,
|
|
1393
|
+
},
|
|
1394
|
+
],
|
|
1395
|
+
agents: [makeAgent("producer"), makeAgent("reviewer", { model: "anthropic/claude-sonnet-4-5:high", thinking: "high" })],
|
|
1396
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
|
|
1397
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1398
|
+
shareEnabled: false,
|
|
1399
|
+
sessionFilesByFlatIndex: [undefined, sessionA, sessionB],
|
|
1400
|
+
thinkingOverridesByFlatIndex: [undefined, "off", "off"],
|
|
1401
|
+
maxSubagentDepth: 2,
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
assert.ok(!result.isError);
|
|
1405
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1406
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1407
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
|
|
1408
|
+
const firstDynamicArgs = readMockPiArgs(mockPi, 1);
|
|
1409
|
+
const secondDynamicArgs = readMockPiArgs(mockPi, 2);
|
|
1410
|
+
assert.equal(payload.success, true);
|
|
1411
|
+
assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--session") + 1], sessionA);
|
|
1412
|
+
assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--session") + 1], sessionB);
|
|
1413
|
+
assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off");
|
|
1414
|
+
assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off");
|
|
1415
|
+
assert.deepEqual(status.steps?.slice(1).map((step) => step.sessionFile), [sessionA, sessionB]);
|
|
1416
|
+
assert.deepEqual(status.steps?.slice(1).map((step) => step.thinking), ["off", "off"]);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it("applies read-only acceptance roles to async dynamic children and their aggregate group", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1420
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1421
|
+
const readOnlyReport = [
|
|
1422
|
+
"done",
|
|
1423
|
+
"```acceptance-report",
|
|
1424
|
+
JSON.stringify({
|
|
1425
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "inspection complete" }],
|
|
1426
|
+
changedFiles: [],
|
|
1427
|
+
testsAddedOrUpdated: [],
|
|
1428
|
+
commandsRun: [],
|
|
1429
|
+
validationOutput: [],
|
|
1430
|
+
reviewFindings: ["No blocking findings"],
|
|
1431
|
+
residualRisks: [],
|
|
1432
|
+
noStagedFiles: true,
|
|
1433
|
+
}),
|
|
1434
|
+
"```",
|
|
1435
|
+
].join("\n");
|
|
1436
|
+
mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "a" } });
|
|
1437
|
+
mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "b" } });
|
|
1438
|
+
const id = `async-dynamic-acceptance-role-${Date.now().toString(36)}`;
|
|
1439
|
+
const result = executeAsyncChain(id, {
|
|
1440
|
+
chain: [
|
|
1441
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1442
|
+
{
|
|
1443
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
|
|
1444
|
+
parallel: { agent: "explorer", task: "Explore {target.path}", outputSchema: { type: "object" } },
|
|
1445
|
+
collect: { as: "reviews" },
|
|
1446
|
+
concurrency: 1,
|
|
1447
|
+
},
|
|
1448
|
+
],
|
|
1449
|
+
agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
1450
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role" },
|
|
1451
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1452
|
+
shareEnabled: false,
|
|
1453
|
+
maxSubagentDepth: 2,
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
assert.ok(!result.isError);
|
|
1457
|
+
const payload = await readAsyncPayload(id);
|
|
1458
|
+
const explorerResults = payload.results.filter((child) => child.agent === "explorer");
|
|
1459
|
+
assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["none", "none"]);
|
|
1460
|
+
const dynamicNode = payload.workflowGraph?.nodes?.[1];
|
|
1461
|
+
assert.equal(dynamicNode?.acceptanceStatus, "not-required");
|
|
1462
|
+
assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["not-required", "not-required"]);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it("infers async dynamic acceptance after materializing item templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1466
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1467
|
+
const writerReport = [
|
|
1468
|
+
"done",
|
|
1469
|
+
"```acceptance-report",
|
|
1470
|
+
JSON.stringify({
|
|
1471
|
+
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patch complete" }],
|
|
1472
|
+
changedFiles: ["src/a.ts"],
|
|
1473
|
+
testsAddedOrUpdated: ["test/a.test.ts"],
|
|
1474
|
+
commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
|
|
1475
|
+
validationOutput: ["tests passed"],
|
|
1476
|
+
residualRisks: [],
|
|
1477
|
+
noStagedFiles: true,
|
|
1478
|
+
}),
|
|
1479
|
+
"```",
|
|
1480
|
+
].join("\n");
|
|
1481
|
+
mockPi.onCall({ output: writerReport, structuredOutput: { ok: "a" } });
|
|
1482
|
+
mockPi.onCall({ output: writerReport, structuredOutput: { ok: "b" } });
|
|
1483
|
+
const id = `async-dynamic-role-item-template-${Date.now().toString(36)}`;
|
|
1484
|
+
executeAsyncChain(id, {
|
|
1485
|
+
chain: [
|
|
1486
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1487
|
+
{
|
|
1488
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
|
|
1489
|
+
parallel: { agent: "explorer", task: "Patch {target.path}", outputSchema: { type: "object" } },
|
|
1490
|
+
collect: { as: "reviews" },
|
|
1491
|
+
concurrency: 1,
|
|
1492
|
+
},
|
|
1493
|
+
],
|
|
1494
|
+
agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
|
|
1495
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role-item" },
|
|
1496
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1497
|
+
shareEnabled: false,
|
|
1498
|
+
maxSubagentDepth: 2,
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
const payload = await readAsyncPayload(id);
|
|
1502
|
+
const explorerResults = payload.results.filter((child) => child.agent === "explorer");
|
|
1503
|
+
assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["checked", "checked"]);
|
|
1504
|
+
const dynamicNode = payload.workflowGraph?.nodes?.[1];
|
|
1505
|
+
assert.equal(payload.success, true);
|
|
1506
|
+
assert.equal(dynamicNode?.acceptanceStatus, "rejected");
|
|
1507
|
+
assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["rejected", "rejected"]);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
it("cancels dynamic fanout aggregate acceptance when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
|
|
1511
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
|
|
1512
|
+
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1513
|
+
const id = `async-dynamic-acceptance-timeout-${Date.now().toString(36)}`;
|
|
1514
|
+
const startedAt = Date.now();
|
|
1515
|
+
executeAsyncChain(id, {
|
|
1516
|
+
chain: [
|
|
1517
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1518
|
+
{
|
|
1519
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1520
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" }, acceptance: { level: "checked" } },
|
|
1521
|
+
collect: { as: "reviews" },
|
|
1522
|
+
acceptance: {
|
|
1523
|
+
level: "verified",
|
|
1524
|
+
verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }],
|
|
1525
|
+
},
|
|
1526
|
+
},
|
|
1527
|
+
],
|
|
1528
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1529
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-acceptance-timeout" },
|
|
1530
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1531
|
+
shareEnabled: false,
|
|
1532
|
+
maxSubagentDepth: 2,
|
|
1533
|
+
timeoutMs: 1_000,
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
const resultPath = await waitForAsyncResultFile(id, 5_000);
|
|
1537
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1538
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1539
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
1540
|
+
const dynamicNode = payload.workflowGraph?.nodes?.[1] as { status?: string; error?: string; acceptanceStatus?: string } | undefined;
|
|
1541
|
+
assert.equal(payload.state, "failed");
|
|
1542
|
+
assert.equal(payload.timedOut, true);
|
|
1543
|
+
assert.equal(payload.results.at(-1)?.timedOut, true);
|
|
1544
|
+
assert.equal(payload.results.at(-1)?.acceptance, undefined);
|
|
1545
|
+
assert.equal(dynamicNode?.status, "failed");
|
|
1546
|
+
assert.match(dynamicNode?.error ?? "", /Subagent timed out after 1000ms\./);
|
|
1547
|
+
assert.notEqual(dynamicNode?.acceptanceStatus, "verified");
|
|
1548
|
+
assert.equal(status.timedOut, true);
|
|
1549
|
+
assert.ok(elapsedMs < 3_000, `timeout should cancel dynamic aggregate acceptance promptly, elapsed ${elapsedMs}ms`);
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it("async dynamic fanout recomputes later child intercom targets by final flat index", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1553
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1554
|
+
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1555
|
+
mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
|
|
1556
|
+
mockPi.onCall({ output: "consumed" });
|
|
1557
|
+
const id = `async-dynamic-targets-${Date.now().toString(36)}`;
|
|
1558
|
+
const result = executeAsyncChain(id, {
|
|
1559
|
+
chain: [
|
|
1560
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1561
|
+
{
|
|
1562
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1563
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } },
|
|
1564
|
+
collect: { as: "reviews" },
|
|
1565
|
+
concurrency: 1,
|
|
1566
|
+
},
|
|
1567
|
+
{ agent: "consumer", task: "Use {outputs.reviews}" },
|
|
1568
|
+
],
|
|
1569
|
+
agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
|
|
1570
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-targets" },
|
|
1571
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1572
|
+
shareEnabled: false,
|
|
1573
|
+
maxSubagentDepth: 2,
|
|
1574
|
+
controlIntercomTarget: "subagent-orchestrator-test",
|
|
1575
|
+
childIntercomTarget: (agent: string, index: number) => `subagent-${agent}-${id}-${index + 1}`,
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
assert.ok(!result.isError);
|
|
1579
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1580
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1581
|
+
const expectedConsumerTarget = `subagent-consumer-${id}-4`;
|
|
1582
|
+
assert.equal(payload.success, true);
|
|
1583
|
+
assert.equal(payload.results[3]?.intercomTarget, expectedConsumerTarget);
|
|
1584
|
+
assert.equal((await waitForMockPiRuntime(mockPi, 3)).intercomSessionName, expectedConsumerTarget);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it("async dynamic pre-spawn failures persist failed graph status and error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1588
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
|
|
1589
|
+
const id = `async-dynamic-prespawn-fail-${Date.now().toString(36)}`;
|
|
1590
|
+
const result = executeAsyncChain(id, {
|
|
1591
|
+
chain: [
|
|
1592
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1593
|
+
{
|
|
1594
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 1 },
|
|
1595
|
+
parallel: { agent: "reviewer", task: "Review {target.path}" },
|
|
1596
|
+
collect: { as: "reviews" },
|
|
1597
|
+
},
|
|
1598
|
+
],
|
|
1599
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1600
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-fail" },
|
|
1601
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1602
|
+
shareEnabled: false,
|
|
1603
|
+
maxSubagentDepth: 2,
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
assert.ok(!result.isError);
|
|
1607
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1608
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1609
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed") as AsyncStatusPayload & { workflowGraph?: AsyncResultPayload["workflowGraph"]; error?: string };
|
|
1610
|
+
assert.equal(payload.success, false);
|
|
1611
|
+
assert.match(payload.results.at(-1)?.error ?? "", /exceeding maxItems 1/);
|
|
1612
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed");
|
|
1613
|
+
assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /exceeding maxItems 1/);
|
|
1614
|
+
assert.equal(status.state, "failed");
|
|
1615
|
+
assert.match(status.error ?? "", /exceeding maxItems 1/);
|
|
1616
|
+
assert.equal(status.workflowGraph?.nodes?.[1]?.status, "failed");
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it("async dynamic collect schema failures persist failed graph status and details", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1620
|
+
mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
|
|
1621
|
+
mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
|
|
1622
|
+
const id = `async-dynamic-collect-fail-${Date.now().toString(36)}`;
|
|
1623
|
+
const result = executeAsyncChain(id, {
|
|
1624
|
+
chain: [
|
|
1625
|
+
{ agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
|
|
1626
|
+
{
|
|
1627
|
+
expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
|
|
1628
|
+
parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } },
|
|
1629
|
+
collect: { as: "reviews", outputSchema: { type: "object" } },
|
|
1630
|
+
},
|
|
1631
|
+
],
|
|
1632
|
+
agents: [makeAgent("producer"), makeAgent("reviewer")],
|
|
1633
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-collect-fail" },
|
|
1634
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1635
|
+
shareEnabled: false,
|
|
1636
|
+
maxSubagentDepth: 2,
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
assert.ok(!result.isError);
|
|
1640
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1641
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1642
|
+
assert.equal(payload.success, false);
|
|
1643
|
+
assert.match(payload.results.at(-1)?.error ?? "", /Collected output validation failed/);
|
|
1644
|
+
assert.ok(Array.isArray(payload.results.at(-1)?.structuredOutput), "failed collect result should preserve ordered collection details");
|
|
1645
|
+
assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed");
|
|
1646
|
+
assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /Collected output validation failed/);
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
it("readStatus caches ordered sweeps above 50 files and invalidates same-mtime replacements", () => {
|
|
1652
|
+
const root = createTempDir();
|
|
1653
|
+
try {
|
|
1654
|
+
const fixedTimestamp = new Date(1_700_000_000_000);
|
|
1655
|
+
const dirs = Array.from({ length: 51 }, (_, index) => {
|
|
1656
|
+
const dir = path.join(root, `run-${index}`);
|
|
1657
|
+
const statusPath = path.join(dir, "status.json");
|
|
1658
|
+
fs.mkdirSync(dir);
|
|
1659
|
+
fs.writeFileSync(statusPath, JSON.stringify({
|
|
1660
|
+
runId: `cache-test-${index}`,
|
|
1661
|
+
state: "running",
|
|
1662
|
+
mode: "single",
|
|
1663
|
+
startedAt: fixedTimestamp.getTime(),
|
|
1664
|
+
}));
|
|
1665
|
+
fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
|
|
1666
|
+
return dir;
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
const cached = dirs.map((dir) => readStatus(dir));
|
|
1670
|
+
cached.forEach((status) => assert.ok(status));
|
|
1671
|
+
dirs.forEach((dir, index) => assert.strictEqual(readStatus(dir), cached[index]));
|
|
1672
|
+
|
|
1673
|
+
const replacedDir = dirs[25]!;
|
|
1674
|
+
const cachedStatus = cached[25];
|
|
1675
|
+
assert.ok(cachedStatus);
|
|
1676
|
+
const statusPath = path.join(replacedDir, "status.json");
|
|
1677
|
+
writeAtomicJson(statusPath, { ...cachedStatus, state: "stopped" });
|
|
1678
|
+
fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
|
|
1679
|
+
assert.equal(fs.statSync(statusPath).mtimeMs, fixedTimestamp.getTime());
|
|
1680
|
+
const replaced = readStatus(replacedDir);
|
|
1681
|
+
assert.ok(replaced);
|
|
1682
|
+
assert.equal(replaced.state, "stopped");
|
|
1683
|
+
assert.notStrictEqual(replaced, cachedStatus);
|
|
1684
|
+
|
|
1685
|
+
fs.rmSync(statusPath);
|
|
1686
|
+
assert.equal(readStatus(replacedDir), null);
|
|
1687
|
+
|
|
1688
|
+
const removedDir = dirs[50]!;
|
|
1689
|
+
assert.ok(readStatus(removedDir));
|
|
1690
|
+
fs.rmSync(removedDir, { recursive: true, force: true });
|
|
1691
|
+
assert.equal(pruneStatusCacheForAsyncRoot(root, dirs.slice(0, 50).map((dir) => path.basename(dir))), 1);
|
|
1692
|
+
} finally {
|
|
1693
|
+
removeTempDir(root);
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it("readStatus throws for malformed status files", () => {
|
|
1698
|
+
const dir = createTempDir();
|
|
1699
|
+
try {
|
|
1700
|
+
fs.writeFileSync(path.join(dir, "status.json"), "{bad-json", "utf-8");
|
|
1701
|
+
assert.throws(() => readStatus(dir), /Failed to parse async status file/);
|
|
1702
|
+
} finally {
|
|
1703
|
+
removeTempDir(dir);
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
it("background runs record fallback attempts and final model", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1708
|
+
mockPi.onCall({
|
|
1709
|
+
jsonl: [{
|
|
1710
|
+
type: "message_end",
|
|
1711
|
+
message: {
|
|
1712
|
+
role: "assistant",
|
|
1713
|
+
content: [{ type: "text", text: "primary failed" }],
|
|
1714
|
+
model: "openai/gpt-5-mini",
|
|
1715
|
+
errorMessage: "rate limit exceeded",
|
|
1716
|
+
usage: { input: 10, output: 5, cacheRead: 300, cacheWrite: 0, cost: { total: 0.01 } },
|
|
1717
|
+
},
|
|
1718
|
+
}],
|
|
1719
|
+
exitCode: 1,
|
|
1720
|
+
});
|
|
1721
|
+
mockPi.onCall({ output: "Recovered asynchronously" });
|
|
1722
|
+
const id = `async-fallback-${Date.now().toString(36)}`;
|
|
1723
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
1724
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
1725
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
1726
|
+
const run = executeAsyncSingle(id, {
|
|
1727
|
+
agent: "worker",
|
|
1728
|
+
task: "Do work",
|
|
1729
|
+
acceptance: { level: "none", reason: "descriptor persistence coverage" },
|
|
1730
|
+
agentConfig: makeAgent("worker", {
|
|
1731
|
+
model: "openai/gpt-5-mini:high",
|
|
1732
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
1733
|
+
}),
|
|
1734
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1735
|
+
availableModels: [
|
|
1736
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
1737
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
1738
|
+
],
|
|
1739
|
+
artifactConfig: {
|
|
1740
|
+
enabled: false,
|
|
1741
|
+
includeInput: false,
|
|
1742
|
+
includeOutput: false,
|
|
1743
|
+
includeJsonl: false,
|
|
1744
|
+
includeMetadata: false,
|
|
1745
|
+
cleanupDays: 7,
|
|
1746
|
+
},
|
|
1747
|
+
shareEnabled: false,
|
|
1748
|
+
sessionRoot,
|
|
1749
|
+
maxSubagentDepth: 2,
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
assert.equal(run.details.asyncId, id);
|
|
1753
|
+
|
|
1754
|
+
const started = Date.now();
|
|
1755
|
+
while (!fs.existsSync(resultPath)) {
|
|
1756
|
+
if (Date.now() - started > 15000) {
|
|
1757
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
1758
|
+
}
|
|
1759
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
|
|
1763
|
+
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf-8"));
|
|
1764
|
+
assert.equal(descriptor.sourceRunId, id);
|
|
1765
|
+
assert.equal(descriptor.agent, "worker");
|
|
1766
|
+
assert.equal(descriptor.model, "openai/gpt-5-mini:high");
|
|
1767
|
+
assert.deepEqual(descriptor.fallbackModels, ["anthropic/claude-sonnet-4:low"]);
|
|
1768
|
+
assert.equal(descriptor.cwd, tempDir);
|
|
1769
|
+
assert.equal(descriptor.sessionDir, path.join(sessionRoot, `async-${id}`));
|
|
1770
|
+
assert.deepEqual(descriptor.acceptance, { level: "none", reason: "descriptor persistence coverage" });
|
|
1771
|
+
assert.equal(descriptor.initialTurnBudget, undefined);
|
|
1772
|
+
assert.equal(Object.hasOwn(descriptor.acceptance, "explicit"), false);
|
|
1773
|
+
assert.equal(Object.hasOwn(descriptor.acceptance, "inferredReason"), false);
|
|
1774
|
+
assert.equal(Object.hasOwn(descriptor, "task"), false);
|
|
1775
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(descriptorPath).mode & 0o777, 0o600);
|
|
1776
|
+
|
|
1777
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1778
|
+
assert.equal(payload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1779
|
+
assert.equal(payload.success, true);
|
|
1780
|
+
assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:low");
|
|
1781
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
|
|
1782
|
+
assert.equal(payload.results[0].modelAttempts.length, 2);
|
|
1783
|
+
assert.deepEqual(payload.results[0].totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1784
|
+
assert.deepEqual(payload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1785
|
+
const statusPayload = await waitForAsyncState(id, (candidate) => candidate.state === "complete"
|
|
1786
|
+
&& candidate.lifecycleArtifactVersion !== undefined
|
|
1787
|
+
&& candidate.totalTokens?.total !== undefined
|
|
1788
|
+
&& candidate.totalCost !== undefined
|
|
1789
|
+
&& candidate.steps[0]?.model !== undefined
|
|
1790
|
+
&& candidate.steps[0]?.thinking !== undefined
|
|
1791
|
+
&& candidate.steps[0]?.tokens?.total !== undefined
|
|
1792
|
+
&& candidate.steps[0]?.totalCost !== undefined);
|
|
1793
|
+
assert.equal(statusPayload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1794
|
+
assert.equal(statusPayload.steps[0]?.model, "anthropic/claude-sonnet-4:low");
|
|
1795
|
+
assert.equal(statusPayload.steps[0]?.thinking, "low");
|
|
1796
|
+
assert.ok(statusPayload.totalTokens!.total > 0);
|
|
1797
|
+
assert.ok(statusPayload.steps[0]?.tokens!.total > 0);
|
|
1798
|
+
assert.equal(statusPayload.totalTokens!.window, 100);
|
|
1799
|
+
assert.equal(statusPayload.totalTokens!.windowPeak, 310);
|
|
1800
|
+
assert.equal(statusPayload.steps[0]?.tokens!.window, 100);
|
|
1801
|
+
assert.equal(statusPayload.steps[0]?.tokens!.windowPeak, 310);
|
|
1802
|
+
assert.deepEqual(statusPayload.steps[0]?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1803
|
+
assert.deepEqual(statusPayload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1804
|
+
const events = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
|
|
1805
|
+
assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1806
|
+
const completed = await waitForAsyncEvent(id, "subagent.run.completed");
|
|
1807
|
+
assert.equal(completed.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
|
|
1808
|
+
assert.deepEqual(completed.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
|
|
1809
|
+
assert.match(fs.readFileSync(path.join(asyncDir, "output-0.log"), "utf-8"), /Recovered asynchronously/);
|
|
1810
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
it("background runs accept captured response aliases for the resolved fallback without rewriting its route", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1814
|
+
mockPi.onCall({ jsonl: [], stderr: "429 rate limit exceeded", exitCode: 1 });
|
|
1815
|
+
mockPi.onCall({ jsonl: [events.assistantMessage("Declared async echo accepted", "claude-opus-5")] });
|
|
1816
|
+
const id = `async-response-alias-fallback-${Date.now().toString(36)}`;
|
|
1817
|
+
executeAsyncSingle(id, {
|
|
1818
|
+
agent: "worker",
|
|
1819
|
+
task: "Say hello",
|
|
1820
|
+
acceptance: false,
|
|
1821
|
+
agentConfig: makeAgent("worker", { model: "mock/primary", fallbackModels: ["ias-claude-opus-5:high"] }),
|
|
1822
|
+
ctx: {
|
|
1823
|
+
pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1",
|
|
1824
|
+
modelResponseAliases: { "databricks-bedrock/ias-claude-opus-5": ["claude-opus-5"] },
|
|
1825
|
+
},
|
|
1826
|
+
availableModels: [
|
|
1827
|
+
{ provider: "mock", id: "primary", fullId: "mock/primary" },
|
|
1828
|
+
{ provider: "databricks-bedrock", id: "ias-claude-opus-5", fullId: "databricks-bedrock/ias-claude-opus-5" },
|
|
1829
|
+
],
|
|
1830
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1831
|
+
shareEnabled: false,
|
|
1832
|
+
maxSubagentDepth: 2,
|
|
1833
|
+
});
|
|
1834
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8")) as AsyncResultPayload;
|
|
1835
|
+
assert.equal(payload.success, true, payload.results[0]?.error);
|
|
1836
|
+
assert.equal(payload.results[0]?.model, "databricks-bedrock/ias-claude-opus-5:high");
|
|
1837
|
+
assert.deepEqual(payload.results[0]?.attemptedModels, ["mock/primary", "databricks-bedrock/ias-claude-opus-5:high"]);
|
|
1838
|
+
assert.equal(payload.results[0]?.modelAttempts?.[1]?.success, true);
|
|
1839
|
+
const args = readMockPiArgs(mockPi, 1);
|
|
1840
|
+
assert.equal(args[args.indexOf("--model") + 1], "databricks-bedrock/ias-claude-opus-5:high");
|
|
1841
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
it("background runs fail when a configured provider-qualified model starts on a different child model", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1845
|
+
mockPi.onCall({ jsonl: [events.assistantMessage("wrong async provider", "openai-codex/gpt-5.6-sol")] });
|
|
1846
|
+
const id = `async-model-verification-${Date.now().toString(36)}`;
|
|
1847
|
+
executeAsyncSingle(id, {
|
|
1848
|
+
agent: "worker",
|
|
1849
|
+
task: "Do work",
|
|
1850
|
+
agentConfig: makeAgent("worker", { model: "opencode-go/ox-alpha-free:max" }),
|
|
1851
|
+
ctx: {
|
|
1852
|
+
pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1",
|
|
1853
|
+
modelResponseAliases: { "opencode-go/ox-alpha-free": ["declared-echo"] },
|
|
1854
|
+
},
|
|
1855
|
+
availableModels: [
|
|
1856
|
+
{ provider: "opencode-go", id: "ox-alpha-free", fullId: "opencode-go/ox-alpha-free" },
|
|
1857
|
+
{ provider: "openai-codex", id: "gpt-5.6-sol", fullId: "openai-codex/gpt-5.6-sol" },
|
|
1858
|
+
],
|
|
1859
|
+
artifactConfig: {
|
|
1860
|
+
enabled: false,
|
|
1861
|
+
includeInput: false,
|
|
1862
|
+
includeOutput: false,
|
|
1863
|
+
includeJsonl: false,
|
|
1864
|
+
includeMetadata: false,
|
|
1865
|
+
cleanupDays: 7,
|
|
1866
|
+
},
|
|
1867
|
+
shareEnabled: false,
|
|
1868
|
+
maxSubagentDepth: 2,
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8")) as AsyncResultPayload;
|
|
1872
|
+
assert.equal(payload.success, false);
|
|
1873
|
+
assert.equal(payload.results[0]?.model, "opencode-go/ox-alpha-free:max");
|
|
1874
|
+
assert.deepEqual(payload.results[0]?.attemptedModels, ["opencode-go/ox-alpha-free:max"]);
|
|
1875
|
+
assert.equal(payload.results[0]?.modelAttempts?.[0]?.success, false);
|
|
1876
|
+
assert.match(payload.results[0]?.error ?? "", /model_verification_failed/);
|
|
1877
|
+
assert.match(payload.results[0]?.error ?? "", /Expected 'opencode-go\/ox-alpha-free:max'/);
|
|
1878
|
+
assert.match(payload.results[0]?.error ?? "", /observed 'openai-codex\/gpt-5\.6-sol'/);
|
|
1879
|
+
assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /model_verification_failed/);
|
|
1880
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1881
|
+
assert.equal(args[args.indexOf("--model") + 1], "opencode-go/ox-alpha-free:max");
|
|
1882
|
+
assert.equal(mockPi.callCount(), 1);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it("background runs retry the fallback model when the provider stream ends without finish_reason", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1886
|
+
mockPi.onCall({
|
|
1887
|
+
jsonl: [{
|
|
1888
|
+
type: "message_end",
|
|
1889
|
+
message: {
|
|
1890
|
+
role: "assistant",
|
|
1891
|
+
content: [{ type: "text", text: "stream broke mid-response" }],
|
|
1892
|
+
model: "openai/gpt-5-mini",
|
|
1893
|
+
errorMessage: "Stream ended without finish_reason",
|
|
1894
|
+
usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
1895
|
+
},
|
|
1896
|
+
}],
|
|
1897
|
+
exitCode: 1,
|
|
1898
|
+
});
|
|
1899
|
+
mockPi.onCall({ output: "Recovered after stream failure" });
|
|
1900
|
+
const id = `async-fallback-stream-${Date.now().toString(36)}`;
|
|
1901
|
+
executeAsyncSingle(id, {
|
|
1902
|
+
agent: "worker",
|
|
1903
|
+
task: "Do work",
|
|
1904
|
+
agentConfig: makeAgent("worker", {
|
|
1905
|
+
model: "openai/gpt-5-mini:high",
|
|
1906
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
1907
|
+
}),
|
|
1908
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1909
|
+
availableModels: [
|
|
1910
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
1911
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
1912
|
+
],
|
|
1913
|
+
artifactConfig: {
|
|
1914
|
+
enabled: false,
|
|
1915
|
+
includeInput: false,
|
|
1916
|
+
includeOutput: false,
|
|
1917
|
+
includeJsonl: false,
|
|
1918
|
+
includeMetadata: false,
|
|
1919
|
+
cleanupDays: 7,
|
|
1920
|
+
},
|
|
1921
|
+
shareEnabled: false,
|
|
1922
|
+
maxSubagentDepth: 2,
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
1926
|
+
assert.equal(payload.success, true);
|
|
1927
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
|
|
1928
|
+
assert.match(payload.results[0].output ?? "", /Recovered after stream failure/);
|
|
1929
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it("background runs retry the fallback model after a provider connection error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1933
|
+
mockPi.onCall({
|
|
1934
|
+
jsonl: [{
|
|
1935
|
+
type: "message_end",
|
|
1936
|
+
message: {
|
|
1937
|
+
role: "assistant",
|
|
1938
|
+
content: [],
|
|
1939
|
+
model: "openai/gpt-5-mini",
|
|
1940
|
+
errorMessage: "Connection error.",
|
|
1941
|
+
usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
1942
|
+
},
|
|
1943
|
+
}],
|
|
1944
|
+
exitCode: 1,
|
|
1945
|
+
});
|
|
1946
|
+
mockPi.onCall({ output: "Recovered after connection error" });
|
|
1947
|
+
const id = `async-fallback-connection-error-${Date.now().toString(36)}`;
|
|
1948
|
+
executeAsyncSingle(id, {
|
|
1949
|
+
agent: "worker",
|
|
1950
|
+
task: "Do work",
|
|
1951
|
+
agentConfig: makeAgent("worker", {
|
|
1952
|
+
model: "openai/gpt-5-mini:high",
|
|
1953
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
1954
|
+
}),
|
|
1955
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1956
|
+
availableModels: [
|
|
1957
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
1958
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
1959
|
+
],
|
|
1960
|
+
artifactConfig: {
|
|
1961
|
+
enabled: false,
|
|
1962
|
+
includeInput: false,
|
|
1963
|
+
includeOutput: false,
|
|
1964
|
+
includeJsonl: false,
|
|
1965
|
+
includeMetadata: false,
|
|
1966
|
+
cleanupDays: 7,
|
|
1967
|
+
},
|
|
1968
|
+
shareEnabled: false,
|
|
1969
|
+
maxSubagentDepth: 2,
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
1973
|
+
assert.equal(payload.success, true);
|
|
1974
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
|
|
1975
|
+
assert.match(payload.results[0].modelAttempts[0].error ?? "", /Connection error/u);
|
|
1976
|
+
assert.match(payload.results[0].output ?? "", /Recovered after connection error/u);
|
|
1977
|
+
assert.equal(mockPi.callCount(), 2);
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
it("background runs do not retry the fallback model for a trailing tool failure", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1981
|
+
mockPi.onCall({
|
|
1982
|
+
jsonl: [
|
|
1983
|
+
mockAssistantMessage("checking connectivity", "tool_use"),
|
|
1984
|
+
events.toolResult("bash", "curl: (28) Connection timed out after 5000 ms\nCommand exited with code 1", true),
|
|
1985
|
+
],
|
|
1986
|
+
exitCode: 0,
|
|
1987
|
+
});
|
|
1988
|
+
mockPi.onCall({ output: "fallback must not run" });
|
|
1989
|
+
const id = `async-fallback-toolfail-${Date.now().toString(36)}`;
|
|
1990
|
+
executeAsyncSingle(id, {
|
|
1991
|
+
agent: "worker",
|
|
1992
|
+
task: "Do work",
|
|
1993
|
+
agentConfig: makeAgent("worker", {
|
|
1994
|
+
model: "openai/gpt-5-mini:high",
|
|
1995
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
1996
|
+
}),
|
|
1997
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1998
|
+
availableModels: [
|
|
1999
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2000
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2001
|
+
],
|
|
2002
|
+
artifactConfig: {
|
|
2003
|
+
enabled: false,
|
|
2004
|
+
includeInput: false,
|
|
2005
|
+
includeOutput: false,
|
|
2006
|
+
includeJsonl: false,
|
|
2007
|
+
includeMetadata: false,
|
|
2008
|
+
cleanupDays: 7,
|
|
2009
|
+
},
|
|
2010
|
+
shareEnabled: false,
|
|
2011
|
+
maxSubagentDepth: 2,
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2015
|
+
assert.equal(payload.success, false);
|
|
2016
|
+
assert.equal(payload.results[0].modelAttempts.length, 1);
|
|
2017
|
+
assert.match(payload.results[0].error ?? "", /^bash failed \(exit 1\)/);
|
|
2018
|
+
assert.match(payload.results[0].error ?? "", /timed out/i);
|
|
2019
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
it("background runs do not retry raw connection stderr after child activity", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2023
|
+
mockPi.onCall({
|
|
2024
|
+
jsonl: [{
|
|
2025
|
+
type: "message_end",
|
|
2026
|
+
message: {
|
|
2027
|
+
role: "assistant",
|
|
2028
|
+
content: [{ type: "text", text: "completed side effect" }],
|
|
2029
|
+
model: "openai/gpt-5-mini",
|
|
2030
|
+
stopReason: "stop",
|
|
2031
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
|
|
2032
|
+
},
|
|
2033
|
+
}],
|
|
2034
|
+
stderr: "APIConnectionError: Connection closed.",
|
|
2035
|
+
exitCode: 1,
|
|
2036
|
+
});
|
|
2037
|
+
mockPi.onCall({ output: "fallback must not run" });
|
|
2038
|
+
const id = `async-fallback-raw-stderr-${Date.now().toString(36)}`;
|
|
2039
|
+
executeAsyncSingle(id, {
|
|
2040
|
+
agent: "worker",
|
|
2041
|
+
task: "Do work",
|
|
2042
|
+
agentConfig: makeAgent("worker", {
|
|
2043
|
+
model: "openai/gpt-5-mini:high",
|
|
2044
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
2045
|
+
}),
|
|
2046
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2047
|
+
availableModels: [
|
|
2048
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2049
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2050
|
+
],
|
|
2051
|
+
artifactConfig: {
|
|
2052
|
+
enabled: false,
|
|
2053
|
+
includeInput: false,
|
|
2054
|
+
includeOutput: false,
|
|
2055
|
+
includeJsonl: false,
|
|
2056
|
+
includeMetadata: false,
|
|
2057
|
+
cleanupDays: 7,
|
|
2058
|
+
},
|
|
2059
|
+
shareEnabled: false,
|
|
2060
|
+
maxSubagentDepth: 2,
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2064
|
+
assert.equal(payload.success, false);
|
|
2065
|
+
assert.equal(payload.results[0].modelAttempts.length, 1);
|
|
2066
|
+
assert.match(payload.results[0].error ?? "", /Connection closed/u);
|
|
2067
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
it("background runs resume the retained session once after a compaction-induced abort following completed tool work", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2071
|
+
const sessionFile = path.join(tempDir, "async-abort-recovery-session.jsonl");
|
|
2072
|
+
mockPi.onCall({
|
|
2073
|
+
jsonl: [
|
|
2074
|
+
events.toolStart("write", { path: "side-effect.txt", content: "done" }),
|
|
2075
|
+
events.toolEnd("write"),
|
|
2076
|
+
events.toolResult("write", "Wrote side-effect.txt"),
|
|
2077
|
+
{ type: "compaction_start" },
|
|
2078
|
+
{
|
|
2079
|
+
type: "message_end",
|
|
2080
|
+
message: {
|
|
2081
|
+
role: "assistant",
|
|
2082
|
+
content: [],
|
|
2083
|
+
model: "openai/gpt-5-mini",
|
|
2084
|
+
stopReason: "aborted",
|
|
2085
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
2086
|
+
},
|
|
2087
|
+
},
|
|
2088
|
+
{ type: "agent_settled" },
|
|
2089
|
+
],
|
|
2090
|
+
writeFiles: [{ path: "side-effect.txt", content: "done" }, { path: sessionFile, content: "{}\n" }],
|
|
2091
|
+
keepAliveAfterFinalMessageMs: 5_000,
|
|
2092
|
+
exitCode: 0,
|
|
2093
|
+
});
|
|
2094
|
+
mockPi.onCall({ output: "Recovered asynchronously from retained session" });
|
|
2095
|
+
const id = `async-fallback-provider-after-tool-${Date.now().toString(36)}`;
|
|
2096
|
+
executeAsyncSingle(id, {
|
|
2097
|
+
agent: "worker",
|
|
2098
|
+
task: "Do work",
|
|
2099
|
+
sessionFile,
|
|
2100
|
+
agentConfig: makeAgent("worker", {
|
|
2101
|
+
model: "openai/gpt-5-mini:high",
|
|
2102
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
2103
|
+
}),
|
|
2104
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2105
|
+
availableModels: [
|
|
2106
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2107
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2108
|
+
],
|
|
2109
|
+
artifactConfig: {
|
|
2110
|
+
enabled: false,
|
|
2111
|
+
includeInput: false,
|
|
2112
|
+
includeOutput: false,
|
|
2113
|
+
includeJsonl: false,
|
|
2114
|
+
includeMetadata: false,
|
|
2115
|
+
cleanupDays: 7,
|
|
2116
|
+
},
|
|
2117
|
+
shareEnabled: false,
|
|
2118
|
+
maxSubagentDepth: 2,
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2122
|
+
assert.equal(payload.success, true);
|
|
2123
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
|
|
2124
|
+
assert.deepEqual(payload.results[0].modelAttempts.map((attempt: { success: boolean }) => attempt.success), [false, true]);
|
|
2125
|
+
assert.equal(mockPi.callCount(), 2);
|
|
2126
|
+
const firstArgs = readMockPiArgs(mockPi, 0);
|
|
2127
|
+
const resumedArgs = readMockPiArgs(mockPi, 1);
|
|
2128
|
+
assert.equal(firstArgs[firstArgs.indexOf("--session") + 1], sessionFile);
|
|
2129
|
+
assert.equal(resumedArgs[resumedArgs.indexOf("--session") + 1], sessionFile);
|
|
2130
|
+
assert.match(resumedArgs.at(-1) ?? "", /Continue from the current files and transcript/);
|
|
2131
|
+
assert.equal(fs.readFileSync(path.join(tempDir, "side-effect.txt"), "utf-8"), "done");
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it("background compaction abort without a retained session does not fall back", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2135
|
+
mockPi.onCall({
|
|
2136
|
+
jsonl: [
|
|
2137
|
+
events.assistantMessage("I inspected the source."),
|
|
2138
|
+
{ type: "compaction_start" },
|
|
2139
|
+
{
|
|
2140
|
+
type: "message_end",
|
|
2141
|
+
message: {
|
|
2142
|
+
role: "assistant",
|
|
2143
|
+
content: [],
|
|
2144
|
+
model: "openai/gpt-5-mini",
|
|
2145
|
+
stopReason: "aborted",
|
|
2146
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
2147
|
+
},
|
|
2148
|
+
},
|
|
2149
|
+
{ type: "agent_settled" },
|
|
2150
|
+
],
|
|
2151
|
+
keepAliveAfterFinalMessageMs: 5_000,
|
|
2152
|
+
exitCode: 0,
|
|
2153
|
+
});
|
|
2154
|
+
mockPi.onCall({ output: "Fallback must not run" });
|
|
2155
|
+
const id = `async-compaction-abort-no-session-${Date.now().toString(36)}`;
|
|
2156
|
+
executeAsyncSingle(id, {
|
|
2157
|
+
agent: "worker",
|
|
2158
|
+
task: "Do work",
|
|
2159
|
+
agentConfig: makeAgent("worker", {
|
|
2160
|
+
model: "openai/gpt-5-mini:high",
|
|
2161
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
2162
|
+
}),
|
|
2163
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2164
|
+
availableModels: [
|
|
2165
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
2166
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
2167
|
+
],
|
|
2168
|
+
artifactConfig: {
|
|
2169
|
+
enabled: false,
|
|
2170
|
+
includeInput: false,
|
|
2171
|
+
includeOutput: false,
|
|
2172
|
+
includeJsonl: false,
|
|
2173
|
+
includeMetadata: false,
|
|
2174
|
+
cleanupDays: 7,
|
|
2175
|
+
},
|
|
2176
|
+
shareEnabled: false,
|
|
2177
|
+
maxSubagentDepth: 2,
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2181
|
+
assert.equal(payload.success, false);
|
|
2182
|
+
assert.match(payload.results[0]?.error ?? "", /Compaction-induced child abort could not be resumed safely: retained session unavailable\./);
|
|
2183
|
+
assert.doesNotMatch(payload.results[0]?.output ?? "", /Fallback must not run/);
|
|
2184
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
it("background does not use compaction recovery for a generic empty assistant abort after a compaction retry", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2188
|
+
const sessionFile = path.join(tempDir, "async-generic-empty-after-compaction-retry-session.jsonl");
|
|
2189
|
+
mockPi.onCall({
|
|
2190
|
+
jsonl: [
|
|
2191
|
+
{ type: "compaction_start" },
|
|
2192
|
+
{ type: "compaction_end", willRetry: true },
|
|
2193
|
+
{ type: "agent_settled" },
|
|
2194
|
+
{ type: "agent_start" },
|
|
2195
|
+
events.assistantMessage("The compaction retry produced useful output.", "openai/gpt-5-mini"),
|
|
2196
|
+
{ type: "agent_settled" },
|
|
2197
|
+
{
|
|
2198
|
+
type: "message_end",
|
|
2199
|
+
message: {
|
|
2200
|
+
role: "assistant",
|
|
2201
|
+
content: [],
|
|
2202
|
+
model: "openai/gpt-5-mini",
|
|
2203
|
+
stopReason: "aborted",
|
|
2204
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
{ type: "agent_settled" },
|
|
2208
|
+
],
|
|
2209
|
+
writeFiles: [{ path: sessionFile, content: "{}\n" }],
|
|
2210
|
+
exitCode: 0,
|
|
2211
|
+
});
|
|
2212
|
+
mockPi.onCall({ output: "Compaction recovery must not run" });
|
|
2213
|
+
const id = `async-no-compaction-recovery-after-retry-${Date.now().toString(36)}`;
|
|
2214
|
+
executeAsyncSingle(id, {
|
|
2215
|
+
agent: "worker",
|
|
2216
|
+
task: "Do work",
|
|
2217
|
+
sessionFile,
|
|
2218
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
2219
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2220
|
+
availableModels: [{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" }],
|
|
2221
|
+
artifactConfig: {
|
|
2222
|
+
enabled: false,
|
|
2223
|
+
includeInput: false,
|
|
2224
|
+
includeOutput: false,
|
|
2225
|
+
includeJsonl: false,
|
|
2226
|
+
includeMetadata: false,
|
|
2227
|
+
cleanupDays: 7,
|
|
2228
|
+
},
|
|
2229
|
+
shareEnabled: false,
|
|
2230
|
+
maxSubagentDepth: 2,
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
2234
|
+
assert.equal(payload.success, false);
|
|
2235
|
+
assert.match(payload.results[0]?.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
|
|
2236
|
+
assert.equal(payload.results[0]?.modelAttempts?.length, 1);
|
|
2237
|
+
assert.equal(mockPi.callCount(), 1);
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
});
|