@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,2268 @@
|
|
|
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 { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { childSessionFactoryModule, setChildSessionFactoryModule } from "../../src/runs/shared/child-session.ts";
|
|
16
|
+
import { createEventBus, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir } from "../support/helpers.ts";
|
|
17
|
+
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
18
|
+
import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
|
|
19
|
+
import { deriveForkPromptCacheKey } from "../../src/runs/shared/child-tool-plan.ts";
|
|
20
|
+
import type { AsyncExecutionResult, AsyncResultPayload, AsyncStatusPayload } from "../support/async-execution-fixture.ts";
|
|
21
|
+
import {
|
|
22
|
+
installAsyncExecutionHooks, waitForMockPiRuntime, available, isAsyncAvailable,
|
|
23
|
+
executeAsyncSingle, executeAsyncChain, ASYNC_DIR, RESULTS_DIR, TEMP_ROOT_DIR,
|
|
24
|
+
createSubagentExecutor, escapeRegExp, createRepo, writePackageSkill,
|
|
25
|
+
waitForAsyncResultFile, waitForAsyncEvent, waitForAsyncState, waitForMockPiCall,
|
|
26
|
+
readLastMockPiArgs, readMockPiArgs, readMockPiArgsMatching, tempDir, mockPi,
|
|
27
|
+
makeAsyncExecutor, readAsyncPayload, observeSharedCwdRunner,
|
|
28
|
+
} from "../support/async-execution-fixture.ts";
|
|
29
|
+
|
|
30
|
+
describe("async execution utilities", { skip: !available ? "pi packages not available" : undefined }, () => {
|
|
31
|
+
installAsyncExecutionHooks();
|
|
32
|
+
|
|
33
|
+
it("background does not use compaction recovery after compaction_end willRetry false and a continued agent turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
34
|
+
const sessionFile = path.join(tempDir, "async-generic-empty-after-successful-compaction-session.jsonl");
|
|
35
|
+
mockPi.onCall({
|
|
36
|
+
jsonl: [
|
|
37
|
+
{ type: "compaction_start" },
|
|
38
|
+
{ type: "compaction_end", willRetry: false },
|
|
39
|
+
{ type: "agent_start" },
|
|
40
|
+
{
|
|
41
|
+
type: "message_end",
|
|
42
|
+
message: {
|
|
43
|
+
role: "assistant",
|
|
44
|
+
content: [],
|
|
45
|
+
model: "openai/gpt-5-mini",
|
|
46
|
+
stopReason: "aborted",
|
|
47
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{ type: "agent_settled" },
|
|
51
|
+
],
|
|
52
|
+
writeFiles: [{ path: sessionFile, content: "{}\n" }],
|
|
53
|
+
exitCode: 0,
|
|
54
|
+
});
|
|
55
|
+
mockPi.onCall({ output: "Compaction recovery must not run" });
|
|
56
|
+
const id = `async-no-compaction-recovery-after-successful-compaction-${Date.now().toString(36)}`;
|
|
57
|
+
executeAsyncSingle(id, {
|
|
58
|
+
agent: "worker",
|
|
59
|
+
task: "Do work",
|
|
60
|
+
sessionFile,
|
|
61
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
62
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
63
|
+
availableModels: [{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" }],
|
|
64
|
+
artifactConfig: {
|
|
65
|
+
enabled: false,
|
|
66
|
+
includeInput: false,
|
|
67
|
+
includeOutput: false,
|
|
68
|
+
includeJsonl: false,
|
|
69
|
+
includeMetadata: false,
|
|
70
|
+
cleanupDays: 7,
|
|
71
|
+
},
|
|
72
|
+
shareEnabled: false,
|
|
73
|
+
maxSubagentDepth: 2,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
|
|
77
|
+
assert.equal(payload.success, false);
|
|
78
|
+
assert.match(payload.results[0]?.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
|
|
79
|
+
assert.equal(payload.results[0]?.modelAttempts?.length, 1);
|
|
80
|
+
assert.equal(mockPi.callCount(), 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("background single thinking override replaces primary and fallback suffixes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
84
|
+
mockPi.onCall({
|
|
85
|
+
jsonl: [{
|
|
86
|
+
type: "message_end",
|
|
87
|
+
message: {
|
|
88
|
+
role: "assistant",
|
|
89
|
+
content: [{ type: "text", text: "primary failed" }],
|
|
90
|
+
model: "openai/gpt-5-mini",
|
|
91
|
+
errorMessage: "rate limit exceeded",
|
|
92
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
93
|
+
},
|
|
94
|
+
}],
|
|
95
|
+
exitCode: 1,
|
|
96
|
+
});
|
|
97
|
+
mockPi.onCall({ output: "Recovered asynchronously" });
|
|
98
|
+
const id = `async-fallback-thinking-off-${Date.now().toString(36)}`;
|
|
99
|
+
const run = executeAsyncSingle(id, {
|
|
100
|
+
agent: "worker",
|
|
101
|
+
task: "Do work",
|
|
102
|
+
agentConfig: makeAgent("worker", {
|
|
103
|
+
model: "openai/gpt-5-mini:high",
|
|
104
|
+
fallbackModels: ["anthropic/claude-sonnet-4:low"],
|
|
105
|
+
thinking: "high",
|
|
106
|
+
}),
|
|
107
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
108
|
+
availableModels: [
|
|
109
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
110
|
+
{ provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
|
|
111
|
+
],
|
|
112
|
+
artifactConfig: {
|
|
113
|
+
enabled: false,
|
|
114
|
+
includeInput: false,
|
|
115
|
+
includeOutput: false,
|
|
116
|
+
includeJsonl: false,
|
|
117
|
+
includeMetadata: false,
|
|
118
|
+
cleanupDays: 7,
|
|
119
|
+
},
|
|
120
|
+
shareEnabled: false,
|
|
121
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
122
|
+
thinkingOverride: "off",
|
|
123
|
+
maxSubagentDepth: 2,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(run.details.asyncId, id);
|
|
127
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
128
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
129
|
+
const firstArgs = readMockPiArgs(mockPi, 0);
|
|
130
|
+
const secondArgs = readMockPiArgs(mockPi, 1);
|
|
131
|
+
assert.equal(payload.success, true);
|
|
132
|
+
assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:off");
|
|
133
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:off", "anthropic/claude-sonnet-4:off"]);
|
|
134
|
+
assert.equal(firstArgs[firstArgs.indexOf("--model") + 1], "openai/gpt-5-mini:off");
|
|
135
|
+
assert.equal(secondArgs[secondArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4:off");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("background runs retry fallback models when a zero-exit attempt has empty output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
139
|
+
mockPi.onCall({
|
|
140
|
+
jsonl: [{
|
|
141
|
+
type: "message_end",
|
|
142
|
+
message: {
|
|
143
|
+
role: "assistant",
|
|
144
|
+
content: [{ type: "text", text: "" }],
|
|
145
|
+
model: "openai/gpt-5-mini",
|
|
146
|
+
stopReason: "error",
|
|
147
|
+
usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
148
|
+
},
|
|
149
|
+
}],
|
|
150
|
+
exitCode: 0,
|
|
151
|
+
});
|
|
152
|
+
mockPi.onCall({ output: "Recovered asynchronously from empty output" });
|
|
153
|
+
const id = `async-empty-output-fallback-${Date.now().toString(36)}`;
|
|
154
|
+
executeAsyncSingle(id, {
|
|
155
|
+
agent: "worker",
|
|
156
|
+
task: "Do work",
|
|
157
|
+
agentConfig: makeAgent("worker", {
|
|
158
|
+
model: "openai/gpt-5-mini",
|
|
159
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
160
|
+
}),
|
|
161
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
162
|
+
artifactConfig: {
|
|
163
|
+
enabled: false,
|
|
164
|
+
includeInput: false,
|
|
165
|
+
includeOutput: false,
|
|
166
|
+
includeJsonl: false,
|
|
167
|
+
includeMetadata: false,
|
|
168
|
+
cleanupDays: 7,
|
|
169
|
+
},
|
|
170
|
+
shareEnabled: false,
|
|
171
|
+
maxSubagentDepth: 2,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
175
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
176
|
+
assert.equal(payload.success, true);
|
|
177
|
+
assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4");
|
|
178
|
+
assert.match(payload.results[0]?.output ?? "", /Recovered asynchronously from empty output/);
|
|
179
|
+
assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i);
|
|
180
|
+
assert.deepEqual(payload.results[0]?.modelAttempts?.map((attempt) => attempt.success), [false, true]);
|
|
181
|
+
assert.equal(mockPi.callCount(), 2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("background fails a zero-exit child that stops during a tool after earlier assistant output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
185
|
+
mockPi.onCall({
|
|
186
|
+
jsonl: [
|
|
187
|
+
events.assistantMessage("Work is in progress"),
|
|
188
|
+
events.toolStart("bash", { command: "write files" }),
|
|
189
|
+
],
|
|
190
|
+
exitCode: 0,
|
|
191
|
+
});
|
|
192
|
+
const id = `async-mid-tool-exit-${Date.now().toString(36)}`;
|
|
193
|
+
executeAsyncSingle(id, {
|
|
194
|
+
agent: "worker",
|
|
195
|
+
task: "Do work",
|
|
196
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
197
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
198
|
+
artifactConfig: {
|
|
199
|
+
enabled: false,
|
|
200
|
+
includeInput: false,
|
|
201
|
+
includeOutput: false,
|
|
202
|
+
includeJsonl: false,
|
|
203
|
+
includeMetadata: false,
|
|
204
|
+
cleanupDays: 7,
|
|
205
|
+
},
|
|
206
|
+
shareEnabled: false,
|
|
207
|
+
maxSubagentDepth: 2,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
211
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
212
|
+
assert.equal(payload.success, false);
|
|
213
|
+
assert.equal(payload.results[0]?.success, false);
|
|
214
|
+
assert.match(payload.results[0]?.error ?? "", /ended during 'bash' tool execution before the tool completed/);
|
|
215
|
+
assert.match(payload.results[0]?.error ?? "", /Earlier assistant output is not a terminal result/);
|
|
216
|
+
assert.doesNotMatch(payload.results[0]?.error ?? "", /cold-start/);
|
|
217
|
+
assert.equal(mockPi.callCount(), 1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("background retains an earlier open tool when a later overlapping tool completes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
221
|
+
mockPi.onCall({
|
|
222
|
+
jsonl: [
|
|
223
|
+
{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "wait" } },
|
|
224
|
+
{ type: "tool_execution_start", toolCallId: "read-1", toolName: "read", args: { path: "README.md" } },
|
|
225
|
+
{ type: "tool_execution_end", toolCallId: "read-1", toolName: "read" },
|
|
226
|
+
],
|
|
227
|
+
exitCode: 0,
|
|
228
|
+
});
|
|
229
|
+
const id = `async-overlap-mid-tool-exit-${Date.now().toString(36)}`;
|
|
230
|
+
executeAsyncSingle(id, {
|
|
231
|
+
agent: "worker",
|
|
232
|
+
task: "Do work",
|
|
233
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
234
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
235
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
236
|
+
shareEnabled: false,
|
|
237
|
+
maxSubagentDepth: 2,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
241
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
242
|
+
assert.equal(payload.success, false);
|
|
243
|
+
assert.match(payload.results[0]?.error ?? "", /ended during 'bash' tool execution before the tool completed/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
for (const terminal of [
|
|
247
|
+
{ name: "empty text stop", content: [{ type: "text", text: "" }], stopReason: "stop", error: /no output.*empty response/i },
|
|
248
|
+
{ name: "tool-call-only stop", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "README.md" } }], stopReason: "toolUse", error: /grep failed.*Path not found/i },
|
|
249
|
+
{ name: "empty text length limit", content: [{ type: "text", text: "" }], stopReason: "length", error: /grep failed.*Path not found/i },
|
|
250
|
+
]) {
|
|
251
|
+
it(`background diagnoses ${terminal.name} after an exploratory tool error`, { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
252
|
+
mockPi.onCall({
|
|
253
|
+
stdoutRaw: [
|
|
254
|
+
events.toolResult("grep", "Path not found", true),
|
|
255
|
+
events.toolResult("read", "recovered file contents"),
|
|
256
|
+
{
|
|
257
|
+
type: "message_end",
|
|
258
|
+
message: {
|
|
259
|
+
role: "assistant",
|
|
260
|
+
content: terminal.content,
|
|
261
|
+
model: "openai/gpt-5-mini",
|
|
262
|
+
stopReason: terminal.stopReason,
|
|
263
|
+
usage: { input: 0, output: 4, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
].map((event) => JSON.stringify(event)).join("\n"),
|
|
267
|
+
exitCode: 0,
|
|
268
|
+
});
|
|
269
|
+
const id = `async-terminal-diagnosis-${Date.now().toString(36)}`;
|
|
270
|
+
executeAsyncSingle(id, {
|
|
271
|
+
agent: "worker",
|
|
272
|
+
task: "Do work",
|
|
273
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
274
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
275
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
276
|
+
shareEnabled: false,
|
|
277
|
+
maxSubagentDepth: 2,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
281
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
282
|
+
assert.equal(payload.success, false);
|
|
283
|
+
assert.equal(payload.exitCode, 1);
|
|
284
|
+
assert.match(payload.results[0]?.error ?? "", terminal.error);
|
|
285
|
+
assert.equal(payload.results[0]?.output, "");
|
|
286
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
287
|
+
assert.match(status.steps?.[0]?.error ?? "", terminal.error);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
it("background runs prefer empty-output fallback over an earlier tool error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
292
|
+
mockPi.onCall({
|
|
293
|
+
jsonl: [
|
|
294
|
+
events.toolResult("read", "ENOENT: no such file or directory", true),
|
|
295
|
+
events.toolResult("read", "recovered file contents"),
|
|
296
|
+
{
|
|
297
|
+
type: "message_end",
|
|
298
|
+
message: {
|
|
299
|
+
role: "assistant",
|
|
300
|
+
content: [],
|
|
301
|
+
model: "openai/gpt-5-mini",
|
|
302
|
+
stopReason: "stop",
|
|
303
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
exitCode: 0,
|
|
308
|
+
});
|
|
309
|
+
mockPi.onCall({ output: "Recovered asynchronously on fallback" });
|
|
310
|
+
const id = `async-empty-output-after-tool-error-${Date.now().toString(36)}`;
|
|
311
|
+
executeAsyncSingle(id, {
|
|
312
|
+
agent: "worker",
|
|
313
|
+
task: "Do work",
|
|
314
|
+
agentConfig: makeAgent("worker", {
|
|
315
|
+
model: "openai/gpt-5-mini",
|
|
316
|
+
fallbackModels: ["anthropic/claude-sonnet-4"],
|
|
317
|
+
}),
|
|
318
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
319
|
+
artifactConfig: {
|
|
320
|
+
enabled: false,
|
|
321
|
+
includeInput: false,
|
|
322
|
+
includeOutput: false,
|
|
323
|
+
includeJsonl: false,
|
|
324
|
+
includeMetadata: false,
|
|
325
|
+
cleanupDays: 7,
|
|
326
|
+
},
|
|
327
|
+
shareEnabled: false,
|
|
328
|
+
maxSubagentDepth: 2,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
332
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
333
|
+
assert.equal(payload.success, true);
|
|
334
|
+
assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4");
|
|
335
|
+
assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i);
|
|
336
|
+
assert.equal(mockPi.callCount(), 2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("background runs fail zero-exit provider errors when no fallback succeeds", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
340
|
+
mockPi.onCall({
|
|
341
|
+
jsonl: [{
|
|
342
|
+
type: "message_end",
|
|
343
|
+
message: {
|
|
344
|
+
role: "assistant",
|
|
345
|
+
content: [{ type: "text", text: "quota hit" }],
|
|
346
|
+
model: "openai/gpt-5-mini",
|
|
347
|
+
errorMessage: "429 quota exceeded",
|
|
348
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
349
|
+
},
|
|
350
|
+
}],
|
|
351
|
+
exitCode: 0,
|
|
352
|
+
});
|
|
353
|
+
const id = `async-zero-exit-provider-error-${Date.now().toString(36)}`;
|
|
354
|
+
executeAsyncSingle(id, {
|
|
355
|
+
agent: "worker",
|
|
356
|
+
task: "Do work",
|
|
357
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
358
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
359
|
+
artifactConfig: {
|
|
360
|
+
enabled: false,
|
|
361
|
+
includeInput: false,
|
|
362
|
+
includeOutput: false,
|
|
363
|
+
includeJsonl: false,
|
|
364
|
+
includeMetadata: false,
|
|
365
|
+
cleanupDays: 7,
|
|
366
|
+
},
|
|
367
|
+
shareEnabled: false,
|
|
368
|
+
maxSubagentDepth: 2,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
372
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
373
|
+
assert.equal(payload.success, false);
|
|
374
|
+
assert.match(payload.results[0]?.error ?? "", /429 quota exceeded/);
|
|
375
|
+
const statusPayload = await waitForAsyncState(id, (status) => status.state === "failed");
|
|
376
|
+
assert.match(statusPayload.steps?.[0]?.error ?? "", /429 quota exceeded/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("background runs treat recovered child errors as successful", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
380
|
+
mockPi.onCall({
|
|
381
|
+
jsonl: [
|
|
382
|
+
events.toolResult("read", "EISDIR: illegal operation on a directory", true),
|
|
383
|
+
{
|
|
384
|
+
type: "message_end",
|
|
385
|
+
message: {
|
|
386
|
+
role: "assistant",
|
|
387
|
+
content: [{ type: "text", text: "temporary provider failure" }],
|
|
388
|
+
model: "openai/gpt-5-mini",
|
|
389
|
+
stopReason: "error",
|
|
390
|
+
errorMessage: "provider transport failed",
|
|
391
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
events.assistantMessage("Recovered asynchronously"),
|
|
395
|
+
],
|
|
396
|
+
});
|
|
397
|
+
const id = `async-recovered-child-error-${Date.now().toString(36)}`;
|
|
398
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
399
|
+
executeAsyncSingle(id, {
|
|
400
|
+
agent: "worker",
|
|
401
|
+
task: "Do work",
|
|
402
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
403
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
404
|
+
artifactConfig: {
|
|
405
|
+
enabled: false,
|
|
406
|
+
includeInput: false,
|
|
407
|
+
includeOutput: false,
|
|
408
|
+
includeJsonl: false,
|
|
409
|
+
includeMetadata: false,
|
|
410
|
+
cleanupDays: 7,
|
|
411
|
+
},
|
|
412
|
+
shareEnabled: false,
|
|
413
|
+
maxSubagentDepth: 2,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
417
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
418
|
+
assert.equal(payload.success, true);
|
|
419
|
+
assert.equal(payload.state, "complete");
|
|
420
|
+
assert.equal(payload.exitCode, 0);
|
|
421
|
+
assert.equal(payload.results[0]?.success, true);
|
|
422
|
+
assert.equal(payload.results[0]?.error, undefined);
|
|
423
|
+
assert.equal(payload.results[0]?.output, "Recovered asynchronously");
|
|
424
|
+
const statusPayload = await waitForAsyncState(id, (candidate) => candidate.state === "complete");
|
|
425
|
+
assert.equal(statusPayload.state, "complete");
|
|
426
|
+
assert.equal(statusPayload.steps?.[0]?.status, "complete");
|
|
427
|
+
assert.equal(statusPayload.steps?.[0]?.exitCode, 0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("background runs keep provider errors failed when followed only by empty assistant output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
431
|
+
mockPi.onCall({
|
|
432
|
+
jsonl: [
|
|
433
|
+
{
|
|
434
|
+
type: "message_end",
|
|
435
|
+
message: {
|
|
436
|
+
role: "assistant",
|
|
437
|
+
content: [{ type: "text", text: "temporary provider failure" }],
|
|
438
|
+
model: "openai/gpt-5-mini",
|
|
439
|
+
stopReason: "error",
|
|
440
|
+
errorMessage: "provider transport failed",
|
|
441
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
events.assistantMessage(""),
|
|
445
|
+
],
|
|
446
|
+
});
|
|
447
|
+
const id = `async-provider-error-empty-stop-${Date.now().toString(36)}`;
|
|
448
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
449
|
+
executeAsyncSingle(id, {
|
|
450
|
+
agent: "worker",
|
|
451
|
+
task: "Do work",
|
|
452
|
+
agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
|
|
453
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
454
|
+
artifactConfig: {
|
|
455
|
+
enabled: false,
|
|
456
|
+
includeInput: false,
|
|
457
|
+
includeOutput: false,
|
|
458
|
+
includeJsonl: false,
|
|
459
|
+
includeMetadata: false,
|
|
460
|
+
cleanupDays: 7,
|
|
461
|
+
},
|
|
462
|
+
shareEnabled: false,
|
|
463
|
+
maxSubagentDepth: 2,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
467
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
468
|
+
assert.equal(payload.success, false);
|
|
469
|
+
assert.equal(payload.state, "failed");
|
|
470
|
+
assert.equal(payload.exitCode, 1);
|
|
471
|
+
assert.equal(payload.results[0]?.success, false);
|
|
472
|
+
assert.match(payload.results[0]?.error ?? "", /provider transport failed/);
|
|
473
|
+
assert.equal(payload.results[0]?.output, "");
|
|
474
|
+
const statusPayload = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
475
|
+
assert.equal(statusPayload.state, "failed");
|
|
476
|
+
assert.equal(statusPayload.steps?.[0]?.status, "failed");
|
|
477
|
+
assert.equal(statusPayload.steps?.[0]?.exitCode, 1);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("background file-only runs write full output but return only a file reference", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
481
|
+
mockPi.onCall({ output: "async full output\nwith details" });
|
|
482
|
+
const id = `async-file-only-${Date.now().toString(36)}`;
|
|
483
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
484
|
+
const outputPath = path.join(tempDir, "async-file-only.md");
|
|
485
|
+
const run = executeAsyncSingle(id, {
|
|
486
|
+
agent: "analyst",
|
|
487
|
+
task: "Analyze without modifying files",
|
|
488
|
+
agentConfig: makeAgent("analyst", { tools: ["read", "grep", "find", "ls"] }),
|
|
489
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
490
|
+
artifactConfig: {
|
|
491
|
+
enabled: false,
|
|
492
|
+
includeInput: false,
|
|
493
|
+
includeOutput: false,
|
|
494
|
+
includeJsonl: false,
|
|
495
|
+
includeMetadata: false,
|
|
496
|
+
cleanupDays: 7,
|
|
497
|
+
},
|
|
498
|
+
shareEnabled: false,
|
|
499
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
500
|
+
output: outputPath,
|
|
501
|
+
outputMode: "file-only",
|
|
502
|
+
maxSubagentDepth: 2,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
assert.equal(run.details.asyncId, id);
|
|
506
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
507
|
+
const taskArg = call.args.at(-1) ?? "";
|
|
508
|
+
const systemPrompt = call.systemPrompts[0]?.text ?? "";
|
|
509
|
+
for (const instruction of [taskArg, systemPrompt]) {
|
|
510
|
+
assert.match(instruction, /Return the complete artifact in your final response\./);
|
|
511
|
+
assert.match(instruction, /runtime will persist it to exactly this path:/);
|
|
512
|
+
assert.match(instruction, /Do not call contact_supervisor merely because no write-capable tool is available\./);
|
|
513
|
+
assert.doesNotMatch(instruction, /Write your findings to exactly this path/);
|
|
514
|
+
}
|
|
515
|
+
const deadline = Date.now() + 10_000;
|
|
516
|
+
while (!fs.existsSync(resultPath)) {
|
|
517
|
+
if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
518
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
522
|
+
assert.equal(payload.success, true);
|
|
523
|
+
assert.match(payload.summary ?? "", /Output saved to:/);
|
|
524
|
+
assert.match(payload.summary ?? "", /2 lines/);
|
|
525
|
+
assert.doesNotMatch(payload.summary ?? "", /async full output/);
|
|
526
|
+
assert.match(payload.results[0]?.output ?? "", /Output saved to:/);
|
|
527
|
+
assert.doesNotMatch(payload.results[0]?.output ?? "", /async full output/);
|
|
528
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), "async full output\nwith details");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("removes Pi turn-timing telemetry from runtime-persisted background output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
532
|
+
const report = "## Review\n\nVERDICT: FINDINGS";
|
|
533
|
+
const timingFooter = "\x1b[38;2;136;136;136m✻ Turn took 5m 54s (Total time 5m 54s · 2 turns)\x1b[0m";
|
|
534
|
+
mockPi.onCall({ output: `${report}\n\n${timingFooter}` });
|
|
535
|
+
const id = `async-timing-footer-${Date.now().toString(36)}`;
|
|
536
|
+
const outputPath = path.join(tempDir, "async-review.md");
|
|
537
|
+
executeAsyncSingle(id, {
|
|
538
|
+
agent: "reviewer",
|
|
539
|
+
task: "Review without modifying files",
|
|
540
|
+
agentConfig: makeAgent("reviewer", { tools: ["read", "grep", "find", "ls"] }),
|
|
541
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
542
|
+
artifactConfig: {
|
|
543
|
+
enabled: false,
|
|
544
|
+
includeInput: false,
|
|
545
|
+
includeOutput: false,
|
|
546
|
+
includeJsonl: false,
|
|
547
|
+
includeMetadata: false,
|
|
548
|
+
cleanupDays: 7,
|
|
549
|
+
},
|
|
550
|
+
shareEnabled: false,
|
|
551
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
552
|
+
output: outputPath,
|
|
553
|
+
acceptance: false,
|
|
554
|
+
maxSubagentDepth: 2,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
558
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
559
|
+
assert.equal(payload.success, true);
|
|
560
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), report);
|
|
561
|
+
assert.doesNotMatch(payload.summary ?? "", /Turn took/);
|
|
562
|
+
assert.doesNotMatch(payload.results[0]?.output ?? "", /Turn took/);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("background single runs route relative outputs to outputBaseDir", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
566
|
+
mockPi.onCall({ output: "async configured report" });
|
|
567
|
+
const id = `async-configured-output-base-${Date.now().toString(36)}`;
|
|
568
|
+
const outputBaseDir = path.join(tempDir, "async-configured-outputs");
|
|
569
|
+
const run = executeAsyncSingle(id, {
|
|
570
|
+
agent: "researcher",
|
|
571
|
+
task: "Write report",
|
|
572
|
+
agentConfig: makeAgent("researcher", { output: "context.md" }),
|
|
573
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
574
|
+
artifactConfig: {
|
|
575
|
+
enabled: false,
|
|
576
|
+
includeInput: false,
|
|
577
|
+
includeOutput: false,
|
|
578
|
+
includeJsonl: false,
|
|
579
|
+
includeMetadata: false,
|
|
580
|
+
cleanupDays: 7,
|
|
581
|
+
},
|
|
582
|
+
shareEnabled: false,
|
|
583
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
584
|
+
output: "context.md",
|
|
585
|
+
outputBaseDir,
|
|
586
|
+
maxSubagentDepth: 2,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
assert.equal(run.details.asyncId, id);
|
|
590
|
+
const outputPath = path.join(outputBaseDir, "context.md");
|
|
591
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
592
|
+
const taskArg = call.args.at(-1) ?? "";
|
|
593
|
+
assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
|
|
594
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
595
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
596
|
+
assert.equal(payload.success, true);
|
|
597
|
+
assert.equal(fs.readFileSync(outputPath, "utf-8"), "async configured report");
|
|
598
|
+
assert.equal(fs.existsSync(path.join(tempDir, "context.md")), false);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("background single runs make output overrides authoritative in the child system prompt", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
602
|
+
mockPi.onCall({ output: "async override report" });
|
|
603
|
+
const id = `async-output-override-system-prompt-${Date.now().toString(36)}`;
|
|
604
|
+
const outputPath = path.join(tempDir, "async-custom-report.md");
|
|
605
|
+
const run = executeAsyncSingle(id, {
|
|
606
|
+
agent: "researcher",
|
|
607
|
+
task: "Write report",
|
|
608
|
+
agentConfig: makeAgent("researcher", {
|
|
609
|
+
output: "default-report.md",
|
|
610
|
+
systemPrompt: "Output format (`default-report.md`):\n\nWrite the full report to default-report.md.",
|
|
611
|
+
}),
|
|
612
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
613
|
+
artifactConfig: {
|
|
614
|
+
enabled: false,
|
|
615
|
+
includeInput: false,
|
|
616
|
+
includeOutput: false,
|
|
617
|
+
includeJsonl: false,
|
|
618
|
+
includeMetadata: false,
|
|
619
|
+
cleanupDays: 7,
|
|
620
|
+
},
|
|
621
|
+
shareEnabled: false,
|
|
622
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
623
|
+
output: outputPath,
|
|
624
|
+
maxSubagentDepth: 2,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
assert.equal(run.details.asyncId, id);
|
|
628
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
629
|
+
const taskArg = call.args.at(-1) ?? "";
|
|
630
|
+
const systemPrompt = call.systemPrompts[0]?.text ?? "";
|
|
631
|
+
assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
|
|
632
|
+
assert.match(systemPrompt, /Output format \(`default-report\.md`\):/);
|
|
633
|
+
assert.match(systemPrompt, /Runtime output path override:/);
|
|
634
|
+
assert.match(systemPrompt, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
|
|
635
|
+
assert.match(systemPrompt, /Ignore any other output filename or output path mentioned elsewhere/);
|
|
636
|
+
await waitForAsyncResultFile(id);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("background single runs treat string false as disabled output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
640
|
+
mockPi.onCall({ output: "async inline report" });
|
|
641
|
+
const id = `async-string-false-output-${Date.now().toString(36)}`;
|
|
642
|
+
const run = executeAsyncSingle(id, {
|
|
643
|
+
agent: "worker",
|
|
644
|
+
task: "Do work",
|
|
645
|
+
agentConfig: makeAgent("worker", { output: "default-report.md" }),
|
|
646
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
647
|
+
artifactConfig: {
|
|
648
|
+
enabled: false,
|
|
649
|
+
includeInput: false,
|
|
650
|
+
includeOutput: false,
|
|
651
|
+
includeJsonl: false,
|
|
652
|
+
includeMetadata: false,
|
|
653
|
+
cleanupDays: 7,
|
|
654
|
+
},
|
|
655
|
+
shareEnabled: false,
|
|
656
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
657
|
+
output: "false",
|
|
658
|
+
maxSubagentDepth: 2,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
assert.equal(run.details.asyncId, id);
|
|
662
|
+
const resultPath = await waitForAsyncResultFile(id);
|
|
663
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
664
|
+
assert.equal(payload.success, true);
|
|
665
|
+
assert.equal(payload.results[0]?.output, "async inline report");
|
|
666
|
+
assert.doesNotMatch(payload.summary ?? "", /Output saved to:/);
|
|
667
|
+
assert.equal(fs.existsSync(path.join(tempDir, "false")), false);
|
|
668
|
+
assert.equal(fs.existsSync(path.join(tempDir, "default-report.md")), false);
|
|
669
|
+
assert.doesNotMatch(readLastMockPiArgs(mockPi).at(-1) ?? "", /Write your findings to(?: exactly this path)?:/);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("background runs detect hidden tool failures even when the child exits 0", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
673
|
+
mockPi.onCall({
|
|
674
|
+
jsonl: [events.toolResult("bash", "connection refused")],
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const id = `async-hidden-failure-${Date.now().toString(36)}`;
|
|
678
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
679
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
680
|
+
|
|
681
|
+
executeAsyncSingle(id, {
|
|
682
|
+
agent: "worker",
|
|
683
|
+
task: "Deploy app",
|
|
684
|
+
agentConfig: makeAgent("worker"),
|
|
685
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
686
|
+
artifactConfig: {
|
|
687
|
+
enabled: false,
|
|
688
|
+
includeInput: false,
|
|
689
|
+
includeOutput: false,
|
|
690
|
+
includeJsonl: false,
|
|
691
|
+
includeMetadata: false,
|
|
692
|
+
cleanupDays: 7,
|
|
693
|
+
},
|
|
694
|
+
shareEnabled: false,
|
|
695
|
+
sessionRoot,
|
|
696
|
+
maxSubagentDepth: 2,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const deadline = Date.now() + 10_000;
|
|
700
|
+
while (!fs.existsSync(resultPath)) {
|
|
701
|
+
if (Date.now() > deadline) {
|
|
702
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
703
|
+
}
|
|
704
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
708
|
+
assert.equal(payload.success, false);
|
|
709
|
+
assert.equal(payload.exitCode, 1);
|
|
710
|
+
assert.equal(payload.results[0].success, false);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("background completion intent uses disposed child model services before publishing evidence", { timeout: 60_000, skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async (t) => {
|
|
714
|
+
const reviewTask = 'Review the proposal "Implement the approved fixes" and report whether its reasoning is sound.';
|
|
715
|
+
const factoryPath = path.join(tempDir, "intent-factory.mjs");
|
|
716
|
+
const tracePath = path.join(tempDir, "intent-trace.jsonl");
|
|
717
|
+
const casePath = path.join(tempDir, "intent-case.json");
|
|
718
|
+
fs.writeFileSync(factoryPath, `
|
|
719
|
+
import fs from "node:fs";
|
|
720
|
+
import assert from "node:assert/strict";
|
|
721
|
+
import { createDefaultChildSessionFactory } from ${JSON.stringify(new URL("../../src/runs/shared/child-session.ts", import.meta.url).href)};
|
|
722
|
+
import { createAssistantMessageEventStream, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
|
|
723
|
+
const scenario = JSON.parse(fs.readFileSync(${JSON.stringify(casePath)}, "utf8"));
|
|
724
|
+
const trace = event => fs.appendFileSync(${JSON.stringify(tracePath)}, event + "\\n");
|
|
725
|
+
export default function() {
|
|
726
|
+
let disposed = false;
|
|
727
|
+
const model = { provider: "intent-test", id: "child-attempt", api: "intent-test-api" };
|
|
728
|
+
const runtime = { apiKey: "fixture-key" };
|
|
729
|
+
const registry = {
|
|
730
|
+
runtime,
|
|
731
|
+
async getApiKeyAndHeaders() {
|
|
732
|
+
trace("auth");
|
|
733
|
+
assert.ok(disposed, "auth after child disposal");
|
|
734
|
+
return { ok: true, apiKey: this.runtime.apiKey, headers: { "x-intent-test": "fixture-header" } };
|
|
735
|
+
},
|
|
736
|
+
getRegisteredProviderConfig() { return { api: model.api, streamSimple(selected, context, options) {
|
|
737
|
+
trace("stream");
|
|
738
|
+
assert.ok(disposed, "stream after child disposal");
|
|
739
|
+
assert.equal(selected.provider + "/" + selected.id, "intent-test/child-attempt");
|
|
740
|
+
assert.equal(options.apiKey, "fixture-key");
|
|
741
|
+
assert.equal(options.headers["x-intent-test"], "fixture-header");
|
|
742
|
+
const decided = context.messages.some(message => message.role === "toolResult");
|
|
743
|
+
const message = !decided
|
|
744
|
+
? fauxAssistantMessage(fauxToolCall("task_mutation_decision", { classification: scenario.classification ?? "read_only", confidence: "high", reason: "Scripted task intent." }), { stopReason: "toolUse" })
|
|
745
|
+
: fauxAssistantMessage("Review findings: the proposal is sound.", { stopReason: "stop" });
|
|
746
|
+
const stream = createAssistantMessageEventStream();
|
|
747
|
+
queueMicrotask(() => stream.push({ type: "done", reason: message.stopReason, message }));
|
|
748
|
+
return stream;
|
|
749
|
+
} }; },
|
|
750
|
+
};
|
|
751
|
+
// Existing SDK seam: production factory/hooks, scripted child/model services.
|
|
752
|
+
return createDefaultChildSessionFactory({ loadPiCodingAgent: async () => ({
|
|
753
|
+
ModelRuntime: { create: async () => runtime },
|
|
754
|
+
SettingsManager: { create: () => ({}) },
|
|
755
|
+
SessionManager: { inMemory: () => ({}) },
|
|
756
|
+
DefaultResourceLoader: class {
|
|
757
|
+
loaded = false;
|
|
758
|
+
handlers = [];
|
|
759
|
+
constructor(options) { this.options = options; }
|
|
760
|
+
async reload() {
|
|
761
|
+
const pi = { on: (event, handler) => this.handlers.push({ event, handler }), registerTool() {}, events: { on() { return () => {}; }, emit() {} } };
|
|
762
|
+
for (const hook of this.options.extensionFactories) hook.factory(pi);
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
resolveCliModel: ({ cliModel }) => { assert.equal(cliModel, "intent-test/child-attempt"); return { model }; },
|
|
766
|
+
createAgentSession: async ({ resourceLoader, model }) => {
|
|
767
|
+
const ctx = Proxy.revocable({ model, modelRegistry: registry }, {});
|
|
768
|
+
let listener;
|
|
769
|
+
const messages = [];
|
|
770
|
+
return { session: {
|
|
771
|
+
model, messages, sessionId: "intent-child",
|
|
772
|
+
async bindExtensions() { for (const { event, handler } of resourceLoader.handlers) if (event === "session_start") await handler({ type: event }, ctx.proxy); },
|
|
773
|
+
extensionRunner: { hasHandlers: () => false },
|
|
774
|
+
subscribe(next) { listener = next; return () => {}; },
|
|
775
|
+
async prompt() {
|
|
776
|
+
const message = fauxAssistantMessage("Review findings: the proposal is sound.", { stopReason: "stop" }); messages.push(message); listener({ type: "message_end", message });
|
|
777
|
+
},
|
|
778
|
+
async abort() {}, async steer() {}, async followUp() {},
|
|
779
|
+
dispose() { disposed = true; ctx.revoke(); },
|
|
780
|
+
} };
|
|
781
|
+
},
|
|
782
|
+
}) });
|
|
783
|
+
}
|
|
784
|
+
`);
|
|
785
|
+
setChildSessionFactoryModule(factoryPath);
|
|
786
|
+
t.after(() => setChildSessionFactoryModule(fileURLToPath(new URL("../support/runner-child-session-factory.ts", import.meta.url))));
|
|
787
|
+
for (const scenario of [
|
|
788
|
+
{ name: "review-rescue", task: reviewTask, success: true, effect: "not-applicable", calls: true },
|
|
789
|
+
{ name: "implementation", task: "Implement the approved fixes", classification: "implementation", success: false, effect: "missing", calls: true },
|
|
790
|
+
{ name: "ordinary", task: "Summarize the proposal", success: true, effect: "not-applicable", calls: false },
|
|
791
|
+
]) {
|
|
792
|
+
fs.writeFileSync(casePath, JSON.stringify(scenario));
|
|
793
|
+
fs.writeFileSync(tracePath, "");
|
|
794
|
+
const id = `async-intent-${scenario.name}-${Date.now().toString(36)}`;
|
|
795
|
+
const outputPath = path.join(tempDir, `${id}.md`);
|
|
796
|
+
const launched = executeAsyncSingle(id, {
|
|
797
|
+
agent: "worker", task: scenario.task,
|
|
798
|
+
agentConfig: makeAgent("worker", { model: "intent-test/child-attempt" }),
|
|
799
|
+
output: outputPath,
|
|
800
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
801
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
802
|
+
shareEnabled: false, maxSubagentDepth: 2,
|
|
803
|
+
});
|
|
804
|
+
assert.notEqual(launched.isError, true, `${scenario.name}: ${JSON.stringify(launched)}`);
|
|
805
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf8"));
|
|
806
|
+
await waitForAsyncEvent(id, "subagent.run.process_terminal");
|
|
807
|
+
assert.equal(payload.success, scenario.success, `${scenario.name}: ${JSON.stringify(payload)}`);
|
|
808
|
+
assert.equal(payload.results[0].effects?.fileMutation?.status, scenario.effect, scenario.name);
|
|
809
|
+
const trace = fs.readFileSync(tracePath, "utf8");
|
|
810
|
+
assert.equal(trace.includes("auth"), scenario.calls, scenario.name);
|
|
811
|
+
assert.equal(trace.includes("stream"), scenario.calls, scenario.name);
|
|
812
|
+
if (!scenario.success) assert.match(payload.results[0].modelAttempts[0].error, /completed without making edits/, scenario.name);
|
|
813
|
+
if (scenario.name === "review-rescue") {
|
|
814
|
+
assert.equal(payload.results[0].effects.fileMutation.resolvedBy, "llm-intent-arbiter");
|
|
815
|
+
assert.equal(payload.results[0].effects.fileMutation.expected, true);
|
|
816
|
+
assert.equal(payload.results[0].modelAttempts[0].success, true);
|
|
817
|
+
assert.equal(payload.results[0].modelAttempts[0].error, undefined);
|
|
818
|
+
assert.match(fs.readFileSync(outputPath, "utf8"), /Review findings: the proposal is sound/);
|
|
819
|
+
const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf8"));
|
|
820
|
+
assert.equal(status.state, "complete");
|
|
821
|
+
assert.doesNotMatch(fs.readFileSync(path.join(ASYNC_DIR, id, "events.jsonl"), "utf8"), /"reason":"completion_guard"|completed without making edits/);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("background implementation runs fail when no mutation attempt occurred", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
827
|
+
mockPi.onCall({ output: "I’ll do that now and report back after implementing." });
|
|
828
|
+
|
|
829
|
+
const id = `async-no-mutation-${Date.now().toString(36)}`;
|
|
830
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
831
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
832
|
+
|
|
833
|
+
executeAsyncSingle(id, {
|
|
834
|
+
agent: "worker",
|
|
835
|
+
task: "Implement the approved fixes",
|
|
836
|
+
agentConfig: makeAgent("worker"),
|
|
837
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
838
|
+
artifactConfig: {
|
|
839
|
+
enabled: false,
|
|
840
|
+
includeInput: false,
|
|
841
|
+
includeOutput: false,
|
|
842
|
+
includeJsonl: false,
|
|
843
|
+
includeMetadata: false,
|
|
844
|
+
cleanupDays: 7,
|
|
845
|
+
},
|
|
846
|
+
shareEnabled: false,
|
|
847
|
+
sessionRoot,
|
|
848
|
+
maxSubagentDepth: 2,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const deadline = Date.now() + 10_000;
|
|
852
|
+
while (!fs.existsSync(resultPath)) {
|
|
853
|
+
if (Date.now() > deadline) {
|
|
854
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
855
|
+
}
|
|
856
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
860
|
+
assert.equal(payload.success, false);
|
|
861
|
+
assert.equal(payload.exitCode, 1);
|
|
862
|
+
assert.equal(payload.results[0].success, false);
|
|
863
|
+
assert.match(String(payload.results[0].error ?? ""), /completed without making edits/);
|
|
864
|
+
assert.match(String(payload.results[0].modelAttempts?.[0]?.error ?? ""), /completed without making edits/);
|
|
865
|
+
assert.deepEqual(payload.results[0].effects?.settlementDiagnostic?.mutation, {
|
|
866
|
+
expected: true,
|
|
867
|
+
attempted: false,
|
|
868
|
+
observed: false,
|
|
869
|
+
});
|
|
870
|
+
assert.equal(payload.results[0].effects?.settlementDiagnostic?.finalTextPresent, true);
|
|
871
|
+
|
|
872
|
+
const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl");
|
|
873
|
+
const eventsText = fs.readFileSync(eventsPath, "utf-8");
|
|
874
|
+
assert.match(eventsText, /"reason":"completion_guard"/);
|
|
875
|
+
assert.match(eventsText, /Subagent failed: worker/);
|
|
876
|
+
assert.doesNotMatch(eventsText, /Status:/);
|
|
877
|
+
assert.doesNotMatch(eventsText, /Interrupt:/);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("does not use shared-cwd sibling tracked edits as parallel completion-guard proof", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async (t) => {
|
|
881
|
+
mockPi.onCall({
|
|
882
|
+
matchArgIncludes: "Edit tracked file",
|
|
883
|
+
delay: 50,
|
|
884
|
+
writeFiles: [{ path: "input.md", content: "changed by first sibling\n" }],
|
|
885
|
+
jsonl: [
|
|
886
|
+
...events.completedWrite("input.md", "changed by first sibling\n"),
|
|
887
|
+
events.assistantMessage("Implemented first sibling change."),
|
|
888
|
+
],
|
|
889
|
+
});
|
|
890
|
+
mockPi.onCall({
|
|
891
|
+
matchArgIncludes: "Implement second sibling change",
|
|
892
|
+
delay: 500,
|
|
893
|
+
output: "Implemented second sibling change.",
|
|
894
|
+
});
|
|
895
|
+
const repo = createRepo("pi-subagents-shared-cwd-mutation-guard-");
|
|
896
|
+
const id = `async-parallel-shared-cwd-mutation-${Date.now().toString(36)}`;
|
|
897
|
+
let runnerStarted = false;
|
|
898
|
+
const observer = observeSharedCwdRunner(id);
|
|
899
|
+
const originalFactoryModule = childSessionFactoryModule();
|
|
900
|
+
const failures: unknown[] = [];
|
|
901
|
+
const reportFailure = () => observer.reportFailure((message) => t.diagnostic(message));
|
|
902
|
+
try {
|
|
903
|
+
assert.ok(originalFactoryModule, "expected the installed scripted runner factory");
|
|
904
|
+
const factoryPath = path.join(tempDir, "shared-cwd-exit-phases.mjs");
|
|
905
|
+
fs.writeFileSync(factoryPath, `
|
|
906
|
+
import { writeSync } from "node:fs";
|
|
907
|
+
import createFactory from ${JSON.stringify(pathToFileURL(originalFactoryModule).href)};
|
|
908
|
+
export default function() {
|
|
909
|
+
const factory = createFactory();
|
|
910
|
+
let invocation = 0;
|
|
911
|
+
const mark = (phase, call) => writeSync(process.stderr.fd, "#1906 phase=" + phase + " invocation=" + call + " ts=" + Date.now() + " pid=" + process.pid + "\\n");
|
|
912
|
+
process.once("exit", () => mark("exit", 0));
|
|
913
|
+
return {
|
|
914
|
+
create(...args) { return factory.create(...args); },
|
|
915
|
+
async dispose() {
|
|
916
|
+
const call = ++invocation;
|
|
917
|
+
mark("dispose-entry", call);
|
|
918
|
+
const result = await factory.dispose();
|
|
919
|
+
mark("dispose-return", call);
|
|
920
|
+
return result;
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
`);
|
|
925
|
+
setChildSessionFactoryModule(factoryPath);
|
|
926
|
+
const launch = observer.launch(() => executeAsyncChain(id, {
|
|
927
|
+
chain: [{
|
|
928
|
+
parallel: [
|
|
929
|
+
{ agent: "first", task: "Edit tracked file" },
|
|
930
|
+
{ agent: "second", task: "Implement second sibling change" },
|
|
931
|
+
],
|
|
932
|
+
concurrency: 2,
|
|
933
|
+
}],
|
|
934
|
+
resultMode: "parallel",
|
|
935
|
+
agents: [makeAgent("first"), makeAgent("second")],
|
|
936
|
+
ctx: { pi: { events: { emit: observer.emit } }, cwd: repo, currentSessionId: "session-1" },
|
|
937
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
938
|
+
shareEnabled: false,
|
|
939
|
+
maxSubagentDepth: 2,
|
|
940
|
+
}));
|
|
941
|
+
runnerStarted = !launch.isError;
|
|
942
|
+
|
|
943
|
+
const payload = await readAsyncPayload(id);
|
|
944
|
+
observer.marks.payloadReadAt = Date.now();
|
|
945
|
+
assert.equal(payload.results[0]?.success, true);
|
|
946
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.status, "observed");
|
|
947
|
+
assert.equal(payload.results[1]?.success, false);
|
|
948
|
+
assert.equal(payload.results[1]?.effects?.fileMutation?.status, "missing");
|
|
949
|
+
assert.equal(payload.results[1]?.effects?.fileMutation?.attempted, false);
|
|
950
|
+
assert.match(payload.results[1]?.error ?? "", /completed without making edits/);
|
|
951
|
+
observer.marks.assertionsCompletedAt = Date.now();
|
|
952
|
+
} catch (error) {
|
|
953
|
+
failures.push(error);
|
|
954
|
+
} finally {
|
|
955
|
+
try {
|
|
956
|
+
if (runnerStarted) {
|
|
957
|
+
observer.marks.waitStartedAt = Date.now();
|
|
958
|
+
try { await waitForAsyncEvent(id, "subagent.run.process_terminal"); }
|
|
959
|
+
finally { observer.marks.waitFinishedAt = Date.now(); }
|
|
960
|
+
}
|
|
961
|
+
if (failures.length) reportFailure();
|
|
962
|
+
fs.rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
963
|
+
// Validate channel support/correlation without reading artifacts on success.
|
|
964
|
+
const summary = observer.summary();
|
|
965
|
+
t.diagnostic(`#1906 observer ${JSON.stringify(summary)}`);
|
|
966
|
+
if (runnerStarted) {
|
|
967
|
+
assert.equal(summary.correlatedProcesses, 1, "diagnostic channel must capture the started-event runner PID");
|
|
968
|
+
for (const type of ["spawn", "exit", "close"]) assert.ok(summary.processEvents.flat().some((event) => (event as { type: string }).type === type), `diagnostic runner ${type} must be observed`);
|
|
969
|
+
}
|
|
970
|
+
} catch (error) {
|
|
971
|
+
failures.push(error);
|
|
972
|
+
reportFailure();
|
|
973
|
+
} finally {
|
|
974
|
+
setChildSessionFactoryModule(originalFactoryModule);
|
|
975
|
+
observer.dispose();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (failures.length === 1) throw failures[0];
|
|
979
|
+
if (failures.length > 1) throw new AggregateError(failures, "Primary execution and cleanup/diagnostic failures", { cause: failures[0] });
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("background implementation challenges keep explicit no-change reports successful", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
983
|
+
mockPi.onCall({ output: [
|
|
984
|
+
"Kept the current implementation. No new code or test changes were made in this challenge pass.",
|
|
985
|
+
"Reason: the current candidate is the smallest correct shape.",
|
|
986
|
+
].join("\n\n") });
|
|
987
|
+
|
|
988
|
+
const id = `async-no-change-challenge-${Date.now().toString(36)}`;
|
|
989
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
990
|
+
const task = [
|
|
991
|
+
"You are reviving a previous subagent conversation.",
|
|
992
|
+
"",
|
|
993
|
+
"Original run: source-run",
|
|
994
|
+
"Original agent: worker",
|
|
995
|
+
"Original session file: /tmp/source-session.jsonl",
|
|
996
|
+
"",
|
|
997
|
+
"Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.",
|
|
998
|
+
"",
|
|
999
|
+
"Follow-up:",
|
|
1000
|
+
"Implementation challenge pass 1 for the accepted candidate. Reconsider it and implement any better current-scope change.",
|
|
1001
|
+
].join("\n");
|
|
1002
|
+
|
|
1003
|
+
executeAsyncSingle(id, {
|
|
1004
|
+
agent: "worker",
|
|
1005
|
+
task,
|
|
1006
|
+
agentConfig: makeAgent("worker"),
|
|
1007
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1008
|
+
artifactConfig: {
|
|
1009
|
+
enabled: false,
|
|
1010
|
+
includeInput: false,
|
|
1011
|
+
includeOutput: false,
|
|
1012
|
+
includeJsonl: false,
|
|
1013
|
+
includeMetadata: false,
|
|
1014
|
+
cleanupDays: 7,
|
|
1015
|
+
},
|
|
1016
|
+
shareEnabled: false,
|
|
1017
|
+
sessionRoot,
|
|
1018
|
+
maxSubagentDepth: 2,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1022
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1023
|
+
assert.equal(payload.success, true);
|
|
1024
|
+
assert.equal(payload.exitCode, 0);
|
|
1025
|
+
assert.equal(payload.results[0].success, true);
|
|
1026
|
+
assert.match(String(payload.results[0].output), /Kept the current implementation/);
|
|
1027
|
+
assert.doesNotMatch(String(payload.results[0].error ?? ""), /completed without making edits/);
|
|
1028
|
+
|
|
1029
|
+
const eventsText = fs.readFileSync(path.join(ASYNC_DIR, id, "events.jsonl"), "utf-8");
|
|
1030
|
+
assert.doesNotMatch(eventsText, /Subagent failed: worker/);
|
|
1031
|
+
assert.doesNotMatch(eventsText, /"reason":"completion_guard"/);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("agent contract keeps async acceptance and file-mutation effects separate from execution", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1035
|
+
mockPi.onCall({ output: "I’ll do that now and report back after implementing.\n```acceptance-report\n{\"criteriaSatisfied\":[{\"id\":\"criterion-1\",\"status\":\"not-satisfied\",\"evidence\":\"no proof\"}]}\n```" });
|
|
1036
|
+
const id = `async-v1-separate-${Date.now().toString(36)}`;
|
|
1037
|
+
|
|
1038
|
+
executeAsyncSingle(id, {
|
|
1039
|
+
agent: "worker",
|
|
1040
|
+
task: "Implement the approved fixes",
|
|
1041
|
+
agentConfig: makeAgent("worker", { completionGuard: true }),
|
|
1042
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1043
|
+
artifactConfig: {
|
|
1044
|
+
enabled: false,
|
|
1045
|
+
includeInput: false,
|
|
1046
|
+
includeOutput: false,
|
|
1047
|
+
includeJsonl: false,
|
|
1048
|
+
includeMetadata: false,
|
|
1049
|
+
cleanupDays: 7,
|
|
1050
|
+
},
|
|
1051
|
+
shareEnabled: false,
|
|
1052
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1053
|
+
maxSubagentDepth: 2,
|
|
1054
|
+
agentContract: { version: 1 },
|
|
1055
|
+
acceptance: { level: "checked", criteria: ["Return required proof"] },
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1059
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1060
|
+
const statusPayload = await waitForAsyncState(id, (candidate) => candidate.state === "complete");
|
|
1061
|
+
|
|
1062
|
+
assert.equal(payload.success, true);
|
|
1063
|
+
assert.equal(payload.state, "complete");
|
|
1064
|
+
assert.equal(payload.exitCode, 0);
|
|
1065
|
+
assert.equal(payload.results[0]?.agentContract?.version, 1);
|
|
1066
|
+
assert.equal(payload.results[0]?.execution?.status, "completed");
|
|
1067
|
+
assert.equal(payload.results[0]?.execution?.success, true);
|
|
1068
|
+
assert.equal(payload.results[0]?.acceptance?.status, "rejected");
|
|
1069
|
+
assert.equal(payload.results[0]?.effects?.fileMutation?.status, "missing");
|
|
1070
|
+
assert.equal(statusPayload.state, "complete");
|
|
1071
|
+
assert.equal(statusPayload.steps?.[0]?.agentContract?.version, 1);
|
|
1072
|
+
assert.equal(statusPayload.steps?.[0]?.execution?.status, "completed");
|
|
1073
|
+
assert.equal(statusPayload.steps?.[0]?.effects?.fileMutation?.status, "missing");
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("background single runs support outputSchema", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1077
|
+
mockPi.onCall({ output: "structured", structuredOutput: { ok: true, note: "async" } });
|
|
1078
|
+
const id = `async-single-schema-${Date.now().toString(36)}`;
|
|
1079
|
+
|
|
1080
|
+
executeAsyncSingle(id, {
|
|
1081
|
+
agent: "worker",
|
|
1082
|
+
task: "Return structured data",
|
|
1083
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
1084
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1085
|
+
artifactConfig: {
|
|
1086
|
+
enabled: false,
|
|
1087
|
+
includeInput: false,
|
|
1088
|
+
includeOutput: false,
|
|
1089
|
+
includeJsonl: false,
|
|
1090
|
+
includeMetadata: false,
|
|
1091
|
+
cleanupDays: 7,
|
|
1092
|
+
},
|
|
1093
|
+
shareEnabled: false,
|
|
1094
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1095
|
+
maxSubagentDepth: 2,
|
|
1096
|
+
acceptance: false,
|
|
1097
|
+
structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } },
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
|
|
1101
|
+
assert.equal(payload.success, true);
|
|
1102
|
+
assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("background outputSchema runs fail closed when required acceptanceReport is missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1106
|
+
mockPi.onCall({ output: "structured", structuredOutput: { ok: true } });
|
|
1107
|
+
const id = `async-schema-missing-acceptance-${Date.now().toString(36)}`;
|
|
1108
|
+
|
|
1109
|
+
executeAsyncSingle(id, {
|
|
1110
|
+
agent: "worker",
|
|
1111
|
+
task: "Return structured data",
|
|
1112
|
+
agentConfig: makeAgent("worker", { completionGuard: false }),
|
|
1113
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1114
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1115
|
+
shareEnabled: false,
|
|
1116
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1117
|
+
maxSubagentDepth: 2,
|
|
1118
|
+
acceptance: { level: "checked", report: "on" },
|
|
1119
|
+
structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
|
|
1123
|
+
assert.equal(payload.success, false);
|
|
1124
|
+
assert.match(payload.results[0]?.error ?? "", /Missing acceptanceReport/);
|
|
1125
|
+
assert.equal(payload.results[0]?.acceptance?.status, "rejected");
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("background bash-enabled non-implementation agents can opt out of the completion guard", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1129
|
+
mockPi.onCall({ output: "cold start test after patch" });
|
|
1130
|
+
|
|
1131
|
+
const id = `async-completion-guard-optout-${Date.now().toString(36)}`;
|
|
1132
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
1133
|
+
|
|
1134
|
+
executeAsyncSingle(id, {
|
|
1135
|
+
agent: "test-runner",
|
|
1136
|
+
task: "Run cold start test after patch",
|
|
1137
|
+
agentConfig: makeAgent("test-runner", { tools: ["read", "grep", "bash", "ls"], completionGuard: false }),
|
|
1138
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1139
|
+
artifactConfig: {
|
|
1140
|
+
enabled: false,
|
|
1141
|
+
includeInput: false,
|
|
1142
|
+
includeOutput: false,
|
|
1143
|
+
includeJsonl: false,
|
|
1144
|
+
includeMetadata: false,
|
|
1145
|
+
cleanupDays: 7,
|
|
1146
|
+
},
|
|
1147
|
+
shareEnabled: false,
|
|
1148
|
+
sessionRoot,
|
|
1149
|
+
maxSubagentDepth: 2,
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1153
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1154
|
+
assert.equal(payload.success, true);
|
|
1155
|
+
assert.equal(payload.exitCode, 0);
|
|
1156
|
+
assert.equal(payload.results[0].success, true);
|
|
1157
|
+
assert.equal(payload.results[0].output, "cold start test after patch");
|
|
1158
|
+
|
|
1159
|
+
const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl");
|
|
1160
|
+
const eventsText = fs.readFileSync(eventsPath, "utf-8");
|
|
1161
|
+
assert.doesNotMatch(eventsText, /"reason":"completion_guard"/);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it("background runs prefer the parent session provider for ambiguous bare model ids", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1165
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1166
|
+
|
|
1167
|
+
const id = `async-provider-${Date.now().toString(36)}`;
|
|
1168
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
1169
|
+
const sessionRoot = path.join(tempDir, "sessions");
|
|
1170
|
+
|
|
1171
|
+
executeAsyncSingle(id, {
|
|
1172
|
+
agent: "worker",
|
|
1173
|
+
task: "Do work",
|
|
1174
|
+
agentConfig: makeAgent("worker", { model: "gpt-5-mini" }),
|
|
1175
|
+
ctx: {
|
|
1176
|
+
pi: { events: { emit() {} } },
|
|
1177
|
+
cwd: tempDir,
|
|
1178
|
+
currentSessionId: "session-1",
|
|
1179
|
+
currentModelProvider: "github-copilot",
|
|
1180
|
+
},
|
|
1181
|
+
availableModels: [
|
|
1182
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
|
|
1183
|
+
{ provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
|
|
1184
|
+
],
|
|
1185
|
+
artifactConfig: {
|
|
1186
|
+
enabled: false,
|
|
1187
|
+
includeInput: false,
|
|
1188
|
+
includeOutput: false,
|
|
1189
|
+
includeJsonl: false,
|
|
1190
|
+
includeMetadata: false,
|
|
1191
|
+
cleanupDays: 7,
|
|
1192
|
+
},
|
|
1193
|
+
shareEnabled: false,
|
|
1194
|
+
sessionRoot,
|
|
1195
|
+
maxSubagentDepth: 2,
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const deadline = Date.now() + 10_000;
|
|
1199
|
+
while (!fs.existsSync(resultPath)) {
|
|
1200
|
+
if (Date.now() > deadline) {
|
|
1201
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
1202
|
+
}
|
|
1203
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
1207
|
+
assert.equal(payload.success, true);
|
|
1208
|
+
assert.equal(payload.results[0].model, "github-copilot/gpt-5-mini");
|
|
1209
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["github-copilot/gpt-5-mini"]);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("rejects an over-cap top-level async launch before creating run artifacts", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1213
|
+
fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey("session-cap")), { recursive: true, force: true });
|
|
1214
|
+
const state = {
|
|
1215
|
+
baseCwd: tempDir,
|
|
1216
|
+
currentSessionId: "session-cap",
|
|
1217
|
+
asyncJobs: new Map(),
|
|
1218
|
+
fleetJobs: new Map(),
|
|
1219
|
+
foregroundControls: new Map(),
|
|
1220
|
+
lastForegroundControlId: null,
|
|
1221
|
+
};
|
|
1222
|
+
const occupied = acquireActiveAsyncCapacity({
|
|
1223
|
+
sessionId: "session-cap",
|
|
1224
|
+
limit: 1,
|
|
1225
|
+
runId: "held-run",
|
|
1226
|
+
kind: "runner",
|
|
1227
|
+
asyncDir: path.join(tempDir, "held-run"),
|
|
1228
|
+
});
|
|
1229
|
+
assert.ok(occupied);
|
|
1230
|
+
occupied.markStarted("held-runner");
|
|
1231
|
+
const rejectedAsyncDir = path.join(ASYNC_DIR, "cap-rejected");
|
|
1232
|
+
const rejectedResultPath = path.join(RESULTS_DIR, "cap-rejected.json");
|
|
1233
|
+
fs.rmSync(rejectedAsyncDir, { recursive: true, force: true });
|
|
1234
|
+
fs.rmSync(rejectedResultPath, { force: true });
|
|
1235
|
+
const executor = createSubagentExecutor!({
|
|
1236
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1237
|
+
state,
|
|
1238
|
+
config: { maxActiveAsyncRunsPerSession: 1, artifactDir: "project" },
|
|
1239
|
+
asyncByDefault: false,
|
|
1240
|
+
tempArtifactsDir: tempDir,
|
|
1241
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1242
|
+
expandTilde: (p: string) => p,
|
|
1243
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1244
|
+
});
|
|
1245
|
+
const context = makeMinimalCtx(tempDir);
|
|
1246
|
+
context.sessionManager.getSessionFile = () => null;
|
|
1247
|
+
context.sessionManager.getSessionId = () => "session-cap";
|
|
1248
|
+
const previousDepth = process.env.SELESAI_SUBAGENT_DEPTH;
|
|
1249
|
+
process.env.SELESAI_SUBAGENT_DEPTH = "0";
|
|
1250
|
+
const result = await executor.execute("cap-rejected", { agent: "worker", task: "Must not start", async: true }, new AbortController().signal, undefined, context);
|
|
1251
|
+
if (previousDepth === undefined) delete process.env.SELESAI_SUBAGENT_DEPTH;
|
|
1252
|
+
else process.env.SELESAI_SUBAGENT_DEPTH = previousDepth;
|
|
1253
|
+
assert.equal(result.isError, true);
|
|
1254
|
+
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Active async run capacity exhausted: 1\/1 used/);
|
|
1255
|
+
assert.equal(fs.existsSync(rejectedAsyncDir), false);
|
|
1256
|
+
assert.equal(fs.existsSync(rejectedResultPath), false);
|
|
1257
|
+
assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents")), false);
|
|
1258
|
+
fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey("session-cap")), { recursive: true, force: true });
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("scheduled executor launches retain the live active session ownership", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1262
|
+
mockPi.onCall({ output: "Scheduled work completed" });
|
|
1263
|
+
const liveCwd = path.join(tempDir, "live-project");
|
|
1264
|
+
fs.mkdirSync(liveCwd);
|
|
1265
|
+
const state = {
|
|
1266
|
+
baseCwd: liveCwd,
|
|
1267
|
+
currentSessionId: "session-b",
|
|
1268
|
+
lastParentModel: { provider: "deepseek", id: "live-model" },
|
|
1269
|
+
subagentSpawns: { sessionId: "session-b", count: 1, configuredLimit: 1, granted: 1, grantHistory: [] },
|
|
1270
|
+
asyncJobs: new Map(),
|
|
1271
|
+
fleetJobs: new Map(),
|
|
1272
|
+
foregroundControls: new Map(),
|
|
1273
|
+
lastForegroundControlId: null,
|
|
1274
|
+
};
|
|
1275
|
+
const executor = createSubagentExecutor!({
|
|
1276
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1277
|
+
state,
|
|
1278
|
+
config: { maxSubagentSpawnsPerSession: 1 },
|
|
1279
|
+
asyncByDefault: false,
|
|
1280
|
+
tempArtifactsDir: tempDir,
|
|
1281
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1282
|
+
expandTilde: (p: string) => p,
|
|
1283
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1284
|
+
});
|
|
1285
|
+
const retainedCtx = makeMinimalCtx(tempDir);
|
|
1286
|
+
retainedCtx.sessionManager.getSessionId = () => "session-a";
|
|
1287
|
+
retainedCtx.model = { provider: "deepseek", id: "scheduled-model" };
|
|
1288
|
+
const launch = await executor.executeScheduled(
|
|
1289
|
+
`scheduled-owner-${Date.now().toString(36)}`,
|
|
1290
|
+
{ agent: "worker", task: "Run retained project timer", async: true, acceptance: false },
|
|
1291
|
+
new AbortController().signal,
|
|
1292
|
+
retainedCtx,
|
|
1293
|
+
) as AsyncExecutionResult;
|
|
1294
|
+
|
|
1295
|
+
assert.equal(launch.isError, undefined);
|
|
1296
|
+
assert.ok(launch.details.asyncId);
|
|
1297
|
+
assert.equal(state.currentSessionId, "session-b");
|
|
1298
|
+
assert.equal(state.baseCwd, liveCwd);
|
|
1299
|
+
assert.deepEqual(state.lastParentModel, { provider: "deepseek", id: "live-model" });
|
|
1300
|
+
assert.deepEqual(state.subagentSpawns, { sessionId: "session-b", count: 1, configuredLimit: 1, granted: 1, grantHistory: [] });
|
|
1301
|
+
const blocked = await executor.executeScheduled(
|
|
1302
|
+
`scheduled-owner-blocked-${Date.now().toString(36)}`,
|
|
1303
|
+
{ agent: "worker", task: "Exceed the retained owner budget", async: true, acceptance: false },
|
|
1304
|
+
new AbortController().signal,
|
|
1305
|
+
retainedCtx,
|
|
1306
|
+
);
|
|
1307
|
+
assert.equal(blocked.isError, true);
|
|
1308
|
+
assert.match(blocked.content[0]?.type === "text" ? blocked.content[0].text : "", /1\/1 used/);
|
|
1309
|
+
assert.deepEqual(state.subagentSpawns, { sessionId: "session-b", count: 1, configuredLimit: 1, granted: 1, grantHistory: [] });
|
|
1310
|
+
await readAsyncPayload(launch.details.asyncId);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it("scheduled owners without a model do not inherit the live session model", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1314
|
+
mockPi.onCall({ output: "Scheduled owner work completed" });
|
|
1315
|
+
const liveCwd = path.join(tempDir, "live-model-project");
|
|
1316
|
+
fs.mkdirSync(liveCwd);
|
|
1317
|
+
const state = {
|
|
1318
|
+
baseCwd: liveCwd,
|
|
1319
|
+
currentSessionId: "session-live",
|
|
1320
|
+
lastParentModel: { provider: "router", id: "live-model" },
|
|
1321
|
+
asyncJobs: new Map(),
|
|
1322
|
+
fleetJobs: new Map(),
|
|
1323
|
+
foregroundControls: new Map(),
|
|
1324
|
+
lastForegroundControlId: null,
|
|
1325
|
+
};
|
|
1326
|
+
const executor = createSubagentExecutor!({
|
|
1327
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1328
|
+
state,
|
|
1329
|
+
config: {},
|
|
1330
|
+
asyncByDefault: false,
|
|
1331
|
+
tempArtifactsDir: tempDir,
|
|
1332
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1333
|
+
expandTilde: (p: string) => p,
|
|
1334
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1335
|
+
});
|
|
1336
|
+
const retainedCtx = makeMinimalCtx(tempDir);
|
|
1337
|
+
retainedCtx.sessionManager.getSessionId = () => "session-scheduled-owner";
|
|
1338
|
+
|
|
1339
|
+
const launch = await executor.executeScheduled(
|
|
1340
|
+
`scheduled-owner-no-model-${Date.now().toString(36)}`,
|
|
1341
|
+
{ agent: "worker", task: "Run owner without model", async: true, acceptance: false },
|
|
1342
|
+
new AbortController().signal,
|
|
1343
|
+
retainedCtx,
|
|
1344
|
+
) as AsyncExecutionResult;
|
|
1345
|
+
assert.equal(launch.isError, undefined);
|
|
1346
|
+
assert.ok(launch.details.asyncId);
|
|
1347
|
+
|
|
1348
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1349
|
+
assert.equal(payload.success, true);
|
|
1350
|
+
assert.equal(state.currentSessionId, "session-live");
|
|
1351
|
+
assert.deepEqual(state.lastParentModel, { provider: "router", id: "live-model" });
|
|
1352
|
+
const args = readMockPiArgsMatching(mockPi, "Run owner without model");
|
|
1353
|
+
assert.equal(args.includes("router/live-model"), false);
|
|
1354
|
+
assert.equal(args.includes("--model"), false);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it("scheduled workflows keep owner status and registries isolated from the live session", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1358
|
+
const liveCwd = path.join(tempDir, "live-workflow-project");
|
|
1359
|
+
fs.mkdirSync(liveCwd);
|
|
1360
|
+
const state = {
|
|
1361
|
+
baseCwd: liveCwd,
|
|
1362
|
+
currentSessionId: "session-b",
|
|
1363
|
+
subagentSpawns: { sessionId: "session-b", count: 4, configuredLimit: 5, granted: 0, grantHistory: [] },
|
|
1364
|
+
asyncJobs: new Map(),
|
|
1365
|
+
fleetJobs: new Map(),
|
|
1366
|
+
foregroundControls: new Map(),
|
|
1367
|
+
lastForegroundControlId: null,
|
|
1368
|
+
};
|
|
1369
|
+
const executor = createSubagentExecutor!({
|
|
1370
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1371
|
+
state,
|
|
1372
|
+
config: { maxSubagentSpawnsPerSession: 5 },
|
|
1373
|
+
asyncByDefault: false,
|
|
1374
|
+
tempArtifactsDir: tempDir,
|
|
1375
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1376
|
+
expandTilde: (p: string) => p,
|
|
1377
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1378
|
+
});
|
|
1379
|
+
const retainedCtx = makeMinimalCtx(tempDir);
|
|
1380
|
+
retainedCtx.sessionManager.getSessionId = () => "session-a";
|
|
1381
|
+
const workflowId = `scheduled-workflow-${Date.now().toString(36)}`;
|
|
1382
|
+
const launch = await executor.executeScheduled(
|
|
1383
|
+
workflowId,
|
|
1384
|
+
{ workflowScript: `return await runs.status("${workflowId}")`, async: true },
|
|
1385
|
+
new AbortController().signal,
|
|
1386
|
+
retainedCtx,
|
|
1387
|
+
) as AsyncExecutionResult;
|
|
1388
|
+
|
|
1389
|
+
assert.equal(launch.isError, undefined);
|
|
1390
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
1391
|
+
assert.equal(state.fleetJobs.size, 0);
|
|
1392
|
+
assert.deepEqual(state.subagentSpawns, { sessionId: "session-b", count: 4, configuredLimit: 5, granted: 0, grantHistory: [] });
|
|
1393
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1394
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
1395
|
+
assert.equal(state.fleetJobs.size, 0);
|
|
1396
|
+
assert.match(payload.workflow.value.output, /Spawn budget: 0\/5 used/);
|
|
1397
|
+
assert.match(payload.workflow.value.output, new RegExp(workflowId));
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it("async executor keeps the last parent session model after continuation drops ctx.model", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1401
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1402
|
+
const state = {
|
|
1403
|
+
baseCwd: tempDir,
|
|
1404
|
+
currentSessionId: null,
|
|
1405
|
+
asyncJobs: new Map(),
|
|
1406
|
+
foregroundControls: new Map(),
|
|
1407
|
+
lastForegroundControlId: null,
|
|
1408
|
+
};
|
|
1409
|
+
const executor = createSubagentExecutor!({
|
|
1410
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1411
|
+
state,
|
|
1412
|
+
config: {},
|
|
1413
|
+
asyncByDefault: false,
|
|
1414
|
+
tempArtifactsDir: tempDir,
|
|
1415
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1416
|
+
expandTilde: (p: string) => p,
|
|
1417
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1418
|
+
});
|
|
1419
|
+
const initialCtx = makeMinimalCtx(tempDir);
|
|
1420
|
+
initialCtx.sessionManager.getSessionId = () => "session-continued";
|
|
1421
|
+
initialCtx.model = { provider: "deepseek", id: "deepseek-v4-flash" };
|
|
1422
|
+
await executor.execute("prime-parent-model", { action: "list" }, new AbortController().signal, undefined, initialCtx);
|
|
1423
|
+
|
|
1424
|
+
const continuedCtx = makeMinimalCtx(tempDir);
|
|
1425
|
+
continuedCtx.sessionManager.getSessionId = () => "session-continued";
|
|
1426
|
+
const launch = await executor.execute(
|
|
1427
|
+
"continued-async-child",
|
|
1428
|
+
{ agent: "worker", task: "Do work", async: true, acceptance: false },
|
|
1429
|
+
new AbortController().signal,
|
|
1430
|
+
undefined,
|
|
1431
|
+
continuedCtx,
|
|
1432
|
+
) as AsyncExecutionResult;
|
|
1433
|
+
assert.equal(launch.isError, undefined);
|
|
1434
|
+
assert.ok(launch.details.asyncId);
|
|
1435
|
+
|
|
1436
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1437
|
+
assert.equal(payload.success, true);
|
|
1438
|
+
assert.equal(payload.results[0]?.model, "deepseek/deepseek-v4-flash");
|
|
1439
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1440
|
+
assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it("async workflows snapshot the parent model before workflow setup reads session data", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1444
|
+
mockPi.onCall({ output: "Inherited model work" });
|
|
1445
|
+
mockPi.onCall({ output: "Explicit model work" });
|
|
1446
|
+
const state = {
|
|
1447
|
+
baseCwd: tempDir,
|
|
1448
|
+
currentSessionId: null,
|
|
1449
|
+
asyncJobs: new Map(),
|
|
1450
|
+
foregroundControls: new Map(),
|
|
1451
|
+
lastForegroundControlId: null,
|
|
1452
|
+
};
|
|
1453
|
+
const executor = createSubagentExecutor!({
|
|
1454
|
+
pi: { events: createEventBus(), getSessionName: () => undefined },
|
|
1455
|
+
state,
|
|
1456
|
+
config: {},
|
|
1457
|
+
asyncByDefault: false,
|
|
1458
|
+
tempArtifactsDir: tempDir,
|
|
1459
|
+
getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
|
|
1460
|
+
expandTilde: (p: string) => p,
|
|
1461
|
+
discoverAgents: () => ({ agents: [makeAgent("worker")] }),
|
|
1462
|
+
});
|
|
1463
|
+
const context = makeMinimalCtx(tempDir);
|
|
1464
|
+
context.sessionManager.getSessionId = () => "session-workflow-parent-model";
|
|
1465
|
+
context.model = { provider: "router", id: "openai-personal" };
|
|
1466
|
+
context.sessionManager.getSessionFile = () => {
|
|
1467
|
+
context.model = undefined;
|
|
1468
|
+
return null;
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
const launch = await executor.execute(
|
|
1472
|
+
"workflow-parent-model",
|
|
1473
|
+
{ workflowScript: `await runs.run("inherited", { agent: "worker", task: "Do inherited work" }); return runs.run("explicit", { agent: "worker", task: "Do explicit work", model: "openai/gpt-5-mini" });`, async: true },
|
|
1474
|
+
new AbortController().signal,
|
|
1475
|
+
undefined,
|
|
1476
|
+
context,
|
|
1477
|
+
) as AsyncExecutionResult;
|
|
1478
|
+
assert.equal(launch.isError, undefined);
|
|
1479
|
+
assert.ok(launch.details.asyncId);
|
|
1480
|
+
|
|
1481
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1482
|
+
assert.equal(payload.success, true);
|
|
1483
|
+
const inheritedArgs = readMockPiArgsMatching(mockPi, "Do inherited work");
|
|
1484
|
+
const explicitArgs = readMockPiArgsMatching(mockPi, "Do explicit work");
|
|
1485
|
+
assert.equal(inheritedArgs[inheritedArgs.indexOf("--model") + 1], "router/openai-personal");
|
|
1486
|
+
assert.equal(explicitArgs[explicitArgs.indexOf("--model") + 1], "openai/gpt-5-mini");
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it("background single runs inherit the parent session model when no model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1490
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1491
|
+
|
|
1492
|
+
const id = `async-single-parent-model-${Date.now().toString(36)}`;
|
|
1493
|
+
executeAsyncSingle(id, {
|
|
1494
|
+
agent: "worker",
|
|
1495
|
+
task: "Do work",
|
|
1496
|
+
agentConfig: makeAgent("worker"),
|
|
1497
|
+
ctx: {
|
|
1498
|
+
pi: { events: { emit() {} } },
|
|
1499
|
+
cwd: tempDir,
|
|
1500
|
+
currentSessionId: "session-1",
|
|
1501
|
+
currentModelProvider: "deepseek",
|
|
1502
|
+
currentModel: { provider: "deepseek", id: "deepseek-v4-flash" },
|
|
1503
|
+
},
|
|
1504
|
+
artifactConfig: {
|
|
1505
|
+
enabled: false,
|
|
1506
|
+
includeInput: false,
|
|
1507
|
+
includeOutput: false,
|
|
1508
|
+
includeJsonl: false,
|
|
1509
|
+
includeMetadata: false,
|
|
1510
|
+
cleanupDays: 7,
|
|
1511
|
+
},
|
|
1512
|
+
shareEnabled: false,
|
|
1513
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1514
|
+
maxSubagentDepth: 2,
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1518
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1519
|
+
assert.equal(payload.success, true);
|
|
1520
|
+
assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash");
|
|
1521
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]);
|
|
1522
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1523
|
+
assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it("background forked runs use an available fallback when the configured primary is unavailable", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1527
|
+
mockPi.onCall({ output: "Forked async work" });
|
|
1528
|
+
const parentSessionFile = path.join(tempDir, "parent-pruned-fallback.jsonl");
|
|
1529
|
+
const forkedSessionFile = path.join(tempDir, "forked-pruned-fallback.jsonl");
|
|
1530
|
+
const sessionHeader = { type: "session", version: 1, id: "child", cwd: fs.realpathSync(tempDir), parentSession: parentSessionFile };
|
|
1531
|
+
fs.writeFileSync(parentSessionFile, `${JSON.stringify({ type: "session", version: 1, id: "parent", cwd: fs.realpathSync(tempDir) })}\n`, "utf-8");
|
|
1532
|
+
fs.writeFileSync(forkedSessionFile, `${JSON.stringify(sessionHeader)}\n`, "utf-8");
|
|
1533
|
+
const pruner = { provider: "test", id: "pruner", api: "faux", maxTokens: 1024 };
|
|
1534
|
+
const ctx = {
|
|
1535
|
+
...makeMinimalCtx(tempDir),
|
|
1536
|
+
modelRegistry: {
|
|
1537
|
+
getAvailable: () => [
|
|
1538
|
+
{ provider: "mock", id: "fallback" },
|
|
1539
|
+
pruner,
|
|
1540
|
+
],
|
|
1541
|
+
find: (provider: string, id: string) => provider === "test" && id === "pruner" ? pruner : undefined,
|
|
1542
|
+
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "faux" }),
|
|
1543
|
+
},
|
|
1544
|
+
sessionManager: {
|
|
1545
|
+
getSessionId: () => "session-configured-fallback",
|
|
1546
|
+
getSessionFile: () => parentSessionFile,
|
|
1547
|
+
getLeafId: () => "leaf-current",
|
|
1548
|
+
openSession: () => ({ createBranchedSession: () => forkedSessionFile }),
|
|
1549
|
+
},
|
|
1550
|
+
};
|
|
1551
|
+
const launch = await makeAsyncExecutor([
|
|
1552
|
+
makeAgent("worker", {
|
|
1553
|
+
model: "mock/missing-primary",
|
|
1554
|
+
fallbackModels: ["mock/fallback"],
|
|
1555
|
+
completionGuard: false,
|
|
1556
|
+
}),
|
|
1557
|
+
], { forkContext: { mode: "pruned", model: "test/pruner" } }).execute(
|
|
1558
|
+
"forked-configured-fallback",
|
|
1559
|
+
{ agent: "worker", task: "Do work", async: true, context: "fork" },
|
|
1560
|
+
new AbortController().signal,
|
|
1561
|
+
undefined,
|
|
1562
|
+
ctx,
|
|
1563
|
+
) as AsyncExecutionResult;
|
|
1564
|
+
assert.ok(!launch.isError, launch.content[0]?.text);
|
|
1565
|
+
assert.ok(launch.details.asyncId);
|
|
1566
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1567
|
+
assert.equal(payload.results[0]?.model, "mock/fallback");
|
|
1568
|
+
assert.deepEqual(payload.results[0]?.attemptedModels, ["mock/fallback"]);
|
|
1569
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1570
|
+
assert.equal(args[args.indexOf("--model") + 1], "mock/fallback");
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it("revival preserves captured response aliases and their absence after config changes", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1574
|
+
const route = "databricks-bedrock/ias-claude-opus-5";
|
|
1575
|
+
const agents = [makeAgent("worker", { model: route, completionGuard: false })];
|
|
1576
|
+
const cases = [
|
|
1577
|
+
{ original: { [route]: ["original-echo"] }, current: {}, echo: "original-echo", success: true },
|
|
1578
|
+
{ original: { [route]: ["original-echo"] }, current: { [route]: ["new-echo"] }, echo: "new-echo", success: false },
|
|
1579
|
+
{ original: undefined, current: { [route]: ["new-echo"] }, echo: "new-echo", success: false },
|
|
1580
|
+
];
|
|
1581
|
+
for (const [index, scenario] of cases.entries()) {
|
|
1582
|
+
const parentSessionFile = path.join(tempDir, `alias-parent-${index}.jsonl`);
|
|
1583
|
+
const sessionFile = path.join(tempDir, `alias-child-${index}.jsonl`);
|
|
1584
|
+
const header = JSON.stringify({ type: "session", version: 1, id: `alias-${index}`, cwd: fs.realpathSync(tempDir) });
|
|
1585
|
+
fs.writeFileSync(parentSessionFile, `${header}\n`);
|
|
1586
|
+
fs.writeFileSync(sessionFile, `${header}\n`);
|
|
1587
|
+
const ctx = {
|
|
1588
|
+
...makeMinimalCtx(tempDir),
|
|
1589
|
+
modelRegistry: { getAvailable: () => [{ provider: "databricks-bedrock", id: "ias-claude-opus-5" }] },
|
|
1590
|
+
sessionManager: {
|
|
1591
|
+
getSessionId: () => `alias-session-${index}`,
|
|
1592
|
+
getSessionFile: () => parentSessionFile,
|
|
1593
|
+
getLeafId: () => "leaf",
|
|
1594
|
+
openSession: () => ({ createBranchedSession: () => sessionFile }),
|
|
1595
|
+
},
|
|
1596
|
+
};
|
|
1597
|
+
mockPi.onCall({ jsonl: [events.assistantMessage("Initial work", route)] });
|
|
1598
|
+
const launch = await makeAsyncExecutor(agents, { modelResponseAliases: scenario.original }).execute(
|
|
1599
|
+
`alias-launch-${index}`, { agent: "worker", task: "Do work", async: true, context: "fork", acceptance: false },
|
|
1600
|
+
new AbortController().signal, undefined, ctx,
|
|
1601
|
+
) as AsyncExecutionResult;
|
|
1602
|
+
assert.ok(!launch.isError, launch.content[0]?.text);
|
|
1603
|
+
assert.ok(launch.details.asyncId);
|
|
1604
|
+
assert.equal((await readAsyncPayload(launch.details.asyncId)).success, true);
|
|
1605
|
+
|
|
1606
|
+
// A new executor must recover the durable launch declaration, not its current settings.
|
|
1607
|
+
mockPi.onCall({ jsonl: [events.assistantMessage("Continued work", scenario.echo)] });
|
|
1608
|
+
const resumed = await makeAsyncExecutor(agents, { modelResponseAliases: scenario.current }).execute(
|
|
1609
|
+
`alias-revive-${index}`, { action: "resume", id: launch.details.asyncId, message: "Continue", acceptance: false },
|
|
1610
|
+
new AbortController().signal, undefined, ctx,
|
|
1611
|
+
) as AsyncExecutionResult;
|
|
1612
|
+
assert.ok(!resumed.isError, resumed.content[0]?.text);
|
|
1613
|
+
assert.ok(resumed.details.asyncId);
|
|
1614
|
+
const payload = await readAsyncPayload(resumed.details.asyncId);
|
|
1615
|
+
assert.equal(payload.success, scenario.success, `case ${index}: ${payload.results[0]?.error}`);
|
|
1616
|
+
if (!scenario.success) assert.match(payload.results[0]?.error ?? "", /model_verification_failed/);
|
|
1617
|
+
const args = readMockPiArgs(mockPi, index * 2 + 1);
|
|
1618
|
+
assert.equal(args[args.indexOf("--model") + 1], route);
|
|
1619
|
+
assert.equal(args[args.indexOf("--session") + 1], sessionFile);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it("aligns initial and resumed background forked sessions with an explicit child cwd", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1624
|
+
mockPi.onCall({ output: "Forked async work" });
|
|
1625
|
+
const parentCwd = fs.realpathSync(tempDir);
|
|
1626
|
+
const childCwd = path.join(tempDir, "child-cwd");
|
|
1627
|
+
fs.mkdirSync(childCwd);
|
|
1628
|
+
const parentSessionFile = path.join(tempDir, "parent-cross-cwd.jsonl");
|
|
1629
|
+
const forkedSessionFile = path.join(tempDir, "forked-cross-cwd.jsonl");
|
|
1630
|
+
const parentHeader = { type: "session", version: 1, id: "parent", cwd: parentCwd };
|
|
1631
|
+
const childHeader = { type: "session", version: 1, id: "child", cwd: parentCwd, parentSession: parentSessionFile };
|
|
1632
|
+
fs.writeFileSync(parentSessionFile, `${JSON.stringify(parentHeader)}\n`, "utf-8");
|
|
1633
|
+
fs.writeFileSync(forkedSessionFile, `${JSON.stringify(childHeader)}\n`, "utf-8");
|
|
1634
|
+
const ctx = {
|
|
1635
|
+
...makeMinimalCtx(parentCwd),
|
|
1636
|
+
sessionManager: {
|
|
1637
|
+
getSessionId: () => "session-cross-cwd",
|
|
1638
|
+
getSessionFile: () => parentSessionFile,
|
|
1639
|
+
getLeafId: () => "leaf-current",
|
|
1640
|
+
openSession: () => ({ createBranchedSession: () => forkedSessionFile }),
|
|
1641
|
+
},
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
const executor = makeAsyncExecutor([makeAgent("worker")]);
|
|
1645
|
+
const launch = await executor.execute(
|
|
1646
|
+
"forked-cross-cwd",
|
|
1647
|
+
{ agent: "worker", task: "Do work", async: true, context: "fork", cwd: childCwd },
|
|
1648
|
+
new AbortController().signal,
|
|
1649
|
+
undefined,
|
|
1650
|
+
ctx,
|
|
1651
|
+
) as AsyncExecutionResult;
|
|
1652
|
+
assert.ok(!launch.isError, launch.content[0]?.text);
|
|
1653
|
+
assert.ok(launch.details.asyncId);
|
|
1654
|
+
await readAsyncPayload(launch.details.asyncId);
|
|
1655
|
+
|
|
1656
|
+
const sessionHeader = JSON.parse(fs.readFileSync(forkedSessionFile, "utf-8").split("\n", 1)[0]!) as { cwd?: string };
|
|
1657
|
+
assert.equal(sessionHeader.cwd, fs.realpathSync.native(childCwd));
|
|
1658
|
+
|
|
1659
|
+
fs.writeFileSync(forkedSessionFile, `${JSON.stringify(childHeader)}\n`, "utf-8");
|
|
1660
|
+
mockPi.onCall({ output: "Resumed async work" });
|
|
1661
|
+
const resumed = await executor.execute(
|
|
1662
|
+
"resume-cross-cwd",
|
|
1663
|
+
{ action: "resume", id: launch.details.asyncId, message: "Continue" },
|
|
1664
|
+
new AbortController().signal,
|
|
1665
|
+
undefined,
|
|
1666
|
+
ctx,
|
|
1667
|
+
) as AsyncExecutionResult;
|
|
1668
|
+
assert.ok(!resumed.isError, resumed.content[0]?.text);
|
|
1669
|
+
assert.ok(resumed.details.asyncId);
|
|
1670
|
+
await readAsyncPayload(resumed.details.asyncId);
|
|
1671
|
+
|
|
1672
|
+
const resumedHeader = JSON.parse(fs.readFileSync(forkedSessionFile, "utf-8").split("\n", 1)[0]!) as { cwd?: string };
|
|
1673
|
+
assert.equal(resumedHeader.cwd, fs.realpathSync.native(childCwd));
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
it("background forked runs inherit a parent model outside the registry", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1677
|
+
mockPi.onCall({ output: "Forked async work" });
|
|
1678
|
+
const parentSessionFile = path.join(tempDir, "parent.jsonl");
|
|
1679
|
+
const forkedSessionFile = path.join(tempDir, "forked.jsonl");
|
|
1680
|
+
const sessionHeader = JSON.stringify({ type: "session", cwd: fs.realpathSync(tempDir) });
|
|
1681
|
+
fs.writeFileSync(parentSessionFile, `${sessionHeader}\n`, "utf-8");
|
|
1682
|
+
fs.writeFileSync(forkedSessionFile, `${sessionHeader}\n`, "utf-8");
|
|
1683
|
+
const ctx = {
|
|
1684
|
+
...makeMinimalCtx(tempDir),
|
|
1685
|
+
model: { provider: "gateway", id: "parent-model" },
|
|
1686
|
+
modelRegistry: { getAvailable: () => [{ provider: "openai", id: "gpt-5-mini" }] },
|
|
1687
|
+
sessionManager: {
|
|
1688
|
+
getSessionId: () => "session-123",
|
|
1689
|
+
getSessionFile: () => parentSessionFile,
|
|
1690
|
+
getLeafId: () => "leaf-current",
|
|
1691
|
+
openSession: () => ({ createBranchedSession: () => forkedSessionFile }),
|
|
1692
|
+
},
|
|
1693
|
+
};
|
|
1694
|
+
const launch = await makeAsyncExecutor([makeAgent("worker")]).execute(
|
|
1695
|
+
"forked-parent-model",
|
|
1696
|
+
{ agent: "worker", task: "Do work", async: true, context: "fork" },
|
|
1697
|
+
new AbortController().signal,
|
|
1698
|
+
undefined,
|
|
1699
|
+
ctx,
|
|
1700
|
+
) as AsyncExecutionResult;
|
|
1701
|
+
assert.ok(!launch.isError, launch.content[0]?.text);
|
|
1702
|
+
assert.ok(launch.details.asyncId);
|
|
1703
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1704
|
+
assert.equal(payload.results[0]?.model, "gateway/parent-model");
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
it("background forked runs receive the derived fork cache key", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1708
|
+
mockPi.onCall({ output: "cache affinity inspected" });
|
|
1709
|
+
const parentSessionFile = path.join(tempDir, "parent-cache.jsonl");
|
|
1710
|
+
const forkedSessionFile = path.join(tempDir, "forked-cache.jsonl");
|
|
1711
|
+
const sessionHeader = JSON.stringify({ type: "session", cwd: fs.realpathSync(tempDir) });
|
|
1712
|
+
fs.writeFileSync(parentSessionFile, `${sessionHeader}\n`, "utf-8");
|
|
1713
|
+
fs.writeFileSync(forkedSessionFile, `${sessionHeader}\n`, "utf-8");
|
|
1714
|
+
const ctx = {
|
|
1715
|
+
...makeMinimalCtx(tempDir),
|
|
1716
|
+
sessionManager: {
|
|
1717
|
+
getSessionId: () => "session-cache-parent",
|
|
1718
|
+
getSessionFile: () => parentSessionFile,
|
|
1719
|
+
getLeafId: () => "leaf-current",
|
|
1720
|
+
openSession: () => ({ createBranchedSession: () => forkedSessionFile }),
|
|
1721
|
+
},
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1724
|
+
const launch = await makeAsyncExecutor([makeAgent("worker", { completionGuard: false })]).execute(
|
|
1725
|
+
"forked-cache-key",
|
|
1726
|
+
{ agent: "worker", task: "Inspect cache affinity", async: true, context: "fork" },
|
|
1727
|
+
new AbortController().signal,
|
|
1728
|
+
undefined,
|
|
1729
|
+
ctx,
|
|
1730
|
+
) as AsyncExecutionResult;
|
|
1731
|
+
assert.ok(!launch.isError, launch.content[0]?.text);
|
|
1732
|
+
assert.ok(launch.details.asyncId);
|
|
1733
|
+
|
|
1734
|
+
const payload = await readAsyncPayload(launch.details.asyncId);
|
|
1735
|
+
assert.equal(payload.success, true);
|
|
1736
|
+
assert.equal((await waitForMockPiRuntime(mockPi, 0)).forkCacheKey, deriveForkPromptCacheKey("session-cache-parent"));
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("revives an inherited parent model outside the current registry", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
|
|
1740
|
+
mockPi.onCall({ output: "Initial async work" });
|
|
1741
|
+
const sourceId = `async-revive-parent-model-${Date.now().toString(36)}`;
|
|
1742
|
+
const sessionFile = path.join(tempDir, "sessions", "source.jsonl");
|
|
1743
|
+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
1744
|
+
fs.writeFileSync(sessionFile, "", "utf-8");
|
|
1745
|
+
executeAsyncSingle(sourceId, {
|
|
1746
|
+
agent: "worker",
|
|
1747
|
+
task: "Initial work",
|
|
1748
|
+
agentConfig: makeAgent("worker"),
|
|
1749
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-123" },
|
|
1750
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1751
|
+
shareEnabled: false,
|
|
1752
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1753
|
+
sessionFile,
|
|
1754
|
+
modelOverride: "gateway/parent-model",
|
|
1755
|
+
modelOverrideFromParent: true,
|
|
1756
|
+
maxSubagentDepth: 2,
|
|
1757
|
+
});
|
|
1758
|
+
await readAsyncPayload(sourceId);
|
|
1759
|
+
const descriptor = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, sourceId, "recovery-descriptor.json"), "utf-8"));
|
|
1760
|
+
assert.equal(descriptor.modelOverrideFromParent, true);
|
|
1761
|
+
assert.equal(descriptor.modelOrigin, "inherited");
|
|
1762
|
+
|
|
1763
|
+
mockPi.onCall({ output: "Revived async work" });
|
|
1764
|
+
const result = await makeAsyncExecutor([makeAgent("worker")]).execute(
|
|
1765
|
+
"revive-parent-model",
|
|
1766
|
+
{ action: "resume", id: sourceId, message: "Continue" },
|
|
1767
|
+
new AbortController().signal,
|
|
1768
|
+
undefined,
|
|
1769
|
+
makeMinimalCtx(tempDir),
|
|
1770
|
+
) as AsyncExecutionResult;
|
|
1771
|
+
assert.ok(!result.isError, result.content[0]?.text);
|
|
1772
|
+
assert.ok(result.details.asyncId);
|
|
1773
|
+
const payload = await readAsyncPayload(result.details.asyncId);
|
|
1774
|
+
assert.equal(payload.results[0]?.model, "gateway/parent-model");
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
it("reports the retained discovery context when a resumed target agent is missing", { skip: !createSubagentExecutor ? "executor not available" : undefined }, async () => {
|
|
1778
|
+
const runId = `resume-missing-agent-${Date.now().toString(36)}`;
|
|
1779
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
1780
|
+
const sessionFile = path.join(tempDir, "missing-agent-session.jsonl");
|
|
1781
|
+
const visible = makeAgent("visible");
|
|
1782
|
+
visible.source = "project";
|
|
1783
|
+
const evidenceDir = path.join(tempDir, "request-discovery", "agents");
|
|
1784
|
+
try {
|
|
1785
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
1786
|
+
fs.writeFileSync(sessionFile, "", "utf-8");
|
|
1787
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
1788
|
+
runId,
|
|
1789
|
+
sessionId: "session-123",
|
|
1790
|
+
mode: "single",
|
|
1791
|
+
state: "complete",
|
|
1792
|
+
startedAt: 100,
|
|
1793
|
+
lastUpdate: 200,
|
|
1794
|
+
cwd: tempDir,
|
|
1795
|
+
sessionFile,
|
|
1796
|
+
steps: [{ agent: "vanished", status: "complete" }],
|
|
1797
|
+
}, null, 2), "utf-8");
|
|
1798
|
+
const executor = makeAsyncExecutor([visible], {}, (cwd) => ({
|
|
1799
|
+
agents: [visible],
|
|
1800
|
+
cwd: path.resolve(cwd),
|
|
1801
|
+
scope: "both",
|
|
1802
|
+
directories: [{ source: "project", path: evidenceDir, state: "empty" }],
|
|
1803
|
+
}));
|
|
1804
|
+
const result = await executor.execute(
|
|
1805
|
+
"resume-missing-agent",
|
|
1806
|
+
{ action: "resume", id: runId, message: "Continue" },
|
|
1807
|
+
new AbortController().signal,
|
|
1808
|
+
undefined,
|
|
1809
|
+
makeMinimalCtx(tempDir),
|
|
1810
|
+
) as AsyncExecutionResult;
|
|
1811
|
+
assert.equal(result.isError, true);
|
|
1812
|
+
assert.match(result.content[0]?.text ?? "", /^Unknown agent for resume: vanished\nEffective cwd: /);
|
|
1813
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(evidenceDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1814
|
+
assert.match(result.content[0]?.text ?? "", /visible \(project\)/);
|
|
1815
|
+
} finally {
|
|
1816
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
it("uses the append request discovery context for a missing appended agent", { skip: !createSubagentExecutor ? "executor not available" : undefined }, async () => {
|
|
1821
|
+
const runId = `append-missing-agent-${Date.now().toString(36)}`;
|
|
1822
|
+
const asyncDir = path.join(ASYNC_DIR, runId);
|
|
1823
|
+
const visible = makeAgent("visible");
|
|
1824
|
+
visible.source = "project";
|
|
1825
|
+
const evidenceDir = path.join(tempDir, "append-request", "agents");
|
|
1826
|
+
const storedCwd = path.join(tempDir, "stored-run-cwd");
|
|
1827
|
+
try {
|
|
1828
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
1829
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
1830
|
+
runId,
|
|
1831
|
+
sessionId: "session-123",
|
|
1832
|
+
mode: "chain",
|
|
1833
|
+
state: "running",
|
|
1834
|
+
startedAt: 100,
|
|
1835
|
+
lastUpdate: 200,
|
|
1836
|
+
cwd: storedCwd,
|
|
1837
|
+
steps: [{ agent: "visible", status: "running" }],
|
|
1838
|
+
}, null, 2), "utf-8");
|
|
1839
|
+
const executor = makeAsyncExecutor([visible], {}, (cwd) => ({
|
|
1840
|
+
agents: [visible],
|
|
1841
|
+
cwd: path.resolve(cwd),
|
|
1842
|
+
scope: "both",
|
|
1843
|
+
directories: [{ source: "project", path: evidenceDir, state: "empty" }],
|
|
1844
|
+
}));
|
|
1845
|
+
const result = await executor.execute(
|
|
1846
|
+
"append-missing-agent",
|
|
1847
|
+
{ action: "append-step", id: runId, step: { agent: "vanished", task: "Review" } },
|
|
1848
|
+
new AbortController().signal,
|
|
1849
|
+
undefined,
|
|
1850
|
+
makeMinimalCtx(tempDir),
|
|
1851
|
+
) as AsyncExecutionResult;
|
|
1852
|
+
assert.equal(result.isError, true);
|
|
1853
|
+
assert.match(result.content[0]?.text ?? "", /^Unknown agent: vanished\nEffective cwd: /);
|
|
1854
|
+
assert.match(result.content[0]?.text ?? "", new RegExp(evidenceDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1855
|
+
assert.match(result.content[0]?.text ?? "", /visible \(project\)/);
|
|
1856
|
+
assert.doesNotMatch(result.content[0]?.text ?? "", new RegExp(storedCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1857
|
+
} finally {
|
|
1858
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it("background chains inherit the parent session model when no step or agent model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1863
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1864
|
+
|
|
1865
|
+
const id = `async-chain-parent-model-${Date.now().toString(36)}`;
|
|
1866
|
+
executeAsyncChain(id, {
|
|
1867
|
+
chain: [{ agent: "worker", task: "Do work" }],
|
|
1868
|
+
agents: [makeAgent("worker")],
|
|
1869
|
+
ctx: {
|
|
1870
|
+
pi: { events: { emit() {} } },
|
|
1871
|
+
cwd: tempDir,
|
|
1872
|
+
currentSessionId: "session-1",
|
|
1873
|
+
currentModelProvider: "deepseek",
|
|
1874
|
+
currentModel: { provider: "deepseek", id: "deepseek-v4-flash" },
|
|
1875
|
+
},
|
|
1876
|
+
artifactConfig: {
|
|
1877
|
+
enabled: false,
|
|
1878
|
+
includeInput: false,
|
|
1879
|
+
includeOutput: false,
|
|
1880
|
+
includeJsonl: false,
|
|
1881
|
+
includeMetadata: false,
|
|
1882
|
+
cleanupDays: 7,
|
|
1883
|
+
},
|
|
1884
|
+
shareEnabled: false,
|
|
1885
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1886
|
+
maxSubagentDepth: 2,
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1890
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1891
|
+
assert.equal(payload.success, true);
|
|
1892
|
+
assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash");
|
|
1893
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]);
|
|
1894
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1895
|
+
assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it("background chains treat empty step models as parent inheritance", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1899
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1900
|
+
|
|
1901
|
+
const id = `async-chain-empty-model-${Date.now().toString(36)}`;
|
|
1902
|
+
executeAsyncChain(id, {
|
|
1903
|
+
chain: [{ agent: "worker", task: "Do work", model: "" }],
|
|
1904
|
+
agents: [makeAgent("worker", { model: "anthropic/claude-sonnet-4-5", thinking: "high" })],
|
|
1905
|
+
ctx: {
|
|
1906
|
+
pi: { events: { emit() {} } },
|
|
1907
|
+
cwd: tempDir,
|
|
1908
|
+
currentSessionId: "session-1",
|
|
1909
|
+
currentModelProvider: "openai",
|
|
1910
|
+
currentModel: { provider: "openai", id: "gpt-5-mini" },
|
|
1911
|
+
},
|
|
1912
|
+
availableModels: [
|
|
1913
|
+
{ provider: "anthropic", id: "claude-sonnet-4-5", fullId: "anthropic/claude-sonnet-4-5", api: "anthropic-messages" },
|
|
1914
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
|
|
1915
|
+
],
|
|
1916
|
+
artifactConfig: {
|
|
1917
|
+
enabled: false,
|
|
1918
|
+
includeInput: false,
|
|
1919
|
+
includeOutput: false,
|
|
1920
|
+
includeJsonl: false,
|
|
1921
|
+
includeMetadata: false,
|
|
1922
|
+
cleanupDays: 7,
|
|
1923
|
+
},
|
|
1924
|
+
shareEnabled: false,
|
|
1925
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1926
|
+
maxSubagentDepth: 2,
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1930
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1931
|
+
assert.equal(payload.success, true);
|
|
1932
|
+
assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
|
|
1933
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
|
|
1934
|
+
const args = readMockPiArgs(mockPi, 0);
|
|
1935
|
+
assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it("background chains keep agent fallback models inherited for scope warnings", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1939
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1940
|
+
const warnings: string[] = [];
|
|
1941
|
+
const originalWarn = console.warn;
|
|
1942
|
+
console.warn = (message?: unknown) => warnings.push(String(message));
|
|
1943
|
+
try {
|
|
1944
|
+
for (const [index, requestedModel] of [undefined, "", "inherit"].entries()) {
|
|
1945
|
+
const id = `async-chain-no-parent-${index}-${Date.now().toString(36)}`;
|
|
1946
|
+
executeAsyncChain(id, {
|
|
1947
|
+
chain: [{ agent: "worker", task: "Do work", ...(requestedModel !== undefined ? { model: requestedModel } : {}) }],
|
|
1948
|
+
agents: [makeAgent("worker", { model: "openai/gpt-5-mini", thinking: "high" })],
|
|
1949
|
+
ctx: {
|
|
1950
|
+
pi: { events: { emit() {} } },
|
|
1951
|
+
cwd: tempDir,
|
|
1952
|
+
currentSessionId: "session-1",
|
|
1953
|
+
modelScope: { enforce: true, allow: ["anthropic/*"] },
|
|
1954
|
+
},
|
|
1955
|
+
availableModels: [
|
|
1956
|
+
{ provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
|
|
1957
|
+
],
|
|
1958
|
+
artifactConfig: {
|
|
1959
|
+
enabled: false,
|
|
1960
|
+
includeInput: false,
|
|
1961
|
+
includeOutput: false,
|
|
1962
|
+
includeJsonl: false,
|
|
1963
|
+
includeMetadata: false,
|
|
1964
|
+
cleanupDays: 7,
|
|
1965
|
+
},
|
|
1966
|
+
shareEnabled: false,
|
|
1967
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1968
|
+
maxSubagentDepth: 2,
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1972
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
1973
|
+
assert.equal(payload.success, true);
|
|
1974
|
+
assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
|
|
1975
|
+
assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
|
|
1976
|
+
const args = readMockPiArgs(mockPi, index);
|
|
1977
|
+
assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
|
|
1978
|
+
}
|
|
1979
|
+
assert.equal(warnings.length, 3);
|
|
1980
|
+
assert.equal(warnings.every((warning) => warning.includes("outside the configured subagent model scope")), true);
|
|
1981
|
+
} finally {
|
|
1982
|
+
console.warn = originalWarn;
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
it("background runs resolve skills from the effective task cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1987
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
1988
|
+
const taskCwd = createTempDir("pi-subagent-async-task-cwd-");
|
|
1989
|
+
const id = `async-skill-cwd-${Date.now().toString(36)}`;
|
|
1990
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
1991
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
1992
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
1993
|
+
|
|
1994
|
+
try {
|
|
1995
|
+
writePackageSkill(taskCwd, "async-task-cwd-skill");
|
|
1996
|
+
executeAsyncSingle(id, {
|
|
1997
|
+
agent: "worker",
|
|
1998
|
+
task: "Do work",
|
|
1999
|
+
agentConfig: makeAgent("worker", { skills: ["async-task-cwd-skill"] }),
|
|
2000
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2001
|
+
cwd: taskCwd,
|
|
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
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2012
|
+
maxSubagentDepth: 2,
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
const deadline = Date.now() + 10_000;
|
|
2016
|
+
while (!fs.existsSync(resultPath)) {
|
|
2017
|
+
if (Date.now() > deadline) {
|
|
2018
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
2019
|
+
}
|
|
2020
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
2024
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
2025
|
+
assert.equal(payload.success, true);
|
|
2026
|
+
assert.deepEqual(status.steps?.[0]?.skills, ["async-task-cwd-skill"]);
|
|
2027
|
+
} finally {
|
|
2028
|
+
removeTempDir(taskCwd);
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
it("injects agent-file-relative local skills into background single child prompts", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2033
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
2034
|
+
const id = `async-local-skill-${Date.now().toString(36)}`;
|
|
2035
|
+
const agentFile = path.join(tempDir, "agents", "worker", "worker.md");
|
|
2036
|
+
const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
|
|
2037
|
+
fs.mkdirSync(path.dirname(skillFile), { recursive: true });
|
|
2038
|
+
fs.writeFileSync(skillFile, "---\ndescription: async local skill\n---\nLocal skill body\n", "utf-8");
|
|
2039
|
+
|
|
2040
|
+
executeAsyncSingle(id, {
|
|
2041
|
+
agent: "worker",
|
|
2042
|
+
task: "Do work",
|
|
2043
|
+
agentConfig: makeAgent("worker", { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] }),
|
|
2044
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2045
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
2046
|
+
shareEnabled: false,
|
|
2047
|
+
maxSubagentDepth: 2,
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
await waitForAsyncResultFile(id);
|
|
2051
|
+
const call = await waitForMockPiCall(mockPi, 0);
|
|
2052
|
+
assert.match(call.systemPrompts.map((record) => record.text ?? "").join("\n"), /async local skill/);
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
it("isolates agent-local skills between background parallel chain children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2056
|
+
mockPi.onCall({ output: "one" });
|
|
2057
|
+
mockPi.onCall({ output: "two" });
|
|
2058
|
+
const id = `async-parallel-local-skills-${Date.now().toString(36)}`;
|
|
2059
|
+
const agents = ["one", "two"].map((name) => {
|
|
2060
|
+
const agentFile = path.join(tempDir, "agents", name, `${name}.md`);
|
|
2061
|
+
const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
|
|
2062
|
+
fs.mkdirSync(path.dirname(skillFile), { recursive: true });
|
|
2063
|
+
fs.writeFileSync(skillFile, `---\ndescription: ${name} async local skill\n---\nbody\n`, "utf-8");
|
|
2064
|
+
return makeAgent(name, { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] });
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
executeAsyncChain(id, {
|
|
2068
|
+
chain: [{ parallel: [{ agent: "one", task: "One" }, { agent: "two", task: "Two" }], concurrency: 2 }],
|
|
2069
|
+
resultMode: "parallel",
|
|
2070
|
+
agents,
|
|
2071
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2072
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
2073
|
+
shareEnabled: false,
|
|
2074
|
+
maxSubagentDepth: 2,
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
await waitForAsyncResultFile(id);
|
|
2078
|
+
const prompts = await Promise.all([0, 1].map(async (index) => {
|
|
2079
|
+
const call = await waitForMockPiCall(mockPi, index);
|
|
2080
|
+
return call.systemPrompts.map((record) => record.text ?? "").join("\n");
|
|
2081
|
+
}));
|
|
2082
|
+
assert.equal(prompts.filter((prompt) => /one async local skill/.test(prompt) && !/two async local skill/.test(prompt)).length, 1);
|
|
2083
|
+
assert.equal(prompts.filter((prompt) => /two async local skill/.test(prompt) && !/one async local skill/.test(prompt)).length, 1);
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it("background single runs report unavailable pi-subagents skill requests", () => {
|
|
2087
|
+
const id = `async-pi-subagents-skill-${Date.now().toString(36)}`;
|
|
2088
|
+
const result = executeAsyncSingle(id, {
|
|
2089
|
+
agent: "worker",
|
|
2090
|
+
task: "Do work",
|
|
2091
|
+
agentConfig: makeAgent("worker"),
|
|
2092
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2093
|
+
cwd: tempDir,
|
|
2094
|
+
artifactConfig: {
|
|
2095
|
+
enabled: false,
|
|
2096
|
+
includeInput: false,
|
|
2097
|
+
includeOutput: false,
|
|
2098
|
+
includeJsonl: false,
|
|
2099
|
+
includeMetadata: false,
|
|
2100
|
+
cleanupDays: 7,
|
|
2101
|
+
},
|
|
2102
|
+
shareEnabled: false,
|
|
2103
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2104
|
+
skills: ["pi-subagents"],
|
|
2105
|
+
maxSubagentDepth: 2,
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
assert.equal(result.isError, true);
|
|
2109
|
+
assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/);
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("background chains report unavailable pi-subagents skill requests", () => {
|
|
2113
|
+
const id = `async-chain-pi-subagents-skill-${Date.now().toString(36)}`;
|
|
2114
|
+
const result = executeAsyncChain(id, {
|
|
2115
|
+
chain: [{ agent: "worker", task: "Do work", skill: ["pi-subagents"] }],
|
|
2116
|
+
agents: [makeAgent("worker")],
|
|
2117
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2118
|
+
cwd: tempDir,
|
|
2119
|
+
artifactConfig: {
|
|
2120
|
+
enabled: false,
|
|
2121
|
+
includeInput: false,
|
|
2122
|
+
includeOutput: false,
|
|
2123
|
+
includeJsonl: false,
|
|
2124
|
+
includeMetadata: false,
|
|
2125
|
+
cleanupDays: 7,
|
|
2126
|
+
},
|
|
2127
|
+
shareEnabled: false,
|
|
2128
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2129
|
+
maxSubagentDepth: 2,
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
assert.equal(result.isError, true);
|
|
2133
|
+
assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/);
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it("background chains resolve relative step cwd values against the shared cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2137
|
+
mockPi.onCall({ output: "Done asynchronously" });
|
|
2138
|
+
const chainCwd = createTempDir("pi-subagent-async-chain-cwd-");
|
|
2139
|
+
const id = `async-chain-skill-cwd-${Date.now().toString(36)}`;
|
|
2140
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
2141
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
2142
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
2143
|
+
|
|
2144
|
+
try {
|
|
2145
|
+
writePackageSkill(path.join(chainCwd, "packages", "app"), "async-chain-step-skill");
|
|
2146
|
+
executeAsyncChain(id, {
|
|
2147
|
+
chain: [{ agent: "worker", task: "Do work", cwd: "packages/app", skill: ["async-chain-step-skill"] }],
|
|
2148
|
+
agents: [makeAgent("worker")],
|
|
2149
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2150
|
+
cwd: chainCwd,
|
|
2151
|
+
artifactConfig: {
|
|
2152
|
+
enabled: false,
|
|
2153
|
+
includeInput: false,
|
|
2154
|
+
includeOutput: false,
|
|
2155
|
+
includeJsonl: false,
|
|
2156
|
+
includeMetadata: false,
|
|
2157
|
+
cleanupDays: 7,
|
|
2158
|
+
},
|
|
2159
|
+
shareEnabled: false,
|
|
2160
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2161
|
+
maxSubagentDepth: 2,
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
const deadline = Date.now() + 10_000;
|
|
2165
|
+
while (!fs.existsSync(resultPath)) {
|
|
2166
|
+
if (Date.now() > deadline) {
|
|
2167
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
2168
|
+
}
|
|
2169
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
|
|
2173
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
2174
|
+
assert.equal(payload.success, true);
|
|
2175
|
+
assert.equal(payload.sessionId, "session-1");
|
|
2176
|
+
assert.equal(status.sessionId, "session-1");
|
|
2177
|
+
assert.deepEqual(status.steps?.[0]?.skills, ["async-chain-step-skill"]);
|
|
2178
|
+
} finally {
|
|
2179
|
+
removeTempDir(chainCwd);
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("keeps top-level current tool/path aligned with still-running parallel children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
2184
|
+
mockPi.onCall({
|
|
2185
|
+
steps: [
|
|
2186
|
+
{ jsonl: [events.toolStart("read", { path: "README.md" })] },
|
|
2187
|
+
{ delay: 900, jsonl: [events.toolEnd("read"), events.toolResult("read", "done"), events.assistantMessage("reader done")] },
|
|
2188
|
+
],
|
|
2189
|
+
});
|
|
2190
|
+
mockPi.onCall({
|
|
2191
|
+
steps: [
|
|
2192
|
+
{ delay: 100, jsonl: [events.toolStart("edit", { path: "docs.md" })] },
|
|
2193
|
+
{ delay: 100, jsonl: [events.toolEnd("edit"), events.toolResult("edit", "ok")] },
|
|
2194
|
+
{ delay: 700, jsonl: [events.assistantMessage("editor done")] },
|
|
2195
|
+
],
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
const id = `async-parallel-tool-sync-${Date.now().toString(36)}`;
|
|
2199
|
+
const asyncDir = path.join(ASYNC_DIR, id);
|
|
2200
|
+
const resultPath = path.join(RESULTS_DIR, `${id}.json`);
|
|
2201
|
+
|
|
2202
|
+
executeAsyncChain(id, {
|
|
2203
|
+
chain: [{ parallel: [{ agent: "reader", task: "Read" }, { agent: "editor", task: "Edit" }] }],
|
|
2204
|
+
agents: [makeAgent("reader"), makeAgent("editor")],
|
|
2205
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2206
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
2207
|
+
shareEnabled: false,
|
|
2208
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2209
|
+
maxSubagentDepth: 2,
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
2213
|
+
const doneDeadline = Date.now() + 10_000;
|
|
2214
|
+
let sawRunningTool = false;
|
|
2215
|
+
let invariantViolated = false;
|
|
2216
|
+
while (!fs.existsSync(resultPath) && Date.now() < doneDeadline) {
|
|
2217
|
+
if (fs.existsSync(statusPath)) {
|
|
2218
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
|
|
2219
|
+
const runningTools = (status.steps ?? [])
|
|
2220
|
+
.filter((step) => step.status === "running" && typeof step.currentTool === "string")
|
|
2221
|
+
.map((step) => step.currentTool as string);
|
|
2222
|
+
if (runningTools.length > 0) {
|
|
2223
|
+
sawRunningTool = true;
|
|
2224
|
+
if (!status.currentTool || !runningTools.includes(status.currentTool)) {
|
|
2225
|
+
invariantViolated = true;
|
|
2226
|
+
break;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2231
|
+
}
|
|
2232
|
+
if (!fs.existsSync(resultPath)) {
|
|
2233
|
+
assert.fail(`Timed out waiting for async result file: ${resultPath}`);
|
|
2234
|
+
}
|
|
2235
|
+
assert.equal(sawRunningTool, true, "expected at least one polling interval with a running step tool");
|
|
2236
|
+
assert.equal(invariantViolated, false, "top-level currentTool drifted from running step tools");
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
it("returns a tool error when the detached runner config cannot be written", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => {
|
|
2240
|
+
const id = `async-write-fail-${Date.now().toString(36)}`;
|
|
2241
|
+
assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests");
|
|
2242
|
+
fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
|
|
2243
|
+
fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true });
|
|
2244
|
+
|
|
2245
|
+
const result = executeAsyncSingle(id, {
|
|
2246
|
+
agent: "worker",
|
|
2247
|
+
task: "Do work",
|
|
2248
|
+
agentConfig: makeAgent("worker"),
|
|
2249
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
2250
|
+
artifactConfig: {
|
|
2251
|
+
enabled: false,
|
|
2252
|
+
includeInput: false,
|
|
2253
|
+
includeOutput: false,
|
|
2254
|
+
includeJsonl: false,
|
|
2255
|
+
includeMetadata: false,
|
|
2256
|
+
cleanupDays: 7,
|
|
2257
|
+
},
|
|
2258
|
+
shareEnabled: false,
|
|
2259
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
2260
|
+
maxSubagentDepth: 2,
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
assert.equal(result.isError, true);
|
|
2264
|
+
assert.match(result.content[0]?.text ?? "", /Failed to start async run/);
|
|
2265
|
+
assert.match(result.content[0]?.text ?? "", /async-cfg-/);
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
});
|