@ag-eco/agentplate-cli 0.13.0
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/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- package/ui/dist/index.html +17 -0
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
|
|
7
|
+
import { HierarchyError, ValidationError } from "../errors.ts";
|
|
8
|
+
import { ClaudeRuntime } from "../runtimes/claude.ts";
|
|
9
|
+
import { getRuntime } from "../runtimes/registry.ts";
|
|
10
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
11
|
+
import type { AgentManifest, AgentplateConfig } from "../types.ts";
|
|
12
|
+
import {
|
|
13
|
+
type AutoDispatchOptions,
|
|
14
|
+
type BeaconOptions,
|
|
15
|
+
buildAutoDispatch,
|
|
16
|
+
buildBeacon,
|
|
17
|
+
calculateStaggerDelay,
|
|
18
|
+
checkDuplicateLead,
|
|
19
|
+
checkParentAgentLimit,
|
|
20
|
+
checkRunSessionLimit,
|
|
21
|
+
checkTaskLock,
|
|
22
|
+
extractLoamRecordIds,
|
|
23
|
+
generateAgentName,
|
|
24
|
+
getCurrentBranch,
|
|
25
|
+
getSharedWritableDirs,
|
|
26
|
+
inferDomainsFromFiles,
|
|
27
|
+
isRunningAsRoot,
|
|
28
|
+
isTaskWorkable,
|
|
29
|
+
parentHasScouts,
|
|
30
|
+
parseSiblings,
|
|
31
|
+
resolveParentAgent,
|
|
32
|
+
resolveUseHeadless,
|
|
33
|
+
shouldShowScoutWarning,
|
|
34
|
+
validateHierarchy,
|
|
35
|
+
} from "./sling.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tests for the stagger delay enforcement in the sling command (step 4b).
|
|
39
|
+
*
|
|
40
|
+
* The stagger delay logic prevents rapid-fire agent spawning by requiring
|
|
41
|
+
* a minimum delay between consecutive spawns. If the most recently started
|
|
42
|
+
* active session was spawned less than staggerDelayMs ago, the sling command
|
|
43
|
+
* sleeps for the remaining time.
|
|
44
|
+
*
|
|
45
|
+
* calculateStaggerDelay is a pure function that returns the number of
|
|
46
|
+
* milliseconds to sleep (0 if no delay is needed). The sling command calls
|
|
47
|
+
* Bun.sleep with the returned value if it's greater than 0.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
// --- Helpers ---
|
|
51
|
+
|
|
52
|
+
function makeSession(startedAt: string): { startedAt: string } {
|
|
53
|
+
return { startedAt };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("calculateStaggerDelay", () => {
|
|
57
|
+
test("returns remaining delay when a recent session exists", () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
// Session started 500ms ago, stagger delay is 2000ms -> should return ~1500ms
|
|
60
|
+
const sessions = [makeSession(new Date(now - 500).toISOString())];
|
|
61
|
+
|
|
62
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
63
|
+
|
|
64
|
+
expect(delay).toBe(1_500);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns 0 when staggerDelayMs is 0", () => {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
// Even with a very recent session, delay of 0 means no stagger
|
|
70
|
+
const sessions = [makeSession(new Date(now - 100).toISOString())];
|
|
71
|
+
|
|
72
|
+
const delay = calculateStaggerDelay(0, sessions, now);
|
|
73
|
+
|
|
74
|
+
expect(delay).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns 0 when no active sessions exist", () => {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
const delay = calculateStaggerDelay(5_000, [], now);
|
|
81
|
+
|
|
82
|
+
expect(delay).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns 0 when enough time has already elapsed", () => {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
// Session started 10 seconds ago, stagger delay is 2 seconds -> no delay
|
|
88
|
+
const sessions = [makeSession(new Date(now - 10_000).toISOString())];
|
|
89
|
+
|
|
90
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
91
|
+
|
|
92
|
+
expect(delay).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns 0 when elapsed time exactly equals stagger delay", () => {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
// Session started exactly 2000ms ago, stagger delay is 2000ms -> remaining = 0
|
|
98
|
+
const sessions = [makeSession(new Date(now - 2_000).toISOString())];
|
|
99
|
+
|
|
100
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
101
|
+
|
|
102
|
+
expect(delay).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("uses the most recent session for calculation with multiple sessions", () => {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
// Two sessions: one old (5s ago), one recent (200ms ago)
|
|
108
|
+
// With staggerDelayMs=2000, delay should be based on the 200ms-old session
|
|
109
|
+
const sessions = [
|
|
110
|
+
makeSession(new Date(now - 5_000).toISOString()),
|
|
111
|
+
makeSession(new Date(now - 200).toISOString()),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
115
|
+
|
|
116
|
+
expect(delay).toBe(1_800);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("handles sessions in any order (most recent is not last)", () => {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
// Most recent session is first in the array
|
|
122
|
+
const sessions = [
|
|
123
|
+
makeSession(new Date(now - 300).toISOString()),
|
|
124
|
+
makeSession(new Date(now - 5_000).toISOString()),
|
|
125
|
+
makeSession(new Date(now - 10_000).toISOString()),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
129
|
+
|
|
130
|
+
expect(delay).toBe(1_700);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("returns 0 when staggerDelayMs is negative", () => {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const sessions = [makeSession(new Date(now - 100).toISOString())];
|
|
136
|
+
|
|
137
|
+
const delay = calculateStaggerDelay(-1_000, sessions, now);
|
|
138
|
+
|
|
139
|
+
expect(delay).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("returns full delay when session was just started (elapsed ~0)", () => {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
// Session started at exactly now
|
|
145
|
+
const sessions = [makeSession(new Date(now).toISOString())];
|
|
146
|
+
|
|
147
|
+
const delay = calculateStaggerDelay(3_000, sessions, now);
|
|
148
|
+
|
|
149
|
+
expect(delay).toBe(3_000);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("handles a single session correctly", () => {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const sessions = [makeSession(new Date(now - 1_000).toISOString())];
|
|
155
|
+
|
|
156
|
+
const delay = calculateStaggerDelay(5_000, sessions, now);
|
|
157
|
+
|
|
158
|
+
expect(delay).toBe(4_000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("handles large stagger delay values", () => {
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const sessions = [makeSession(new Date(now - 1_000).toISOString())];
|
|
164
|
+
|
|
165
|
+
const delay = calculateStaggerDelay(60_000, sessions, now);
|
|
166
|
+
|
|
167
|
+
expect(delay).toBe(59_000);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("all sessions old enough means no delay, regardless of count", () => {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
// Many sessions, but all started well before the stagger window
|
|
173
|
+
const sessions = [
|
|
174
|
+
makeSession(new Date(now - 30_000).toISOString()),
|
|
175
|
+
makeSession(new Date(now - 25_000).toISOString()),
|
|
176
|
+
makeSession(new Date(now - 20_000).toISOString()),
|
|
177
|
+
makeSession(new Date(now - 15_000).toISOString()),
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const delay = calculateStaggerDelay(5_000, sessions, now);
|
|
181
|
+
|
|
182
|
+
expect(delay).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Tests for parentHasScouts check.
|
|
188
|
+
*
|
|
189
|
+
* parentHasScouts is used during sling to detect when a lead agent spawns a
|
|
190
|
+
* builder without having previously spawned any scouts. This provides structural
|
|
191
|
+
* enforcement of the scout-first workflow (Phase 1: explore, Phase 2: build).
|
|
192
|
+
*
|
|
193
|
+
* The function is non-blocking — it only emits a warning to stderr, but does
|
|
194
|
+
* not prevent the spawn. This allows valid edge cases where scout-skip is
|
|
195
|
+
* justified, while surfacing the pattern so agents and operators can see it.
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
function makeAgentSession(
|
|
199
|
+
parentAgent: string | null,
|
|
200
|
+
capability: string,
|
|
201
|
+
): { parentAgent: string | null; capability: string } {
|
|
202
|
+
return { parentAgent, capability };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe("parentHasScouts", () => {
|
|
206
|
+
test("returns false when sessions is empty", () => {
|
|
207
|
+
expect(parentHasScouts([], "lead-alpha")).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns false when parent has only builder children", () => {
|
|
211
|
+
const sessions = [
|
|
212
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
213
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("returns true when parent has a scout child", () => {
|
|
220
|
+
const sessions = [makeAgentSession("lead-alpha", "scout")];
|
|
221
|
+
|
|
222
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("returns true when parent has scout + builder children", () => {
|
|
226
|
+
const sessions = [
|
|
227
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
228
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("ignores scouts from other parents", () => {
|
|
235
|
+
const sessions = [
|
|
236
|
+
makeAgentSession("lead-beta", "scout"),
|
|
237
|
+
makeAgentSession("lead-gamma", "scout"),
|
|
238
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("returns false when parent has only reviewer children", () => {
|
|
245
|
+
const sessions = [
|
|
246
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
247
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("returns true when parent has multiple scouts", () => {
|
|
254
|
+
const sessions = [
|
|
255
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
256
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
257
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("returns false when sessions contain null parents only", () => {
|
|
264
|
+
const sessions = [makeAgentSession(null, "scout"), makeAgentSession(null, "builder")];
|
|
265
|
+
|
|
266
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("differentiates between parent names (case-sensitive)", () => {
|
|
270
|
+
const sessions = [
|
|
271
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
272
|
+
makeAgentSession("Lead-Alpha", "scout"),
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
// Should only find the exact match
|
|
276
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
277
|
+
expect(parentHasScouts(sessions, "Lead-Alpha")).toBe(true);
|
|
278
|
+
expect(parentHasScouts(sessions, "lead-beta")).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("works with mixed capability types", () => {
|
|
282
|
+
const sessions = [
|
|
283
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
284
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
285
|
+
makeAgentSession("lead-alpha", "merger"),
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Tests for shouldShowScoutWarning (agentplate-6eyw).
|
|
294
|
+
*
|
|
295
|
+
* shouldShowScoutWarning determines whether the "spawning builder without scouts"
|
|
296
|
+
* warning should be emitted. It is a pure function extracted from slingCommand
|
|
297
|
+
* so it can be suppressed via --no-scout-check or --skip-scout.
|
|
298
|
+
*/
|
|
299
|
+
|
|
300
|
+
describe("shouldShowScoutWarning", () => {
|
|
301
|
+
function makeSession(
|
|
302
|
+
parentAgent: string | null,
|
|
303
|
+
capability: string,
|
|
304
|
+
): { parentAgent: string | null; capability: string } {
|
|
305
|
+
return { parentAgent, capability };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const withScout = [makeSession("lead-alpha", "scout"), makeSession("lead-alpha", "builder")];
|
|
309
|
+
const withoutScout = [makeSession("lead-alpha", "builder")];
|
|
310
|
+
const empty: { parentAgent: string | null; capability: string }[] = [];
|
|
311
|
+
|
|
312
|
+
test("returns true when builder has parent but no scouts", () => {
|
|
313
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, false, false)).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("returns false when builder has parent and scouts exist", () => {
|
|
317
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withScout, false, false)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("returns false when capability is not builder", () => {
|
|
321
|
+
expect(shouldShowScoutWarning("scout", "lead-alpha", empty, false, false)).toBe(false);
|
|
322
|
+
expect(shouldShowScoutWarning("reviewer", "lead-alpha", empty, false, false)).toBe(false);
|
|
323
|
+
expect(shouldShowScoutWarning("lead", "lead-alpha", empty, false, false)).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns false when parentAgent is null (coordinator spawn)", () => {
|
|
327
|
+
expect(shouldShowScoutWarning("builder", null, withoutScout, false, false)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("returns false when noScoutCheck is true (flag suppresses warning)", () => {
|
|
331
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, true, false)).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("returns false when skipScout is true (lead opted out of scouting)", () => {
|
|
335
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, false, true)).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("returns false when both noScoutCheck and skipScout are true", () => {
|
|
339
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, true, true)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("returns false with empty sessions and no parent", () => {
|
|
343
|
+
expect(shouldShowScoutWarning("builder", null, empty, false, false)).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("returns true with empty sessions and a parent (no scouts ever spawned)", () => {
|
|
347
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", empty, false, false)).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("getSharedWritableDirs", () => {
|
|
352
|
+
test("returns only .agentplate for non-lead agents", () => {
|
|
353
|
+
expect(getSharedWritableDirs("/repo", "builder")).toEqual(["/repo/.agentplate"]);
|
|
354
|
+
expect(getSharedWritableDirs("/repo", "scout")).toEqual(["/repo/.agentplate"]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("includes canonical .git for lead agents", () => {
|
|
358
|
+
expect(getSharedWritableDirs("/repo", "lead")).toEqual(["/repo/.agentplate", "/repo/.git"]);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("generateAgentName", () => {
|
|
363
|
+
test("returns capability-taskId when no collision", () => {
|
|
364
|
+
expect(generateAgentName("builder", "agentplate-2f10", [])).toBe("builder-agentplate-2f10");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("returns capability-taskId when takenNames is empty", () => {
|
|
368
|
+
expect(generateAgentName("scout", "task-123", [])).toBe("scout-task-123");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("appends -2 when base name is taken", () => {
|
|
372
|
+
expect(generateAgentName("builder", "agentplate-2f10", ["builder-agentplate-2f10"])).toBe(
|
|
373
|
+
"builder-agentplate-2f10-2",
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("skips taken suffixes and returns -3 when -2 is also taken", () => {
|
|
378
|
+
expect(
|
|
379
|
+
generateAgentName("builder", "agentplate-2f10", [
|
|
380
|
+
"builder-agentplate-2f10",
|
|
381
|
+
"builder-agentplate-2f10-2",
|
|
382
|
+
]),
|
|
383
|
+
).toBe("builder-agentplate-2f10-3");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Tests for hierarchy validation in sling.
|
|
389
|
+
*
|
|
390
|
+
* validateHierarchy enforces that the coordinator (no --parent flag) can only
|
|
391
|
+
* spawn lead agents. All other capabilities must be spawned by a lead or
|
|
392
|
+
* supervisor that passes --parent. This prevents the flat delegation anti-pattern
|
|
393
|
+
* where the coordinator short-circuits the hierarchy.
|
|
394
|
+
*/
|
|
395
|
+
|
|
396
|
+
describe("validateHierarchy", () => {
|
|
397
|
+
test("allows builder when parentAgent is null", () => {
|
|
398
|
+
expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).not.toThrow();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("allows scout when parentAgent is null", () => {
|
|
402
|
+
expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).not.toThrow();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("rejects reviewer when parentAgent is null", () => {
|
|
406
|
+
expect(() => validateHierarchy(null, "reviewer", "test-reviewer", 0, false)).toThrow(
|
|
407
|
+
HierarchyError,
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("rejects merger when parentAgent is null", () => {
|
|
412
|
+
expect(() => validateHierarchy(null, "merger", "test-merger", 0, false)).toThrow(
|
|
413
|
+
HierarchyError,
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("allows lead when parentAgent is null", () => {
|
|
418
|
+
expect(() => validateHierarchy(null, "lead", "test-lead", 0, false)).not.toThrow();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("allows builder when parentAgent is provided", () => {
|
|
422
|
+
expect(() =>
|
|
423
|
+
validateHierarchy("lead-alpha", "builder", "test-builder", 1, false),
|
|
424
|
+
).not.toThrow();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("allows scout when parentAgent is provided", () => {
|
|
428
|
+
expect(() => validateHierarchy("lead-alpha", "scout", "test-scout", 1, false)).not.toThrow();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("allows reviewer when parentAgent is provided", () => {
|
|
432
|
+
expect(() =>
|
|
433
|
+
validateHierarchy("lead-alpha", "reviewer", "test-reviewer", 1, false),
|
|
434
|
+
).not.toThrow();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("--force-hierarchy bypasses the check for builder", () => {
|
|
438
|
+
expect(() => validateHierarchy(null, "builder", "test-builder", 0, true)).not.toThrow();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("--force-hierarchy bypasses the check for scout", () => {
|
|
442
|
+
expect(() => validateHierarchy(null, "scout", "test-scout", 0, true)).not.toThrow();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("error has correct fields and code", () => {
|
|
446
|
+
try {
|
|
447
|
+
validateHierarchy(null, "reviewer", "my-reviewer", 0, false);
|
|
448
|
+
expect.unreachable("should have thrown");
|
|
449
|
+
} catch (err) {
|
|
450
|
+
expect(err).toBeInstanceOf(HierarchyError);
|
|
451
|
+
const he = err as HierarchyError;
|
|
452
|
+
expect(he.code).toBe("HIERARCHY_VIOLATION");
|
|
453
|
+
expect(he.agentName).toBe("my-reviewer");
|
|
454
|
+
expect(he.requestedCapability).toBe("reviewer");
|
|
455
|
+
expect(he.message).toContain("reviewer");
|
|
456
|
+
expect(he.message).toContain("lead");
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Tests for the structured startup beacon sent to agents via tmux send-keys.
|
|
463
|
+
*
|
|
464
|
+
* buildBeacon is a pure function that constructs the first user message an
|
|
465
|
+
* agent sees. It includes identity context (name, capability, task ID),
|
|
466
|
+
* hierarchy info (depth, parent), and startup instructions.
|
|
467
|
+
*
|
|
468
|
+
* The beacon is a single-line string (parts joined by " — ") to prevent
|
|
469
|
+
* multiline tmux send-keys issues (agentplate-y2ob, agentplate-cczf).
|
|
470
|
+
*/
|
|
471
|
+
|
|
472
|
+
function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
|
|
473
|
+
return {
|
|
474
|
+
agentName: "test-builder",
|
|
475
|
+
capability: "builder",
|
|
476
|
+
taskId: "agentplate-abc",
|
|
477
|
+
parentAgent: null,
|
|
478
|
+
depth: 0,
|
|
479
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
480
|
+
...overrides,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
describe("buildBeacon", () => {
|
|
485
|
+
test("is a single line (no newlines)", () => {
|
|
486
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
487
|
+
|
|
488
|
+
expect(beacon).not.toContain("\n");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("includes agent identity and task ID in header", () => {
|
|
492
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
493
|
+
|
|
494
|
+
expect(beacon).toContain("[AGENTPLATE] test-builder (builder) ");
|
|
495
|
+
expect(beacon).toContain("task:agentplate-abc");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("includes ISO timestamp", () => {
|
|
499
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
500
|
+
|
|
501
|
+
expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("includes depth and parent info", () => {
|
|
505
|
+
const beacon = buildBeacon(makeBeaconOpts({ depth: 1, parentAgent: "lead-alpha" }));
|
|
506
|
+
|
|
507
|
+
expect(beacon).toContain("Depth: 1 | Parent: lead-alpha");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("shows 'none' for parent when no parent agent", () => {
|
|
511
|
+
const beacon = buildBeacon(makeBeaconOpts({ parentAgent: null }));
|
|
512
|
+
|
|
513
|
+
expect(beacon).toContain("Depth: 0 | Parent: none");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("includes startup instructions with agent name and task ID", () => {
|
|
517
|
+
const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "agentplate-xyz" });
|
|
518
|
+
const beacon = buildBeacon(opts);
|
|
519
|
+
|
|
520
|
+
expect(beacon).toContain(`read ${opts.instructionPath}`);
|
|
521
|
+
expect(beacon).toContain("loam prime");
|
|
522
|
+
expect(beacon).toContain("ap mail check --agent scout-1");
|
|
523
|
+
expect(beacon).toContain("begin task agentplate-xyz");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("uses custom instructionPath in startup instructions", () => {
|
|
527
|
+
const opts = makeBeaconOpts({ instructionPath: "AGENTS.md" });
|
|
528
|
+
const beacon = buildBeacon(opts);
|
|
529
|
+
|
|
530
|
+
expect(beacon).toContain("read AGENTS.md");
|
|
531
|
+
expect(beacon).not.toContain(".claude/CLAUDE.md");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("uses agent name in mail check command", () => {
|
|
535
|
+
const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
|
|
536
|
+
|
|
537
|
+
expect(beacon).toContain("ap mail check --agent reviewer-beta");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("reflects capability in header", () => {
|
|
541
|
+
const beacon = buildBeacon(makeBeaconOpts({ capability: "scout" }));
|
|
542
|
+
|
|
543
|
+
expect(beacon).toContain("(scout)");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("works with hierarchy depth > 0 and parent", () => {
|
|
547
|
+
const beacon = buildBeacon(
|
|
548
|
+
makeBeaconOpts({
|
|
549
|
+
agentName: "worker-3",
|
|
550
|
+
capability: "builder",
|
|
551
|
+
taskId: "agentplate-deep",
|
|
552
|
+
parentAgent: "lead-main",
|
|
553
|
+
depth: 2,
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
expect(beacon).toContain("[AGENTPLATE] worker-3 (builder)");
|
|
558
|
+
expect(beacon).toContain("task:agentplate-deep");
|
|
559
|
+
expect(beacon).toContain("Depth: 2 | Parent: lead-main");
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Tests for inferDomainsFromFiles.
|
|
565
|
+
*
|
|
566
|
+
* This pure function maps file paths to loam domains using inferDomain(),
|
|
567
|
+
* deduplicates results, sorts them alphabetically, and falls back to
|
|
568
|
+
* configDomains when no paths produce a domain mapping.
|
|
569
|
+
*/
|
|
570
|
+
|
|
571
|
+
describe("inferDomainsFromFiles", () => {
|
|
572
|
+
test("infers cli domain from src/commands/ files", () => {
|
|
573
|
+
const domains = inferDomainsFromFiles(["src/commands/sling.ts"], []);
|
|
574
|
+
|
|
575
|
+
expect(domains).toEqual(["cli"]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("infers messaging domain from src/mail/ files", () => {
|
|
579
|
+
const domains = inferDomainsFromFiles(["src/mail/store.ts"], []);
|
|
580
|
+
|
|
581
|
+
expect(domains).toEqual(["messaging"]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("infers typescript domain from general src/ files", () => {
|
|
585
|
+
const domains = inferDomainsFromFiles(["src/config.ts"], []);
|
|
586
|
+
|
|
587
|
+
expect(domains).toEqual(["typescript"]);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("infers cli domain from .test.ts files in src/commands/ (commands check takes priority)", () => {
|
|
591
|
+
const domains = inferDomainsFromFiles(["src/commands/sling.test.ts"], []);
|
|
592
|
+
|
|
593
|
+
// src/commands/ check runs before .test.ts check in inferDomain
|
|
594
|
+
expect(domains).toEqual(["cli"]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("infers typescript domain from .test.ts files outside recognized directories", () => {
|
|
598
|
+
const domains = inferDomainsFromFiles(["src/config.test.ts"], []);
|
|
599
|
+
|
|
600
|
+
// src/ match triggers typescript (config.test.ts is not in a specific subdirectory)
|
|
601
|
+
expect(domains).toEqual(["typescript"]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("deduplicates domains across multiple files", () => {
|
|
605
|
+
const files = ["src/commands/sling.ts", "src/commands/init.ts", "src/commands/merge.ts"];
|
|
606
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
607
|
+
|
|
608
|
+
expect(domains).toEqual(["cli"]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("returns multiple domains sorted alphabetically", () => {
|
|
612
|
+
const files = ["src/commands/sling.ts", "src/mail/store.ts"];
|
|
613
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
614
|
+
|
|
615
|
+
expect(domains).toEqual(["cli", "messaging"]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("falls back to configDomains when no files match", () => {
|
|
619
|
+
const domains = inferDomainsFromFiles(["docs/README.md"], ["typescript", "cli"]);
|
|
620
|
+
|
|
621
|
+
expect(domains).toEqual(["typescript", "cli"]);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("falls back to configDomains when files list is empty", () => {
|
|
625
|
+
const domains = inferDomainsFromFiles([], ["agents"]);
|
|
626
|
+
|
|
627
|
+
expect(domains).toEqual(["agents"]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("returns empty array when no files match and configDomains is empty", () => {
|
|
631
|
+
const domains = inferDomainsFromFiles(["docs/README.md"], []);
|
|
632
|
+
|
|
633
|
+
expect(domains).toEqual([]);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("infers agents domain from src/agents/ files", () => {
|
|
637
|
+
const domains = inferDomainsFromFiles(["src/agents/manifest.ts"], []);
|
|
638
|
+
|
|
639
|
+
expect(domains).toEqual(["agents"]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("infers architecture domain from src/merge/ files", () => {
|
|
643
|
+
const domains = inferDomainsFromFiles(["src/merge/queue.ts"], []);
|
|
644
|
+
|
|
645
|
+
expect(domains).toEqual(["architecture"]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("infers architecture domain from src/worktree/ files", () => {
|
|
649
|
+
const domains = inferDomainsFromFiles(["src/worktree/manager.ts"], []);
|
|
650
|
+
|
|
651
|
+
expect(domains).toEqual(["architecture"]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("handles mixed file scopes producing multiple domains", () => {
|
|
655
|
+
const files = ["src/commands/sling.ts", "src/agents/manifest.ts", "src/mail/client.ts"];
|
|
656
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
657
|
+
|
|
658
|
+
expect(domains).toEqual(["agents", "cli", "messaging"]);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
describe("isRunningAsRoot", () => {
|
|
663
|
+
test("returns true when getuid returns 0", () => {
|
|
664
|
+
expect(isRunningAsRoot(() => 0)).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("returns false when getuid returns non-zero UID", () => {
|
|
668
|
+
expect(isRunningAsRoot(() => 1000)).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("returns false when getuid is undefined (platform without getuid)", () => {
|
|
672
|
+
expect(isRunningAsRoot(undefined)).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Tests for checkTaskLock.
|
|
678
|
+
*
|
|
679
|
+
* checkTaskLock prevents concurrent agents from working the same task ID.
|
|
680
|
+
* It checks the active session list and returns the agent name that holds
|
|
681
|
+
* the lock (i.e., is already working on the task), or null if the task is free.
|
|
682
|
+
*/
|
|
683
|
+
|
|
684
|
+
function makeTaskSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
|
|
685
|
+
return { agentName, taskId };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
describe("checkTaskLock", () => {
|
|
689
|
+
test("returns null when no sessions exist", () => {
|
|
690
|
+
expect(checkTaskLock([], "agentplate-abc")).toBeNull();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("returns null when no session matches the task ID", () => {
|
|
694
|
+
const sessions = [
|
|
695
|
+
makeTaskSession("builder-1", "agentplate-xyz"),
|
|
696
|
+
makeTaskSession("builder-2", "agentplate-def"),
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
expect(checkTaskLock(sessions, "agentplate-abc")).toBeNull();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("returns the agent name when a session matches", () => {
|
|
703
|
+
const sessions = [
|
|
704
|
+
makeTaskSession("builder-1", "agentplate-abc"),
|
|
705
|
+
makeTaskSession("builder-2", "agentplate-xyz"),
|
|
706
|
+
];
|
|
707
|
+
|
|
708
|
+
expect(checkTaskLock(sessions, "agentplate-abc")).toBe("builder-1");
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("returns the first matching agent when multiple sessions match", () => {
|
|
712
|
+
// Multiple sessions can have the same taskId (e.g., retried agent)
|
|
713
|
+
// checkTaskLock returns the first match
|
|
714
|
+
const sessions = [
|
|
715
|
+
makeTaskSession("builder-1", "agentplate-abc"),
|
|
716
|
+
makeTaskSession("builder-2", "agentplate-abc"),
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
expect(checkTaskLock(sessions, "agentplate-abc")).toBe("builder-1");
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
describe("checkTaskLock parent bypass", () => {
|
|
724
|
+
test("parent matching lock holder is allowed (returns lock holder name for caller to compare)", () => {
|
|
725
|
+
// checkTaskLock is a pure function — it returns the lock holder name or null.
|
|
726
|
+
// The parent bypass logic is in slingCommand, not checkTaskLock.
|
|
727
|
+
// These tests verify the building blocks work correctly.
|
|
728
|
+
const sessions = [makeTaskSession("lead-alpha", "agentplate-abc")];
|
|
729
|
+
// checkTaskLock still returns the holder — the caller (slingCommand) decides
|
|
730
|
+
// whether to allow based on parentAgent match.
|
|
731
|
+
expect(checkTaskLock(sessions, "agentplate-abc")).toBe("lead-alpha");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("non-parent lock holder blocks spawn", () => {
|
|
735
|
+
const sessions = [makeTaskSession("other-agent", "agentplate-abc")];
|
|
736
|
+
const lockHolder = checkTaskLock(sessions, "agentplate-abc");
|
|
737
|
+
const parentAgent = "lead-alpha";
|
|
738
|
+
// lockHolder is 'other-agent', parentAgent is 'lead-alpha' — not equal, should block
|
|
739
|
+
expect(lockHolder).not.toBeNull();
|
|
740
|
+
expect(lockHolder).not.toBe(parentAgent);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("null parent with lock holder blocks spawn", () => {
|
|
744
|
+
const sessions = [makeTaskSession("lead-alpha", "agentplate-abc")];
|
|
745
|
+
const lockHolder = checkTaskLock(sessions, "agentplate-abc");
|
|
746
|
+
const parentAgent = null;
|
|
747
|
+
// lockHolder is non-null and parentAgent is null — should block
|
|
748
|
+
expect(lockHolder).not.toBeNull();
|
|
749
|
+
expect(lockHolder).not.toBe(parentAgent);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Tests for checkDuplicateLead.
|
|
755
|
+
*
|
|
756
|
+
* checkDuplicateLead prevents spawning a second lead agent for the same task ID.
|
|
757
|
+
* It filters the active session list to only "lead" capability sessions, so
|
|
758
|
+
* builder/scout children working the same bead via parent delegation do not
|
|
759
|
+
* trigger this check.
|
|
760
|
+
*
|
|
761
|
+
* The activeSessions input is pre-filtered by store.getActive() to exclude
|
|
762
|
+
* completed and zombie sessions.
|
|
763
|
+
*/
|
|
764
|
+
|
|
765
|
+
function makeLeadSession(
|
|
766
|
+
agentName: string,
|
|
767
|
+
taskId: string,
|
|
768
|
+
capability: string,
|
|
769
|
+
): { agentName: string; taskId: string; capability: string } {
|
|
770
|
+
return { agentName, taskId, capability };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
describe("isTaskWorkable", () => {
|
|
774
|
+
test("accepts open and in_progress without recover", () => {
|
|
775
|
+
expect(isTaskWorkable("open", false)).toBe(true);
|
|
776
|
+
expect(isTaskWorkable("in_progress", false)).toBe(true);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("rejects closed and other terminal statuses without recover", () => {
|
|
780
|
+
expect(isTaskWorkable("closed", false)).toBe(false);
|
|
781
|
+
expect(isTaskWorkable("cancelled", false)).toBe(false);
|
|
782
|
+
expect(isTaskWorkable("done", false)).toBe(false);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("accepts any status when recover is true", () => {
|
|
786
|
+
expect(isTaskWorkable("closed", true)).toBe(true);
|
|
787
|
+
expect(isTaskWorkable("cancelled", true)).toBe(true);
|
|
788
|
+
expect(isTaskWorkable("open", true)).toBe(true);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// --- resolveParentAgent (agentplate-de3c) ---
|
|
793
|
+
//
|
|
794
|
+
// Witnessed bug: a coordinator/lead recovered a zombie spawn-per-turn worker
|
|
795
|
+
// via `ap sling --recover --name <existing>` without threading `--parent`.
|
|
796
|
+
// The pre-fix `parentAgent = opts.parent ?? null` overwrote the prior
|
|
797
|
+
// `parent_agent` row to null on upsert, so the runner could not emit
|
|
798
|
+
// `worker_died` on a resumed-turn parser stall — the lead waited forever.
|
|
799
|
+
// The fix: when --parent is not explicitly passed, fall back to the prior
|
|
800
|
+
// session row's parentAgent. Explicit caller intent (any string, including
|
|
801
|
+
// empty) always wins.
|
|
802
|
+
describe("resolveParentAgent", () => {
|
|
803
|
+
test("case A: explicit --parent wins over prior session linkage", () => {
|
|
804
|
+
const existing = { parentAgent: "old-lead" };
|
|
805
|
+
expect(resolveParentAgent("new-lead", existing)).toBe("new-lead");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("case B: --parent omitted preserves prior session's parentAgent on re-spawn", () => {
|
|
809
|
+
// THE REGRESSION CHECK. Pre-fix this returned null, severing the link
|
|
810
|
+
// the runner needs to emit worker_died (agentplate-de3c).
|
|
811
|
+
const existing = { parentAgent: "lead-r" };
|
|
812
|
+
expect(resolveParentAgent(undefined, existing)).toBe("lead-r");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("--parent omitted with no prior session yields null (fresh agent)", () => {
|
|
816
|
+
expect(resolveParentAgent(undefined, null)).toBeNull();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("--parent omitted with prior session whose parent is null yields null", () => {
|
|
820
|
+
// A coordinator-spawned root agent has parentAgent=null. Re-spawn must
|
|
821
|
+
// not synthesize a parent.
|
|
822
|
+
const existing = { parentAgent: null };
|
|
823
|
+
expect(resolveParentAgent(undefined, existing)).toBeNull();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("explicit --parent='' (empty string) is honored — caller intent wins", () => {
|
|
827
|
+
// Empty string is `defined` but `null`-y; we honor it as caller intent
|
|
828
|
+
// rather than silently falling back to the prior linkage.
|
|
829
|
+
const existing = { parentAgent: "lead-r" };
|
|
830
|
+
expect(resolveParentAgent("", existing)).toBe("");
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
describe("checkDuplicateLead", () => {
|
|
835
|
+
test("returns lead agent name when an active lead exists for the task", () => {
|
|
836
|
+
const sessions = [
|
|
837
|
+
makeLeadSession("lead-alpha", "agentplate-abc", "lead"),
|
|
838
|
+
makeLeadSession("builder-1", "agentplate-xyz", "builder"),
|
|
839
|
+
];
|
|
840
|
+
expect(checkDuplicateLead(sessions, "agentplate-abc")).toBe("lead-alpha");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("returns null when no lead exists for the task", () => {
|
|
844
|
+
const sessions = [
|
|
845
|
+
makeLeadSession("lead-alpha", "agentplate-xyz", "lead"),
|
|
846
|
+
makeLeadSession("builder-1", "agentplate-abc", "builder"),
|
|
847
|
+
];
|
|
848
|
+
expect(checkDuplicateLead(sessions, "agentplate-abc")).toBeNull();
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("returns null when no sessions exist (completed/zombie filtered out)", () => {
|
|
852
|
+
// activeSessions from store.getActive() already excludes completed/zombie
|
|
853
|
+
expect(checkDuplicateLead([], "agentplate-abc")).toBeNull();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("ignores non-lead agents working the same bead", () => {
|
|
857
|
+
const sessions = [
|
|
858
|
+
makeLeadSession("builder-1", "agentplate-abc", "builder"),
|
|
859
|
+
makeLeadSession("scout-1", "agentplate-abc", "scout"),
|
|
860
|
+
makeLeadSession("reviewer-1", "agentplate-abc", "reviewer"),
|
|
861
|
+
];
|
|
862
|
+
expect(checkDuplicateLead(sessions, "agentplate-abc")).toBeNull();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("returns first matching lead when multiple leads exist for the same bead", () => {
|
|
866
|
+
const sessions = [
|
|
867
|
+
makeLeadSession("lead-alpha", "agentplate-abc", "lead"),
|
|
868
|
+
makeLeadSession("lead-beta", "agentplate-abc", "lead"),
|
|
869
|
+
];
|
|
870
|
+
expect(checkDuplicateLead(sessions, "agentplate-abc")).toBe("lead-alpha");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("differentiates between task IDs", () => {
|
|
874
|
+
const sessions = [makeLeadSession("lead-alpha", "agentplate-abc", "lead")];
|
|
875
|
+
expect(checkDuplicateLead(sessions, "agentplate-xyz")).toBeNull();
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Tests for checkRunSessionLimit.
|
|
881
|
+
*
|
|
882
|
+
* checkRunSessionLimit prevents spawning when the per-run agent cap is reached.
|
|
883
|
+
* A limit of 0 (or negative) means unlimited. Returns true if the limit
|
|
884
|
+
* is reached (spawn should be blocked), false otherwise.
|
|
885
|
+
*/
|
|
886
|
+
|
|
887
|
+
describe("checkRunSessionLimit", () => {
|
|
888
|
+
test("returns false when limit is 0 (unlimited)", () => {
|
|
889
|
+
expect(checkRunSessionLimit(0, 100)).toBe(false);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("returns false when count is below limit", () => {
|
|
893
|
+
expect(checkRunSessionLimit(10, 5)).toBe(false);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test("returns true when count equals limit", () => {
|
|
897
|
+
expect(checkRunSessionLimit(10, 10)).toBe(true);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("returns true when count exceeds limit", () => {
|
|
901
|
+
expect(checkRunSessionLimit(10, 15)).toBe(true);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("returns false when limit is negative (treated as unlimited)", () => {
|
|
905
|
+
expect(checkRunSessionLimit(-1, 100)).toBe(false);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe("checkParentAgentLimit", () => {
|
|
910
|
+
test("returns false when limit is 0 (unlimited)", () => {
|
|
911
|
+
const sessions = [{ parentAgent: "lead-alpha" }, { parentAgent: "lead-alpha" }];
|
|
912
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 0)).toBe(false);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("returns false when count is below limit", () => {
|
|
916
|
+
const sessions = [{ parentAgent: "lead-alpha" }];
|
|
917
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 5)).toBe(false);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("returns true when count equals limit", () => {
|
|
921
|
+
const sessions = [
|
|
922
|
+
{ parentAgent: "lead-alpha" },
|
|
923
|
+
{ parentAgent: "lead-alpha" },
|
|
924
|
+
{ parentAgent: "lead-alpha" },
|
|
925
|
+
];
|
|
926
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("returns true when count exceeds limit", () => {
|
|
930
|
+
const sessions = [
|
|
931
|
+
{ parentAgent: "lead-alpha" },
|
|
932
|
+
{ parentAgent: "lead-alpha" },
|
|
933
|
+
{ parentAgent: "lead-alpha" },
|
|
934
|
+
{ parentAgent: "lead-alpha" },
|
|
935
|
+
];
|
|
936
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test("returns false when limit is negative (treated as unlimited)", () => {
|
|
940
|
+
const sessions = [{ parentAgent: "lead-alpha" }];
|
|
941
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", -1)).toBe(false);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("only counts children of the specified parent", () => {
|
|
945
|
+
const sessions = [
|
|
946
|
+
{ parentAgent: "lead-alpha" },
|
|
947
|
+
{ parentAgent: "lead-beta" },
|
|
948
|
+
{ parentAgent: "lead-alpha" },
|
|
949
|
+
{ parentAgent: "lead-gamma" },
|
|
950
|
+
];
|
|
951
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(false);
|
|
952
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(true);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test("returns false when no sessions match the parent", () => {
|
|
956
|
+
const sessions = [{ parentAgent: "lead-beta" }, { parentAgent: "lead-gamma" }];
|
|
957
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 1)).toBe(false);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("ignores sessions with null parent", () => {
|
|
961
|
+
const sessions = [{ parentAgent: null }, { parentAgent: "lead-alpha" }, { parentAgent: null }];
|
|
962
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(false);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("returns false when sessions array is empty", () => {
|
|
966
|
+
expect(checkParentAgentLimit([], "lead-alpha", 5)).toBe(false);
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Tests for sling provider env injection building blocks.
|
|
972
|
+
*
|
|
973
|
+
* In slingCommand, resolveModel() is called to get the { model, env } for the
|
|
974
|
+
* spawned agent. The env dict is then spread into createSession's env parameter
|
|
975
|
+
* alongside AGENTPLATE_AGENT_NAME and AGENTPLATE_WORKTREE_PATH:
|
|
976
|
+
*
|
|
977
|
+
* const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
|
|
978
|
+
* const pid = await createSession(tmuxSessionName, worktreePath, claudeCmd, {
|
|
979
|
+
* ...env,
|
|
980
|
+
* AGENTPLATE_AGENT_NAME: name,
|
|
981
|
+
* AGENTPLATE_WORKTREE_PATH: worktreePath,
|
|
982
|
+
* });
|
|
983
|
+
*
|
|
984
|
+
* These tests verify the building blocks: that resolveModel and resolveProviderEnv
|
|
985
|
+
* produce the correct env dicts for the provider scenarios sling will encounter.
|
|
986
|
+
*/
|
|
987
|
+
|
|
988
|
+
function makeConfig(
|
|
989
|
+
models: AgentplateConfig["models"] = {},
|
|
990
|
+
providers: AgentplateConfig["providers"] = { anthropic: { type: "native" } },
|
|
991
|
+
): AgentplateConfig {
|
|
992
|
+
return {
|
|
993
|
+
project: { name: "test", root: "/tmp/test", canonicalBranch: "main" },
|
|
994
|
+
agents: {
|
|
995
|
+
manifestPath: ".agentplate/agent-manifest.json",
|
|
996
|
+
baseDir: ".agentplate/agent-defs",
|
|
997
|
+
maxConcurrent: 5,
|
|
998
|
+
staggerDelayMs: 0,
|
|
999
|
+
maxDepth: 2,
|
|
1000
|
+
maxSessionsPerRun: 0,
|
|
1001
|
+
maxAgentsPerLead: 5,
|
|
1002
|
+
},
|
|
1003
|
+
worktrees: { baseDir: ".agentplate/worktrees" },
|
|
1004
|
+
taskTracker: { backend: "auto", enabled: false },
|
|
1005
|
+
loam: { enabled: false, domains: [], primeFormat: "markdown" },
|
|
1006
|
+
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
1007
|
+
providers,
|
|
1008
|
+
watchdog: {
|
|
1009
|
+
tier0Enabled: false,
|
|
1010
|
+
tier0IntervalMs: 30_000,
|
|
1011
|
+
tier1Enabled: false,
|
|
1012
|
+
tier2Enabled: false,
|
|
1013
|
+
staleThresholdMs: 300_000,
|
|
1014
|
+
zombieThresholdMs: 600_000,
|
|
1015
|
+
nudgeIntervalMs: 60_000,
|
|
1016
|
+
},
|
|
1017
|
+
models,
|
|
1018
|
+
logging: { verbose: false, redactSecrets: true },
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function makeManifest(): AgentManifest {
|
|
1023
|
+
return {
|
|
1024
|
+
version: "1.0",
|
|
1025
|
+
agents: {
|
|
1026
|
+
builder: {
|
|
1027
|
+
file: "builder.md",
|
|
1028
|
+
model: "opus",
|
|
1029
|
+
tools: ["Read", "Write", "Edit", "Bash"],
|
|
1030
|
+
capabilities: ["implement"],
|
|
1031
|
+
canSpawn: false,
|
|
1032
|
+
constraints: [],
|
|
1033
|
+
},
|
|
1034
|
+
coordinator: {
|
|
1035
|
+
file: "coordinator.md",
|
|
1036
|
+
model: "sonnet",
|
|
1037
|
+
tools: ["Read", "Bash"],
|
|
1038
|
+
capabilities: ["coordinate"],
|
|
1039
|
+
canSpawn: true,
|
|
1040
|
+
constraints: [],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
capabilityIndex: { implement: ["builder"], coordinate: ["coordinator"] },
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
describe("sling provider env injection building blocks", () => {
|
|
1048
|
+
test("resolveModel produces env for gateway provider in config override scenario", () => {
|
|
1049
|
+
const config = makeConfig(
|
|
1050
|
+
{ builder: "openrouter/anthropic/claude-3-5-sonnet" },
|
|
1051
|
+
{ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
|
|
1052
|
+
);
|
|
1053
|
+
const manifest = makeManifest();
|
|
1054
|
+
|
|
1055
|
+
const result = resolveModel(config, manifest, "builder", "sonnet");
|
|
1056
|
+
|
|
1057
|
+
expect(result.model).toBe("sonnet");
|
|
1058
|
+
expect(result.env).toBeDefined();
|
|
1059
|
+
expect(result.env?.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
|
|
1060
|
+
expect(result.env?.ANTHROPIC_API_KEY).toBe("");
|
|
1061
|
+
expect(result.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-sonnet");
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("env dict from resolveModel can be spread with AGENTPLATE_AGENT_NAME and AGENTPLATE_WORKTREE_PATH", () => {
|
|
1065
|
+
const config = makeConfig(
|
|
1066
|
+
{ builder: "openrouter/anthropic/claude-3-5-sonnet" },
|
|
1067
|
+
{ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
|
|
1068
|
+
);
|
|
1069
|
+
const manifest = makeManifest();
|
|
1070
|
+
|
|
1071
|
+
const { env } = resolveModel(config, manifest, "builder", "sonnet");
|
|
1072
|
+
// Simulates the spread in slingCommand: { ...env, AGENTPLATE_AGENT_NAME: name, AGENTPLATE_WORKTREE_PATH: wt }
|
|
1073
|
+
const combined: Record<string, string> = {
|
|
1074
|
+
...(env ?? {}),
|
|
1075
|
+
AGENTPLATE_AGENT_NAME: "test-builder",
|
|
1076
|
+
AGENTPLATE_WORKTREE_PATH: "/tmp/wt",
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
expect(combined.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
|
|
1080
|
+
expect(combined.ANTHROPIC_API_KEY).toBe("");
|
|
1081
|
+
expect(combined.AGENTPLATE_AGENT_NAME).toBe("test-builder");
|
|
1082
|
+
expect(combined.AGENTPLATE_WORKTREE_PATH).toBe("/tmp/wt");
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("env dict from resolveModel can be spread with AGENTPLATE_TASK_ID", () => {
|
|
1086
|
+
const config = makeConfig(
|
|
1087
|
+
{ builder: "openrouter/anthropic/claude-3-5-sonnet" },
|
|
1088
|
+
{ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
|
|
1089
|
+
);
|
|
1090
|
+
const manifest = makeManifest();
|
|
1091
|
+
|
|
1092
|
+
const { env } = resolveModel(config, manifest, "builder", "sonnet");
|
|
1093
|
+
// Simulates the spread in slingCommand: { ...env, AGENTPLATE_AGENT_NAME: name, AGENTPLATE_WORKTREE_PATH: wt, AGENTPLATE_TASK_ID: taskId }
|
|
1094
|
+
const combined: Record<string, string> = {
|
|
1095
|
+
...(env ?? {}),
|
|
1096
|
+
AGENTPLATE_AGENT_NAME: "test-builder",
|
|
1097
|
+
AGENTPLATE_WORKTREE_PATH: "/tmp/wt",
|
|
1098
|
+
AGENTPLATE_TASK_ID: "agentplate-1234",
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
expect(combined.AGENTPLATE_AGENT_NAME).toBe("test-builder");
|
|
1102
|
+
expect(combined.AGENTPLATE_WORKTREE_PATH).toBe("/tmp/wt");
|
|
1103
|
+
expect(combined.AGENTPLATE_TASK_ID).toBe("agentplate-1234");
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test("env dict includes AGENTPLATE_PROJECT_ROOT", () => {
|
|
1107
|
+
const env = { MODEL_KEY: "value" };
|
|
1108
|
+
const combined = {
|
|
1109
|
+
...env,
|
|
1110
|
+
AGENTPLATE_AGENT_NAME: "test-builder",
|
|
1111
|
+
AGENTPLATE_WORKTREE_PATH: "/path/to/wt",
|
|
1112
|
+
AGENTPLATE_TASK_ID: "task-1",
|
|
1113
|
+
AGENTPLATE_PROJECT_ROOT: "/path/to/project",
|
|
1114
|
+
};
|
|
1115
|
+
expect(combined.AGENTPLATE_PROJECT_ROOT).toBe("/path/to/project");
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test("resolveModel returns no env for native anthropic provider", () => {
|
|
1119
|
+
const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
|
|
1120
|
+
const manifest = makeManifest();
|
|
1121
|
+
|
|
1122
|
+
const result = resolveModel(config, manifest, "builder", "sonnet");
|
|
1123
|
+
|
|
1124
|
+
expect(result.model).toBe("sonnet");
|
|
1125
|
+
expect(result.env).toBeUndefined();
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
test("resolveModel returns no env when model is a simple alias from manifest default", () => {
|
|
1129
|
+
// No models override: manifest builder model "opus" is a simple alias
|
|
1130
|
+
const config = makeConfig({}, {});
|
|
1131
|
+
const manifest = makeManifest();
|
|
1132
|
+
|
|
1133
|
+
const result = resolveModel(config, manifest, "builder", "sonnet");
|
|
1134
|
+
|
|
1135
|
+
expect(result.model).toBe("opus");
|
|
1136
|
+
expect(result.env).toBeUndefined();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("resolveProviderEnv includes ANTHROPIC_AUTH_TOKEN when authTokenEnv var is set", () => {
|
|
1140
|
+
const providers = {
|
|
1141
|
+
openrouter: {
|
|
1142
|
+
type: "gateway" as const,
|
|
1143
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
1144
|
+
authTokenEnv: "MY_API_KEY",
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
const env = { MY_API_KEY: "sk-test-123" };
|
|
1148
|
+
|
|
1149
|
+
const result = resolveProviderEnv("openrouter", "anthropic/claude-3-5-sonnet", providers, env);
|
|
1150
|
+
|
|
1151
|
+
expect(result).not.toBeNull();
|
|
1152
|
+
expect(result?.ANTHROPIC_AUTH_TOKEN).toBe("sk-test-123");
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test("resolveProviderEnv omits ANTHROPIC_AUTH_TOKEN when authTokenEnv var is absent", () => {
|
|
1156
|
+
const providers = {
|
|
1157
|
+
openrouter: {
|
|
1158
|
+
type: "gateway" as const,
|
|
1159
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
1160
|
+
authTokenEnv: "MY_API_KEY",
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
const env: Record<string, string | undefined> = {};
|
|
1164
|
+
|
|
1165
|
+
const result = resolveProviderEnv("openrouter", "anthropic/claude-3-5-sonnet", providers, env);
|
|
1166
|
+
|
|
1167
|
+
expect(result).not.toBeNull();
|
|
1168
|
+
expect(result?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("resolveModel produces different env dicts for coordinator and builder with different gateway providers", () => {
|
|
1172
|
+
const config = makeConfig(
|
|
1173
|
+
{
|
|
1174
|
+
coordinator: "openrouter/anthropic/claude-3-5-sonnet",
|
|
1175
|
+
builder: "litellm/anthropic/claude-3-5-haiku",
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
1179
|
+
litellm: { type: "gateway", baseUrl: "https://litellm.example.com/v1" },
|
|
1180
|
+
},
|
|
1181
|
+
);
|
|
1182
|
+
const manifest = makeManifest();
|
|
1183
|
+
|
|
1184
|
+
const coordinatorResult = resolveModel(config, manifest, "coordinator", "sonnet");
|
|
1185
|
+
const builderResult = resolveModel(config, manifest, "builder", "sonnet");
|
|
1186
|
+
|
|
1187
|
+
expect(coordinatorResult.model).toBe("sonnet");
|
|
1188
|
+
expect(builderResult.model).toBe("sonnet");
|
|
1189
|
+
expect(coordinatorResult.env?.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
|
|
1190
|
+
expect(builderResult.env?.ANTHROPIC_BASE_URL).toBe("https://litellm.example.com/v1");
|
|
1191
|
+
expect(coordinatorResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
|
|
1192
|
+
"anthropic/claude-3-5-sonnet",
|
|
1193
|
+
);
|
|
1194
|
+
expect(builderResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-haiku");
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Tests for buildAutoDispatch.
|
|
1200
|
+
*
|
|
1201
|
+
* buildAutoDispatch constructs a pre-spawn dispatch mail message that is
|
|
1202
|
+
* inserted into the mail DB before the tmux session is created. This ensures
|
|
1203
|
+
* the agent's SessionStart hook can immediately deliver context without
|
|
1204
|
+
* waiting for the coordinator to send a separate dispatch message.
|
|
1205
|
+
*/
|
|
1206
|
+
|
|
1207
|
+
function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDispatchOptions {
|
|
1208
|
+
return {
|
|
1209
|
+
agentName: "builder-1",
|
|
1210
|
+
taskId: "agentplate-abc",
|
|
1211
|
+
capability: "builder",
|
|
1212
|
+
specPath: "/path/to/spec.md",
|
|
1213
|
+
parentAgent: "lead-alpha",
|
|
1214
|
+
slingerName: null,
|
|
1215
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1216
|
+
...overrides,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
describe("buildAutoDispatch", () => {
|
|
1221
|
+
test("uses parent agent as sender when provided", () => {
|
|
1222
|
+
const dispatch = buildAutoDispatch({
|
|
1223
|
+
agentName: "builder-1",
|
|
1224
|
+
taskId: "agentplate-abc",
|
|
1225
|
+
capability: "builder",
|
|
1226
|
+
specPath: "/path/to/spec.md",
|
|
1227
|
+
parentAgent: "lead-alpha",
|
|
1228
|
+
slingerName: null,
|
|
1229
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1230
|
+
});
|
|
1231
|
+
expect(dispatch.from).toBe("lead-alpha");
|
|
1232
|
+
expect(dispatch.to).toBe("builder-1");
|
|
1233
|
+
expect(dispatch.subject).toContain("agentplate-abc");
|
|
1234
|
+
expect(dispatch.body).toContain("spec.md");
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
test("uses orchestrator as sender when no parent", () => {
|
|
1238
|
+
const dispatch = buildAutoDispatch({
|
|
1239
|
+
agentName: "lead-1",
|
|
1240
|
+
taskId: "agentplate-xyz",
|
|
1241
|
+
capability: "lead",
|
|
1242
|
+
specPath: null,
|
|
1243
|
+
parentAgent: null,
|
|
1244
|
+
slingerName: null,
|
|
1245
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1246
|
+
});
|
|
1247
|
+
expect(dispatch.from).toBe("orchestrator");
|
|
1248
|
+
expect(dispatch.body).toContain("No spec file");
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test("includes capability in body", () => {
|
|
1252
|
+
const dispatch = buildAutoDispatch({
|
|
1253
|
+
agentName: "scout-1",
|
|
1254
|
+
taskId: "agentplate-abc",
|
|
1255
|
+
capability: "scout",
|
|
1256
|
+
specPath: null,
|
|
1257
|
+
parentAgent: "lead-alpha",
|
|
1258
|
+
slingerName: null,
|
|
1259
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1260
|
+
});
|
|
1261
|
+
expect(dispatch.body).toContain("scout");
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("includes spec path when provided", () => {
|
|
1265
|
+
const dispatch = buildAutoDispatch({
|
|
1266
|
+
agentName: "builder-1",
|
|
1267
|
+
taskId: "agentplate-abc",
|
|
1268
|
+
capability: "builder",
|
|
1269
|
+
specPath: "/abs/path/to/spec.md",
|
|
1270
|
+
parentAgent: "lead-alpha",
|
|
1271
|
+
slingerName: null,
|
|
1272
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1273
|
+
});
|
|
1274
|
+
expect(dispatch.body).toContain("/abs/path/to/spec.md");
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test("slinger takes precedence over parent agent for from field", () => {
|
|
1278
|
+
const dispatch = buildAutoDispatch(
|
|
1279
|
+
makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: "lead-alpha" }),
|
|
1280
|
+
);
|
|
1281
|
+
expect(dispatch.from).toBe("coordinator");
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test("slinger fills in when parent agent is null", () => {
|
|
1285
|
+
const dispatch = buildAutoDispatch(
|
|
1286
|
+
makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: null }),
|
|
1287
|
+
);
|
|
1288
|
+
expect(dispatch.from).toBe("coordinator");
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("falls back to orchestrator when both slinger and parent are null", () => {
|
|
1292
|
+
const dispatch = buildAutoDispatch(
|
|
1293
|
+
makeAutoDispatchOpts({ slingerName: null, parentAgent: null }),
|
|
1294
|
+
);
|
|
1295
|
+
expect(dispatch.from).toBe("orchestrator");
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("subject contains task ID", () => {
|
|
1299
|
+
const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "agentplate-zz99" }));
|
|
1300
|
+
expect(dispatch.subject).toContain("agentplate-zz99");
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("to is the agent name", () => {
|
|
1304
|
+
const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ agentName: "my-builder" }));
|
|
1305
|
+
expect(dispatch.to).toBe("my-builder");
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Beacon verification loop (sling.ts step 13d)
|
|
1311
|
+
*
|
|
1312
|
+
* NOT UNIT-TESTABLE: The beacon verification loop at sling.ts lines 687-698
|
|
1313
|
+
* uses capturePaneContent() to poll tmux and resend the beacon if the agent
|
|
1314
|
+
* is still at the welcome screen ("Try "). This involves real tmux operations
|
|
1315
|
+
* that cannot be reliably mocked without mock.module() (which leaks across
|
|
1316
|
+
* test files — see mx-56558b).
|
|
1317
|
+
*
|
|
1318
|
+
* Manual verification:
|
|
1319
|
+
* 1. `ap sling <task-id> --name test --capability builder`
|
|
1320
|
+
* 2. Watch tmux pane: `tmux capture-pane -t agentplate-<project>-test -p`
|
|
1321
|
+
* 3. Verify the beacon text appears and the agent starts processing
|
|
1322
|
+
*
|
|
1323
|
+
* Integration coverage: The beacon loop has been validated through production
|
|
1324
|
+
* agent spawns. Failure mode is agents stuck at welcome screen (agentplate-3271).
|
|
1325
|
+
*/
|
|
1326
|
+
|
|
1327
|
+
describe("sling runtime integration", () => {
|
|
1328
|
+
test("runtime.buildSpawnCommand produces identical command to old hardcoded string", () => {
|
|
1329
|
+
const runtime = getRuntime("claude");
|
|
1330
|
+
const cmd = runtime.buildSpawnCommand({
|
|
1331
|
+
model: "sonnet",
|
|
1332
|
+
permissionMode: "bypass",
|
|
1333
|
+
cwd: "/tmp/worktree",
|
|
1334
|
+
env: {},
|
|
1335
|
+
});
|
|
1336
|
+
expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("runtime.buildSpawnCommand with opus model", () => {
|
|
1340
|
+
const runtime = getRuntime("claude");
|
|
1341
|
+
const cmd = runtime.buildSpawnCommand({
|
|
1342
|
+
model: "opus",
|
|
1343
|
+
permissionMode: "bypass",
|
|
1344
|
+
cwd: "/tmp/worktree",
|
|
1345
|
+
env: {},
|
|
1346
|
+
});
|
|
1347
|
+
expect(cmd).toBe("claude --model opus --permission-mode bypassPermissions");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("runtime.buildEnv returns empty object for native model", () => {
|
|
1351
|
+
const runtime = new ClaudeRuntime();
|
|
1352
|
+
const env = runtime.buildEnv({ model: "sonnet" });
|
|
1353
|
+
expect(env).toEqual({});
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test("runtime.buildEnv passes through provider env vars", () => {
|
|
1357
|
+
const runtime = new ClaudeRuntime();
|
|
1358
|
+
const env = runtime.buildEnv({
|
|
1359
|
+
model: "sonnet",
|
|
1360
|
+
env: { ANTHROPIC_BASE_URL: "https://example.com" },
|
|
1361
|
+
});
|
|
1362
|
+
expect(env).toEqual({ ANTHROPIC_BASE_URL: "https://example.com" });
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
test("runtime.detectReady returns ready for idle Claude prompt", () => {
|
|
1366
|
+
const runtime = new ClaudeRuntime();
|
|
1367
|
+
const state = runtime.detectReady('Try "hello world"\n\nbypass permissions');
|
|
1368
|
+
expect(state.phase).toBe("ready");
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("runtime.detectReady returns loading when agent is processing", () => {
|
|
1372
|
+
const runtime = new ClaudeRuntime();
|
|
1373
|
+
const state = runtime.detectReady("Running tool: Read\nbypass permissions");
|
|
1374
|
+
expect(state.phase).toBe("loading");
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
describe("extractLoamRecordIds", () => {
|
|
1379
|
+
test("returns empty array for empty string", () => {
|
|
1380
|
+
expect(extractLoamRecordIds("")).toEqual([]);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test("returns empty when no mx-IDs present", () => {
|
|
1384
|
+
const text = "## agents (2 records)\n- convention without ID";
|
|
1385
|
+
expect(extractLoamRecordIds(text)).toEqual([]);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("extracts single ID from a domain", () => {
|
|
1389
|
+
const text = "## agents (1 records)\n- [convention] Some. (mx-abc123)";
|
|
1390
|
+
expect(extractLoamRecordIds(text)).toEqual([{ id: "mx-abc123", domain: "agents" }]);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
test("extracts multiple IDs from same domain", () => {
|
|
1394
|
+
const text = ["## typescript", "- first. (mx-aaa111)", "- second. (mx-bbb222)"].join("\n");
|
|
1395
|
+
expect(extractLoamRecordIds(text)).toEqual([
|
|
1396
|
+
{ id: "mx-aaa111", domain: "typescript" },
|
|
1397
|
+
{ id: "mx-bbb222", domain: "typescript" },
|
|
1398
|
+
]);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("extracts IDs from multiple domains", () => {
|
|
1402
|
+
const text = ["## agents", "- agent. (mx-111aaa)", "## typescript", "- ts. (mx-222bbb)"].join(
|
|
1403
|
+
"\n",
|
|
1404
|
+
);
|
|
1405
|
+
expect(extractLoamRecordIds(text)).toEqual([
|
|
1406
|
+
{ id: "mx-111aaa", domain: "agents" },
|
|
1407
|
+
{ id: "mx-222bbb", domain: "typescript" },
|
|
1408
|
+
]);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
test("ignores non-domain headings with no mx-IDs", () => {
|
|
1412
|
+
const text = [
|
|
1413
|
+
"## Quick Reference",
|
|
1414
|
+
"- use loam search",
|
|
1415
|
+
"## agents",
|
|
1416
|
+
"- real. (mx-deadbeef)",
|
|
1417
|
+
].join("\n");
|
|
1418
|
+
expect(extractLoamRecordIds(text)).toEqual([{ id: "mx-deadbeef", domain: "agents" }]);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
test("deduplicates repeated pairs", () => {
|
|
1422
|
+
const text = ["## agents", "- first. (mx-aabbcc)", "- dup. (mx-aabbcc)"].join("\n");
|
|
1423
|
+
expect(extractLoamRecordIds(text)).toEqual([{ id: "mx-aabbcc", domain: "agents" }]);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
test("handles realistic lm prime output", () => {
|
|
1427
|
+
const text = [
|
|
1428
|
+
"## agents (3 records, updated just now)",
|
|
1429
|
+
"- [convention] lead.md convention. (mx-636708)",
|
|
1430
|
+
"- [convention] writeOverlay(). (mx-b7fa3d)",
|
|
1431
|
+
"## typescript (2 records, updated just now)",
|
|
1432
|
+
"- [convention] No any types. (mx-2ce43d)",
|
|
1433
|
+
"## Quick Reference",
|
|
1434
|
+
"- loam search",
|
|
1435
|
+
].join("\n");
|
|
1436
|
+
const result = extractLoamRecordIds(text);
|
|
1437
|
+
expect(result).toHaveLength(3);
|
|
1438
|
+
expect(result).toContainEqual({ id: "mx-636708", domain: "agents" });
|
|
1439
|
+
expect(result).toContainEqual({ id: "mx-b7fa3d", domain: "agents" });
|
|
1440
|
+
expect(result).toContainEqual({ id: "mx-2ce43d", domain: "typescript" });
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
describe("getCurrentBranch", () => {
|
|
1445
|
+
let repoDir: string;
|
|
1446
|
+
|
|
1447
|
+
beforeEach(async () => {
|
|
1448
|
+
repoDir = realpathSync(await createTempGitRepo());
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
afterEach(async () => {
|
|
1452
|
+
await cleanupTempDir(repoDir);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test("returns the current branch name", async () => {
|
|
1456
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1457
|
+
expect(branch).toMatch(/^(main|master)$/);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test("returns feature branch name after checkout", async () => {
|
|
1461
|
+
const proc = Bun.spawn(["git", "checkout", "-b", "feature/test-branch"], {
|
|
1462
|
+
cwd: repoDir,
|
|
1463
|
+
stdout: "pipe",
|
|
1464
|
+
stderr: "pipe",
|
|
1465
|
+
});
|
|
1466
|
+
await proc.exited;
|
|
1467
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1468
|
+
expect(branch).toBe("feature/test-branch");
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
test("returns null for detached HEAD", async () => {
|
|
1472
|
+
const hashProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
|
|
1473
|
+
cwd: repoDir,
|
|
1474
|
+
stdout: "pipe",
|
|
1475
|
+
stderr: "pipe",
|
|
1476
|
+
});
|
|
1477
|
+
const hash = (await new Response(hashProc.stdout).text()).trim();
|
|
1478
|
+
await hashProc.exited;
|
|
1479
|
+
const proc = Bun.spawn(["git", "checkout", hash], {
|
|
1480
|
+
cwd: repoDir,
|
|
1481
|
+
stdout: "pipe",
|
|
1482
|
+
stderr: "pipe",
|
|
1483
|
+
});
|
|
1484
|
+
await proc.exited;
|
|
1485
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1486
|
+
expect(branch).toBeNull();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
test("returns null for non-git directory", async () => {
|
|
1490
|
+
const tmpDir = realpathSync(await mkdtemp(join(tmpdir(), "agentplate-notgit-")));
|
|
1491
|
+
try {
|
|
1492
|
+
const branch = await getCurrentBranch(tmpDir);
|
|
1493
|
+
expect(branch).toBeNull();
|
|
1494
|
+
} finally {
|
|
1495
|
+
await cleanupTempDir(tmpDir);
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
describe("resolveUseHeadless", () => {
|
|
1501
|
+
const claudeLike = { id: "claude", buildDirectSpawn: () => [] as string[] };
|
|
1502
|
+
const claudeNoSpawn = { id: "claude" };
|
|
1503
|
+
const saplingLike = {
|
|
1504
|
+
id: "sapling",
|
|
1505
|
+
headless: true as const,
|
|
1506
|
+
buildDirectSpawn: () => [] as string[],
|
|
1507
|
+
};
|
|
1508
|
+
const codexLike = { id: "codex" };
|
|
1509
|
+
const baseConfig = {} as AgentplateConfig;
|
|
1510
|
+
const headlessByDefaultConfig = {
|
|
1511
|
+
runtime: { default: "claude", claudeHeadlessByDefault: true },
|
|
1512
|
+
} as unknown as AgentplateConfig;
|
|
1513
|
+
|
|
1514
|
+
test("statically headless runtime returns true regardless of flag", () => {
|
|
1515
|
+
expect(resolveUseHeadless(saplingLike, undefined, baseConfig)).toBe(true);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
test("claude + no flag + base config returns false (default tmux)", () => {
|
|
1519
|
+
expect(resolveUseHeadless(claudeLike, undefined, baseConfig)).toBe(false);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
test("claude + no flag + claudeHeadlessByDefault:true returns true", () => {
|
|
1523
|
+
expect(resolveUseHeadless(claudeLike, undefined, headlessByDefaultConfig)).toBe(true);
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
test("claude + flag:true + base config returns true", () => {
|
|
1527
|
+
expect(resolveUseHeadless(claudeLike, true, baseConfig)).toBe(true);
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
test("claude + flag:false + claudeHeadlessByDefault:true returns false (flag wins)", () => {
|
|
1531
|
+
expect(resolveUseHeadless(claudeLike, false, headlessByDefaultConfig)).toBe(false);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
test("claude without buildDirectSpawn + flag:true throws ValidationError", () => {
|
|
1535
|
+
expect(() => resolveUseHeadless(claudeNoSpawn, true, baseConfig)).toThrow(ValidationError);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
test("codex + claudeHeadlessByDefault:true returns false (config knob is Claude-only)", () => {
|
|
1539
|
+
expect(resolveUseHeadless(codexLike, undefined, headlessByDefaultConfig)).toBe(false);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test("codex + flag:true throws ValidationError (no buildDirectSpawn)", () => {
|
|
1543
|
+
expect(() => resolveUseHeadless(codexLike, true, baseConfig)).toThrow(ValidationError);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
test("sapling + flag:false returns true (statically headless wins over flag)", () => {
|
|
1547
|
+
expect(resolveUseHeadless(saplingLike, false, baseConfig)).toBe(true);
|
|
1548
|
+
});
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
describe("parseSiblings (agentplate-f76a)", () => {
|
|
1552
|
+
test("undefined input returns empty array", () => {
|
|
1553
|
+
expect(parseSiblings(undefined)).toEqual([]);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("empty string returns empty array", () => {
|
|
1557
|
+
expect(parseSiblings("")).toEqual([]);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("single name returns one-element array", () => {
|
|
1561
|
+
expect(parseSiblings("sibling-a")).toEqual(["sibling-a"]);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("comma-separated names are split and trimmed", () => {
|
|
1565
|
+
expect(parseSiblings("sibling-a, sibling-b ,sibling-c")).toEqual([
|
|
1566
|
+
"sibling-a",
|
|
1567
|
+
"sibling-b",
|
|
1568
|
+
"sibling-c",
|
|
1569
|
+
]);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("blank entries between commas are dropped", () => {
|
|
1573
|
+
expect(parseSiblings("sibling-a,,sibling-b, ,sibling-c")).toEqual([
|
|
1574
|
+
"sibling-a",
|
|
1575
|
+
"sibling-b",
|
|
1576
|
+
"sibling-c",
|
|
1577
|
+
]);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("whitespace-only input returns empty array", () => {
|
|
1581
|
+
expect(parseSiblings(" ")).toEqual([]);
|
|
1582
|
+
});
|
|
1583
|
+
});
|