@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,1474 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createEventStore } from "../events/store.ts";
|
|
6
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
7
|
+
import type { ResolvedModel } from "../types.ts";
|
|
8
|
+
import { ClaudeRuntime } from "./claude.ts";
|
|
9
|
+
import type { AgentEvent, DirectSpawnOpts, SpawnOpts } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
describe("ClaudeRuntime", () => {
|
|
12
|
+
const runtime = new ClaudeRuntime();
|
|
13
|
+
|
|
14
|
+
describe("id and instructionPath", () => {
|
|
15
|
+
test("id is 'claude'", () => {
|
|
16
|
+
expect(runtime.id).toBe("claude");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("instructionPath is .claude/CLAUDE.md", () => {
|
|
20
|
+
expect(runtime.instructionPath).toBe(".claude/CLAUDE.md");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("buildSpawnCommand", () => {
|
|
25
|
+
test("basic command with bypass permission mode", () => {
|
|
26
|
+
const opts: SpawnOpts = {
|
|
27
|
+
model: "sonnet",
|
|
28
|
+
permissionMode: "bypass",
|
|
29
|
+
cwd: "/tmp/worktree",
|
|
30
|
+
env: {},
|
|
31
|
+
};
|
|
32
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
33
|
+
expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("basic command with ask permission mode", () => {
|
|
37
|
+
const opts: SpawnOpts = {
|
|
38
|
+
model: "opus",
|
|
39
|
+
permissionMode: "ask",
|
|
40
|
+
cwd: "/tmp/worktree",
|
|
41
|
+
env: {},
|
|
42
|
+
};
|
|
43
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
44
|
+
expect(cmd).toBe("claude --model opus --permission-mode default");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("with appendSystemPrompt (no quotes in prompt)", () => {
|
|
48
|
+
const opts: SpawnOpts = {
|
|
49
|
+
model: "sonnet",
|
|
50
|
+
permissionMode: "bypass",
|
|
51
|
+
cwd: "/tmp/worktree",
|
|
52
|
+
env: {},
|
|
53
|
+
appendSystemPrompt: "You are a builder agent.",
|
|
54
|
+
};
|
|
55
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
56
|
+
expect(cmd).toBe(
|
|
57
|
+
"claude --model sonnet --permission-mode bypassPermissions --append-system-prompt 'You are a builder agent.'",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("with appendSystemPrompt containing single quotes", () => {
|
|
62
|
+
const opts: SpawnOpts = {
|
|
63
|
+
model: "sonnet",
|
|
64
|
+
permissionMode: "bypass",
|
|
65
|
+
cwd: "/tmp/worktree",
|
|
66
|
+
env: {},
|
|
67
|
+
appendSystemPrompt: "Don't touch the user's files",
|
|
68
|
+
};
|
|
69
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
70
|
+
// POSIX single-quote escape: end quote, backslash-quote, start quote → '\\''
|
|
71
|
+
expect(cmd).toContain("--append-system-prompt");
|
|
72
|
+
expect(cmd).toBe(
|
|
73
|
+
"claude --model sonnet --permission-mode bypassPermissions --append-system-prompt 'Don'\\''t touch the user'\\''s files'",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("with appendSystemPromptFile uses $(cat ...) expansion", () => {
|
|
78
|
+
const opts: SpawnOpts = {
|
|
79
|
+
model: "opus",
|
|
80
|
+
permissionMode: "bypass",
|
|
81
|
+
cwd: "/project",
|
|
82
|
+
env: {},
|
|
83
|
+
appendSystemPromptFile: "/project/.agentplate/agent-defs/coordinator.md",
|
|
84
|
+
};
|
|
85
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
86
|
+
expect(cmd).toBe(
|
|
87
|
+
`claude --model opus --permission-mode bypassPermissions --append-system-prompt "$(cat '/project/.agentplate/agent-defs/coordinator.md')"`,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("appendSystemPromptFile with single quotes in path", () => {
|
|
92
|
+
const opts: SpawnOpts = {
|
|
93
|
+
model: "opus",
|
|
94
|
+
permissionMode: "bypass",
|
|
95
|
+
cwd: "/project",
|
|
96
|
+
env: {},
|
|
97
|
+
appendSystemPromptFile: "/project/it's a path/agent.md",
|
|
98
|
+
};
|
|
99
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
100
|
+
expect(cmd).toContain("$(cat '/project/it'\\''s a path/agent.md')");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("appendSystemPromptFile takes precedence over appendSystemPrompt", () => {
|
|
104
|
+
const opts: SpawnOpts = {
|
|
105
|
+
model: "opus",
|
|
106
|
+
permissionMode: "bypass",
|
|
107
|
+
cwd: "/project",
|
|
108
|
+
env: {},
|
|
109
|
+
appendSystemPromptFile: "/project/.agentplate/agent-defs/coordinator.md",
|
|
110
|
+
appendSystemPrompt: "This inline content should be ignored",
|
|
111
|
+
};
|
|
112
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
113
|
+
expect(cmd).toContain("$(cat ");
|
|
114
|
+
expect(cmd).not.toContain("This inline content should be ignored");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("without appendSystemPrompt omits the flag", () => {
|
|
118
|
+
const opts: SpawnOpts = {
|
|
119
|
+
model: "haiku",
|
|
120
|
+
permissionMode: "bypass",
|
|
121
|
+
cwd: "/tmp/worktree",
|
|
122
|
+
env: {},
|
|
123
|
+
};
|
|
124
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
125
|
+
expect(cmd).not.toContain("--append-system-prompt");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("cwd and env are not embedded in command string", () => {
|
|
129
|
+
const opts: SpawnOpts = {
|
|
130
|
+
model: "sonnet",
|
|
131
|
+
permissionMode: "bypass",
|
|
132
|
+
cwd: "/some/specific/path",
|
|
133
|
+
env: { ANTHROPIC_API_KEY: "sk-test-123" },
|
|
134
|
+
};
|
|
135
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
136
|
+
expect(cmd).not.toContain("/some/specific/path");
|
|
137
|
+
expect(cmd).not.toContain("sk-test-123");
|
|
138
|
+
expect(cmd).not.toContain("ANTHROPIC_API_KEY");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("produces identical output for the same inputs (deterministic)", () => {
|
|
142
|
+
const opts: SpawnOpts = {
|
|
143
|
+
model: "sonnet",
|
|
144
|
+
permissionMode: "bypass",
|
|
145
|
+
cwd: "/tmp/worktree",
|
|
146
|
+
env: {},
|
|
147
|
+
appendSystemPrompt: "You are a scout.",
|
|
148
|
+
};
|
|
149
|
+
const cmd1 = runtime.buildSpawnCommand(opts);
|
|
150
|
+
const cmd2 = runtime.buildSpawnCommand(opts);
|
|
151
|
+
expect(cmd1).toBe(cmd2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("all model names pass through unchanged", () => {
|
|
155
|
+
for (const model of ["sonnet", "opus", "haiku", "claude-sonnet-4-6", "openrouter/gpt-5"]) {
|
|
156
|
+
const opts: SpawnOpts = {
|
|
157
|
+
model,
|
|
158
|
+
permissionMode: "bypass",
|
|
159
|
+
cwd: "/tmp",
|
|
160
|
+
env: {},
|
|
161
|
+
};
|
|
162
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
163
|
+
expect(cmd).toContain(`--model ${model}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("systemPrompt field is ignored (only appendSystemPrompt is used)", () => {
|
|
168
|
+
const opts: SpawnOpts = {
|
|
169
|
+
model: "sonnet",
|
|
170
|
+
permissionMode: "bypass",
|
|
171
|
+
cwd: "/tmp",
|
|
172
|
+
env: {},
|
|
173
|
+
systemPrompt: "This should not appear",
|
|
174
|
+
};
|
|
175
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
176
|
+
expect(cmd).not.toContain("This should not appear");
|
|
177
|
+
expect(cmd).not.toContain("--system-prompt");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("buildPrintCommand", () => {
|
|
182
|
+
test("basic command without model", () => {
|
|
183
|
+
const argv = runtime.buildPrintCommand("Summarize this diff");
|
|
184
|
+
expect(argv).toEqual(["claude", "--print", "-p", "Summarize this diff"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("command with model override", () => {
|
|
188
|
+
const argv = runtime.buildPrintCommand("Classify this error", "haiku");
|
|
189
|
+
expect(argv).toEqual(["claude", "--print", "-p", "Classify this error", "--model", "haiku"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("model undefined omits --model flag", () => {
|
|
193
|
+
const argv = runtime.buildPrintCommand("Hello", undefined);
|
|
194
|
+
expect(argv).not.toContain("--model");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("detectReady", () => {
|
|
199
|
+
test("returns loading for empty pane", () => {
|
|
200
|
+
const state = runtime.detectReady("");
|
|
201
|
+
expect(state).toEqual({ phase: "loading" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("returns loading for partial content (prompt only, no status bar)", () => {
|
|
205
|
+
const state = runtime.detectReady("Welcome to Claude Code!\n\u276f");
|
|
206
|
+
expect(state).toEqual({ phase: "loading" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("returns loading for partial content (status bar only, no prompt)", () => {
|
|
210
|
+
const state = runtime.detectReady("bypass permissions");
|
|
211
|
+
expect(state).toEqual({ phase: "loading" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("returns ready for prompt indicator ❯ + bypass permissions", () => {
|
|
215
|
+
const state = runtime.detectReady("Welcome to Claude Code!\n\u276f\nbypass permissions");
|
|
216
|
+
expect(state).toEqual({ phase: "ready" });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('returns ready for Try " + bypass permissions', () => {
|
|
220
|
+
const state = runtime.detectReady('Try "help" to get started\nbypass permissions');
|
|
221
|
+
expect(state).toEqual({ phase: "ready" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns loading for prompt indicator + shift+tab (no bypass permissions)", () => {
|
|
225
|
+
// shift+tab appears in ALL Claude Code sessions — it must NOT trigger ready
|
|
226
|
+
const state = runtime.detectReady("Claude Code\n\u276f\nshift+tab to chat");
|
|
227
|
+
expect(state).toEqual({ phase: "loading" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('returns loading for Try " + shift+tab (no bypass permissions)', () => {
|
|
231
|
+
// False-positive scenario: shift+tab alone is not a reliable readiness signal
|
|
232
|
+
const state = runtime.detectReady('Try "help"\nshift+tab');
|
|
233
|
+
expect(state).toEqual({ phase: "loading" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("returns dialog for trust dialog", () => {
|
|
237
|
+
const state = runtime.detectReady("Do you trust this folder? trust this folder");
|
|
238
|
+
expect(state).toEqual({ phase: "dialog", action: "Enter" });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("returns dialog for bypass permissions confirmation", () => {
|
|
242
|
+
const state = runtime.detectReady(
|
|
243
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
244
|
+
);
|
|
245
|
+
expect(state).toEqual({ phase: "dialog", action: "type:2" });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("bypass permissions confirmation takes precedence over ready indicators", () => {
|
|
249
|
+
const state = runtime.detectReady(
|
|
250
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept\nbypass permissions",
|
|
251
|
+
);
|
|
252
|
+
expect(state).toEqual({ phase: "dialog", action: "type:2" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("trust dialog takes precedence over ready indicators", () => {
|
|
256
|
+
const state = runtime.detectReady("trust this folder\n\u276f\nbypass permissions");
|
|
257
|
+
expect(state).toEqual({ phase: "dialog", action: "Enter" });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("returns loading for random pane content", () => {
|
|
261
|
+
const state = runtime.detectReady("Loading Claude Code...\nPlease wait");
|
|
262
|
+
expect(state).toEqual({ phase: "loading" });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("buildEnv", () => {
|
|
267
|
+
test("returns empty object when model has no env", () => {
|
|
268
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
269
|
+
const env = runtime.buildEnv(model);
|
|
270
|
+
expect(env).toEqual({});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("returns model.env when present", () => {
|
|
274
|
+
const model: ResolvedModel = {
|
|
275
|
+
model: "sonnet",
|
|
276
|
+
env: { ANTHROPIC_API_KEY: "sk-test-123", ANTHROPIC_BASE_URL: "https://api.example.com" },
|
|
277
|
+
};
|
|
278
|
+
const env = runtime.buildEnv(model);
|
|
279
|
+
expect(env).toEqual({
|
|
280
|
+
ANTHROPIC_API_KEY: "sk-test-123",
|
|
281
|
+
ANTHROPIC_BASE_URL: "https://api.example.com",
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("returns empty object when model.env is undefined", () => {
|
|
286
|
+
const model: ResolvedModel = { model: "opus", env: undefined };
|
|
287
|
+
const env = runtime.buildEnv(model);
|
|
288
|
+
expect(env).toEqual({});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("deployConfig", () => {
|
|
293
|
+
let tempDir: string;
|
|
294
|
+
|
|
295
|
+
beforeEach(async () => {
|
|
296
|
+
tempDir = await mkdtemp(join(tmpdir(), "agentplate-claude-test-"));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(async () => {
|
|
300
|
+
await cleanupTempDir(tempDir);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("writes overlay to .claude/CLAUDE.md when overlay is provided", async () => {
|
|
304
|
+
const worktreePath = join(tempDir, "worktree");
|
|
305
|
+
|
|
306
|
+
await runtime.deployConfig(
|
|
307
|
+
worktreePath,
|
|
308
|
+
{ content: "# Agent Overlay\nThis is the overlay content." },
|
|
309
|
+
{
|
|
310
|
+
agentName: "test-builder",
|
|
311
|
+
capability: "builder",
|
|
312
|
+
worktreePath,
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
|
|
317
|
+
const content = await Bun.file(overlayPath).text();
|
|
318
|
+
expect(content).toBe("# Agent Overlay\nThis is the overlay content.");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("writes settings.local.json when overlay is provided", async () => {
|
|
322
|
+
const worktreePath = join(tempDir, "worktree");
|
|
323
|
+
|
|
324
|
+
await runtime.deployConfig(
|
|
325
|
+
worktreePath,
|
|
326
|
+
{ content: "# Overlay" },
|
|
327
|
+
{
|
|
328
|
+
agentName: "test-builder",
|
|
329
|
+
capability: "builder",
|
|
330
|
+
worktreePath,
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
335
|
+
const exists = await Bun.file(settingsPath).exists();
|
|
336
|
+
expect(exists).toBe(true);
|
|
337
|
+
|
|
338
|
+
const parsed = JSON.parse(await Bun.file(settingsPath).text());
|
|
339
|
+
expect(parsed.hooks).toBeDefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("skips overlay write when overlay is undefined (hooks-only)", async () => {
|
|
343
|
+
const worktreePath = join(tempDir, "worktree");
|
|
344
|
+
|
|
345
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
346
|
+
agentName: "coordinator",
|
|
347
|
+
capability: "coordinator",
|
|
348
|
+
worktreePath,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// CLAUDE.md should NOT exist (no overlay written)
|
|
352
|
+
const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
|
|
353
|
+
const overlayExists = await Bun.file(overlayPath).exists();
|
|
354
|
+
expect(overlayExists).toBe(false);
|
|
355
|
+
|
|
356
|
+
// But settings.local.json SHOULD exist (hooks deployed)
|
|
357
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
358
|
+
const settingsExists = await Bun.file(settingsPath).exists();
|
|
359
|
+
expect(settingsExists).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("settings.local.json contains agent name", async () => {
|
|
363
|
+
const worktreePath = join(tempDir, "worktree");
|
|
364
|
+
|
|
365
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
366
|
+
agentName: "my-supervisor",
|
|
367
|
+
capability: "supervisor",
|
|
368
|
+
worktreePath,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
372
|
+
const content = await Bun.file(settingsPath).text();
|
|
373
|
+
expect(content).toContain("my-supervisor");
|
|
374
|
+
expect(content).not.toContain("{{AGENT_NAME}}");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("settings.local.json is valid JSON with hooks", async () => {
|
|
378
|
+
const worktreePath = join(tempDir, "worktree");
|
|
379
|
+
|
|
380
|
+
await runtime.deployConfig(
|
|
381
|
+
worktreePath,
|
|
382
|
+
{ content: "# Overlay" },
|
|
383
|
+
{
|
|
384
|
+
agentName: "json-test",
|
|
385
|
+
capability: "builder",
|
|
386
|
+
worktreePath,
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
391
|
+
const content = await Bun.file(settingsPath).text();
|
|
392
|
+
const parsed = JSON.parse(content);
|
|
393
|
+
expect(parsed.hooks).toBeDefined();
|
|
394
|
+
expect(typeof parsed.hooks).toBe("object");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("different capabilities produce different guard sets", async () => {
|
|
398
|
+
const builderPath = join(tempDir, "builder-wt");
|
|
399
|
+
const scoutPath = join(tempDir, "scout-wt");
|
|
400
|
+
|
|
401
|
+
await runtime.deployConfig(
|
|
402
|
+
builderPath,
|
|
403
|
+
{ content: "# Builder" },
|
|
404
|
+
{ agentName: "test-builder", capability: "builder", worktreePath: builderPath },
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
await runtime.deployConfig(
|
|
408
|
+
scoutPath,
|
|
409
|
+
{ content: "# Scout" },
|
|
410
|
+
{ agentName: "test-scout", capability: "scout", worktreePath: scoutPath },
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const builderSettings = await Bun.file(
|
|
414
|
+
join(builderPath, ".claude", "settings.local.json"),
|
|
415
|
+
).text();
|
|
416
|
+
const scoutSettings = await Bun.file(
|
|
417
|
+
join(scoutPath, ".claude", "settings.local.json"),
|
|
418
|
+
).text();
|
|
419
|
+
|
|
420
|
+
// Scout should have file-modification guards that builder doesn't
|
|
421
|
+
// Scout is non-implementation, builder is implementation
|
|
422
|
+
expect(scoutSettings).not.toBe(builderSettings);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("writes PreToolUse-only settings.local.json when isHeadless is true", async () => {
|
|
426
|
+
// agentplate-e24b: headless Claude Code DOES dispatch settings.local.json hooks,
|
|
427
|
+
// so the security guards (PreToolUse) must be deployed even in headless mode.
|
|
428
|
+
// Non-PreToolUse events have headless equivalents (initial stdin prompt, mail
|
|
429
|
+
// injection loop, stream-json parser) and are stripped to avoid duplicate work.
|
|
430
|
+
const worktreePath = join(tempDir, "headless-wt");
|
|
431
|
+
|
|
432
|
+
await runtime.deployConfig(
|
|
433
|
+
worktreePath,
|
|
434
|
+
{ content: "# Headless Overlay" },
|
|
435
|
+
{
|
|
436
|
+
agentName: "headless-builder",
|
|
437
|
+
capability: "builder",
|
|
438
|
+
worktreePath,
|
|
439
|
+
isHeadless: true,
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// Overlay still written
|
|
444
|
+
const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
|
|
445
|
+
expect(await Bun.file(overlayPath).exists()).toBe(true);
|
|
446
|
+
|
|
447
|
+
// Hooks file IS created in headless mode (reversal of agentplate-1c32 design Q6)
|
|
448
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
449
|
+
expect(await Bun.file(settingsPath).exists()).toBe(true);
|
|
450
|
+
|
|
451
|
+
const parsed = JSON.parse(await Bun.file(settingsPath).text()) as {
|
|
452
|
+
hooks: Record<string, unknown[]>;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Only PreToolUse entries — SessionStart/UserPromptSubmit/PostToolUse/Stop/PreCompact stripped
|
|
456
|
+
expect(Object.keys(parsed.hooks)).toEqual(["PreToolUse"]);
|
|
457
|
+
expect(parsed.hooks.PreToolUse?.length ?? 0).toBeGreaterThan(0);
|
|
458
|
+
|
|
459
|
+
// Sanity: the deployed PreToolUse guards include the destructive-command blocks
|
|
460
|
+
// that were the operational concern in agentplate-e24b.
|
|
461
|
+
const serialized = JSON.stringify(parsed.hooks.PreToolUse);
|
|
462
|
+
expect(serialized).toContain("git push is blocked");
|
|
463
|
+
expect(serialized).toContain("git reset --hard");
|
|
464
|
+
expect(serialized).toContain("Path boundary violation");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("still writes settings.local.json when isHeadless is false", async () => {
|
|
468
|
+
const worktreePath = join(tempDir, "tmux-wt");
|
|
469
|
+
|
|
470
|
+
await runtime.deployConfig(
|
|
471
|
+
worktreePath,
|
|
472
|
+
{ content: "# Tmux Overlay" },
|
|
473
|
+
{
|
|
474
|
+
agentName: "tmux-builder",
|
|
475
|
+
capability: "builder",
|
|
476
|
+
worktreePath,
|
|
477
|
+
isHeadless: false,
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
482
|
+
const settingsExists = await Bun.file(settingsPath).exists();
|
|
483
|
+
expect(settingsExists).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("still writes settings.local.json when isHeadless is omitted (backward compat)", async () => {
|
|
487
|
+
const worktreePath = join(tempDir, "default-wt");
|
|
488
|
+
|
|
489
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
490
|
+
agentName: "default-agent",
|
|
491
|
+
capability: "builder",
|
|
492
|
+
worktreePath,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const settingsPath = join(worktreePath, ".claude", "settings.local.json");
|
|
496
|
+
const settingsExists = await Bun.file(settingsPath).exists();
|
|
497
|
+
expect(settingsExists).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("parseTranscript", () => {
|
|
502
|
+
let tempDir: string;
|
|
503
|
+
|
|
504
|
+
beforeEach(async () => {
|
|
505
|
+
tempDir = await mkdtemp(join(tmpdir(), "agentplate-transcript-test-"));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
afterEach(async () => {
|
|
509
|
+
await cleanupTempDir(tempDir);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("returns null for non-existent file", async () => {
|
|
513
|
+
const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
|
|
514
|
+
expect(result).toBeNull();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("parses a valid transcript with one assistant turn", async () => {
|
|
518
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
519
|
+
const entry = JSON.stringify({
|
|
520
|
+
type: "assistant",
|
|
521
|
+
message: {
|
|
522
|
+
model: "claude-sonnet-4-6",
|
|
523
|
+
usage: {
|
|
524
|
+
input_tokens: 100,
|
|
525
|
+
output_tokens: 50,
|
|
526
|
+
cache_read_input_tokens: 500,
|
|
527
|
+
cache_creation_input_tokens: 200,
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
await Bun.write(transcriptPath, `${entry}\n`);
|
|
532
|
+
|
|
533
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
534
|
+
expect(result).not.toBeNull();
|
|
535
|
+
expect(result?.inputTokens).toBe(100);
|
|
536
|
+
expect(result?.outputTokens).toBe(50);
|
|
537
|
+
expect(result?.model).toBe("claude-sonnet-4-6");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("aggregates multiple assistant turns", async () => {
|
|
541
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
542
|
+
const entry1 = JSON.stringify({
|
|
543
|
+
type: "assistant",
|
|
544
|
+
message: {
|
|
545
|
+
model: "claude-sonnet-4-6",
|
|
546
|
+
usage: { input_tokens: 100, output_tokens: 50 },
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
const entry2 = JSON.stringify({
|
|
550
|
+
type: "assistant",
|
|
551
|
+
message: {
|
|
552
|
+
model: "claude-sonnet-4-6",
|
|
553
|
+
usage: { input_tokens: 200, output_tokens: 75 },
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
await Bun.write(transcriptPath, `${entry1}\n${entry2}\n`);
|
|
557
|
+
|
|
558
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
559
|
+
expect(result).not.toBeNull();
|
|
560
|
+
expect(result?.inputTokens).toBe(300);
|
|
561
|
+
expect(result?.outputTokens).toBe(125);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("skips non-assistant entries", async () => {
|
|
565
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
566
|
+
const userEntry = JSON.stringify({ type: "user", message: { content: "hello" } });
|
|
567
|
+
const assistantEntry = JSON.stringify({
|
|
568
|
+
type: "assistant",
|
|
569
|
+
message: {
|
|
570
|
+
model: "claude-sonnet-4-6",
|
|
571
|
+
usage: { input_tokens: 50, output_tokens: 25 },
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
await Bun.write(transcriptPath, `${userEntry}\n${assistantEntry}\n`);
|
|
575
|
+
|
|
576
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
577
|
+
expect(result).not.toBeNull();
|
|
578
|
+
expect(result?.inputTokens).toBe(50);
|
|
579
|
+
expect(result?.outputTokens).toBe(25);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("returns null for malformed file", async () => {
|
|
583
|
+
const transcriptPath = join(tempDir, "bad.jsonl");
|
|
584
|
+
await Bun.write(transcriptPath, "not json at all\n{broken");
|
|
585
|
+
|
|
586
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
587
|
+
// parseTranscriptUsage should handle gracefully; result may have 0 tokens
|
|
588
|
+
// If it throws, ClaudeRuntime catches and returns null
|
|
589
|
+
if (result !== null) {
|
|
590
|
+
expect(result.inputTokens).toBe(0);
|
|
591
|
+
expect(result.outputTokens).toBe(0);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
describe("ClaudeRuntime integration: spawn command matches pre-refactor behavior", () => {
|
|
598
|
+
const runtime = new ClaudeRuntime();
|
|
599
|
+
|
|
600
|
+
test("sling-style spawn: bypass mode, no system prompt", () => {
|
|
601
|
+
const cmd = runtime.buildSpawnCommand({
|
|
602
|
+
model: "sonnet",
|
|
603
|
+
permissionMode: "bypass",
|
|
604
|
+
cwd: "/project/.agentplate/worktrees/builder-1",
|
|
605
|
+
env: { AGENTPLATE_AGENT_NAME: "builder-1" },
|
|
606
|
+
});
|
|
607
|
+
// Pre-refactor: `claude --model ${model} --permission-mode bypassPermissions`
|
|
608
|
+
expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("coordinator-style spawn: bypass mode with appendSystemPrompt", () => {
|
|
612
|
+
const baseDefinition = "# Coordinator\nYou are the coordinator agent.";
|
|
613
|
+
const cmd = runtime.buildSpawnCommand({
|
|
614
|
+
model: "opus",
|
|
615
|
+
permissionMode: "bypass",
|
|
616
|
+
cwd: "/project",
|
|
617
|
+
appendSystemPrompt: baseDefinition,
|
|
618
|
+
env: { AGENTPLATE_AGENT_NAME: "coordinator" },
|
|
619
|
+
});
|
|
620
|
+
// Pre-refactor: `claude --model ${model} --permission-mode bypassPermissions --append-system-prompt '...'`
|
|
621
|
+
expect(cmd).toBe(
|
|
622
|
+
`claude --model opus --permission-mode bypassPermissions --append-system-prompt '# Coordinator\nYou are the coordinator agent.'`,
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("supervisor-style spawn: identical to coordinator pattern", () => {
|
|
627
|
+
const baseDefinition = "# Supervisor\nYou manage a project.";
|
|
628
|
+
const cmd = runtime.buildSpawnCommand({
|
|
629
|
+
model: "opus",
|
|
630
|
+
permissionMode: "bypass",
|
|
631
|
+
cwd: "/project",
|
|
632
|
+
appendSystemPrompt: baseDefinition,
|
|
633
|
+
env: { AGENTPLATE_AGENT_NAME: "supervisor-1" },
|
|
634
|
+
});
|
|
635
|
+
expect(cmd).toContain("--model opus");
|
|
636
|
+
expect(cmd).toContain("--permission-mode bypassPermissions");
|
|
637
|
+
expect(cmd).toContain("--append-system-prompt");
|
|
638
|
+
expect(cmd).toContain("# Supervisor");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("monitor-style spawn: sonnet model with appendSystemPrompt", () => {
|
|
642
|
+
const baseDefinition = "# Monitor\nYou patrol the fleet.";
|
|
643
|
+
const cmd = runtime.buildSpawnCommand({
|
|
644
|
+
model: "sonnet",
|
|
645
|
+
permissionMode: "bypass",
|
|
646
|
+
cwd: "/project",
|
|
647
|
+
appendSystemPrompt: baseDefinition,
|
|
648
|
+
env: { AGENTPLATE_AGENT_NAME: "monitor" },
|
|
649
|
+
});
|
|
650
|
+
expect(cmd).toBe(
|
|
651
|
+
`claude --model sonnet --permission-mode bypassPermissions --append-system-prompt '# Monitor\nYou patrol the fleet.'`,
|
|
652
|
+
);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe("ClaudeRuntime integration: detectReady matches pre-refactor tmux behavior", () => {
|
|
657
|
+
const runtime = new ClaudeRuntime();
|
|
658
|
+
|
|
659
|
+
// These test cases mirror the exact pane content strings used in tmux.test.ts
|
|
660
|
+
// to verify the callback produces identical behavior to the old hardcoded detection.
|
|
661
|
+
|
|
662
|
+
test("ready: 'Try \"help\" to get started' + 'bypass permissions'", () => {
|
|
663
|
+
const state = runtime.detectReady('Try "help" to get started\nbypass permissions');
|
|
664
|
+
expect(state.phase).toBe("ready");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("ready: ❯ + 'bypass permissions'", () => {
|
|
668
|
+
const state = runtime.detectReady("Welcome to Claude Code!\n\n\u276f\nbypass permissions");
|
|
669
|
+
expect(state.phase).toBe("ready");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("loading: 'Try \"help\"' + 'shift+tab' (no bypass permissions — false-positive fix)", () => {
|
|
673
|
+
// shift+tab appears in all Claude Code sessions, must not trigger ready without bypass permissions
|
|
674
|
+
const state = runtime.detectReady('Try "help"\nshift+tab');
|
|
675
|
+
expect(state.phase).toBe("loading");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("not ready: only prompt (no status bar)", () => {
|
|
679
|
+
const state = runtime.detectReady("Welcome to Claude Code!\n\u276f");
|
|
680
|
+
expect(state.phase).toBe("loading");
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("not ready: only status bar (no prompt)", () => {
|
|
684
|
+
const state = runtime.detectReady("bypass permissions");
|
|
685
|
+
expect(state.phase).toBe("loading");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("dialog: trust this folder", () => {
|
|
689
|
+
const state = runtime.detectReady("Do you trust this folder? trust this folder");
|
|
690
|
+
expect(state.phase).toBe("dialog");
|
|
691
|
+
expect((state as { phase: "dialog"; action: string }).action).toBe("Enter");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("dialog: bypass permissions confirmation", () => {
|
|
695
|
+
const state = runtime.detectReady(
|
|
696
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
697
|
+
);
|
|
698
|
+
expect(state.phase).toBe("dialog");
|
|
699
|
+
expect((state as { phase: "dialog"; action: string }).action).toBe("type:2");
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe("ClaudeRuntime integration: buildEnv matches pre-refactor env injection", () => {
|
|
704
|
+
const runtime = new ClaudeRuntime();
|
|
705
|
+
|
|
706
|
+
test("native Anthropic model: passes env through", () => {
|
|
707
|
+
const model: ResolvedModel = {
|
|
708
|
+
model: "sonnet",
|
|
709
|
+
env: { ANTHROPIC_API_KEY: "sk-ant-test" },
|
|
710
|
+
};
|
|
711
|
+
const env = runtime.buildEnv(model);
|
|
712
|
+
expect(env).toEqual({ ANTHROPIC_API_KEY: "sk-ant-test" });
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("gateway model: passes gateway env through", () => {
|
|
716
|
+
const model: ResolvedModel = {
|
|
717
|
+
model: "openrouter/gpt-5",
|
|
718
|
+
env: { OPENROUTER_API_KEY: "sk-or-test", OPENAI_BASE_URL: "https://openrouter.ai/api/v1" },
|
|
719
|
+
};
|
|
720
|
+
const env = runtime.buildEnv(model);
|
|
721
|
+
expect(env).toEqual({
|
|
722
|
+
OPENROUTER_API_KEY: "sk-or-test",
|
|
723
|
+
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("model without env: returns empty object (safe to spread)", () => {
|
|
728
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
729
|
+
const env = runtime.buildEnv(model);
|
|
730
|
+
expect(env).toEqual({});
|
|
731
|
+
// Must be safe to spread into createSession env
|
|
732
|
+
const combined = { ...env, AGENTPLATE_AGENT_NAME: "builder-1" };
|
|
733
|
+
expect(combined).toEqual({ AGENTPLATE_AGENT_NAME: "builder-1" });
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe("ClaudeRuntime integration: registry resolves 'claude' as default", () => {
|
|
738
|
+
// Import registry here to test the full resolution path
|
|
739
|
+
test("getRuntime() returns ClaudeRuntime", async () => {
|
|
740
|
+
const { getRuntime } = await import("./registry.ts");
|
|
741
|
+
const rt = getRuntime();
|
|
742
|
+
expect(rt).toBeInstanceOf(ClaudeRuntime);
|
|
743
|
+
expect(rt.id).toBe("claude");
|
|
744
|
+
expect(rt.instructionPath).toBe(".claude/CLAUDE.md");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("getRuntime('claude') returns ClaudeRuntime", async () => {
|
|
748
|
+
const { getRuntime } = await import("./registry.ts");
|
|
749
|
+
const rt = getRuntime("claude");
|
|
750
|
+
expect(rt).toBeInstanceOf(ClaudeRuntime);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("getRuntime rejects unknown runtimes", async () => {
|
|
754
|
+
const { getRuntime } = await import("./registry.ts");
|
|
755
|
+
expect(() => getRuntime("nonexistent-runtime")).toThrow(
|
|
756
|
+
'Unknown runtime: "nonexistent-runtime"',
|
|
757
|
+
);
|
|
758
|
+
expect(() => getRuntime("does-not-exist")).toThrow('Unknown runtime: "does-not-exist"');
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// ─── buildDirectSpawn ────────────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
describe("ClaudeRuntime.buildDirectSpawn", () => {
|
|
765
|
+
const runtime = new ClaudeRuntime();
|
|
766
|
+
|
|
767
|
+
test("returns fixed headless argv without model", () => {
|
|
768
|
+
const opts: DirectSpawnOpts = {
|
|
769
|
+
cwd: "/worktree",
|
|
770
|
+
env: {},
|
|
771
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
772
|
+
};
|
|
773
|
+
expect(runtime.buildDirectSpawn(opts)).toEqual([
|
|
774
|
+
"claude",
|
|
775
|
+
"-p",
|
|
776
|
+
"--output-format",
|
|
777
|
+
"stream-json",
|
|
778
|
+
"--input-format",
|
|
779
|
+
"stream-json",
|
|
780
|
+
"--verbose",
|
|
781
|
+
"--strict-mcp-config",
|
|
782
|
+
"--permission-mode",
|
|
783
|
+
"bypassPermissions",
|
|
784
|
+
]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("appends --model when model is specified", () => {
|
|
788
|
+
const opts: DirectSpawnOpts = {
|
|
789
|
+
cwd: "/worktree",
|
|
790
|
+
env: {},
|
|
791
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
792
|
+
model: "claude-sonnet-4-6",
|
|
793
|
+
};
|
|
794
|
+
const argv = runtime.buildDirectSpawn(opts);
|
|
795
|
+
expect(argv.at(-2)).toBe("--model");
|
|
796
|
+
expect(argv.at(-1)).toBe("claude-sonnet-4-6");
|
|
797
|
+
expect(argv).toHaveLength(12);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("does not include instructionPath in argv", () => {
|
|
801
|
+
const opts: DirectSpawnOpts = {
|
|
802
|
+
cwd: "/worktree",
|
|
803
|
+
env: {},
|
|
804
|
+
instructionPath: "/secret/path/CLAUDE.md",
|
|
805
|
+
};
|
|
806
|
+
const argv = runtime.buildDirectSpawn(opts);
|
|
807
|
+
expect(argv.join(" ")).not.toContain("secret");
|
|
808
|
+
expect(argv.join(" ")).not.toContain("CLAUDE.md");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("model undefined omits --model flag", () => {
|
|
812
|
+
const opts: DirectSpawnOpts = {
|
|
813
|
+
cwd: "/worktree",
|
|
814
|
+
env: {},
|
|
815
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
816
|
+
model: undefined,
|
|
817
|
+
};
|
|
818
|
+
expect(runtime.buildDirectSpawn(opts)).not.toContain("--model");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("resumeSessionId emits --resume <id> after --model", () => {
|
|
822
|
+
const opts: DirectSpawnOpts = {
|
|
823
|
+
cwd: "/worktree",
|
|
824
|
+
env: {},
|
|
825
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
826
|
+
model: "claude-sonnet-4-6",
|
|
827
|
+
resumeSessionId: "sess-resume-abc",
|
|
828
|
+
};
|
|
829
|
+
const argv = runtime.buildDirectSpawn(opts);
|
|
830
|
+
// --model and its value precede --resume and its value
|
|
831
|
+
const modelIdx = argv.indexOf("--model");
|
|
832
|
+
const resumeIdx = argv.indexOf("--resume");
|
|
833
|
+
expect(modelIdx).toBeGreaterThan(-1);
|
|
834
|
+
expect(resumeIdx).toBeGreaterThan(modelIdx + 1);
|
|
835
|
+
expect(argv[resumeIdx + 1]).toBe("sess-resume-abc");
|
|
836
|
+
// Trailing pair is --resume <id>
|
|
837
|
+
expect(argv.at(-2)).toBe("--resume");
|
|
838
|
+
expect(argv.at(-1)).toBe("sess-resume-abc");
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("omits --resume when resumeSessionId is undefined", () => {
|
|
842
|
+
const opts: DirectSpawnOpts = {
|
|
843
|
+
cwd: "/worktree",
|
|
844
|
+
env: {},
|
|
845
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
846
|
+
};
|
|
847
|
+
expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("omits --resume when resumeSessionId is empty string", () => {
|
|
851
|
+
const opts: DirectSpawnOpts = {
|
|
852
|
+
cwd: "/worktree",
|
|
853
|
+
env: {},
|
|
854
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
855
|
+
resumeSessionId: "",
|
|
856
|
+
};
|
|
857
|
+
expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("omits --resume when resumeSessionId is null", () => {
|
|
861
|
+
const opts: DirectSpawnOpts = {
|
|
862
|
+
cwd: "/worktree",
|
|
863
|
+
env: {},
|
|
864
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
865
|
+
resumeSessionId: null,
|
|
866
|
+
};
|
|
867
|
+
expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// ─── parseEvents unit tests ──────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
function toStream(s: string): ReadableStream<Uint8Array> {
|
|
874
|
+
return new ReadableStream({
|
|
875
|
+
start(controller) {
|
|
876
|
+
controller.enqueue(new TextEncoder().encode(s));
|
|
877
|
+
controller.close();
|
|
878
|
+
},
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function toChunkedStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
883
|
+
const enc = new TextEncoder();
|
|
884
|
+
return new ReadableStream({
|
|
885
|
+
start(controller) {
|
|
886
|
+
for (const c of chunks) controller.enqueue(enc.encode(c));
|
|
887
|
+
controller.close();
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function collectEvents(stream: ReadableStream<Uint8Array>): Promise<AgentEvent[]> {
|
|
893
|
+
const rt = new ClaudeRuntime();
|
|
894
|
+
const events: AgentEvent[] = [];
|
|
895
|
+
for await (const ev of rt.parseEvents(stream)) {
|
|
896
|
+
events.push(ev);
|
|
897
|
+
}
|
|
898
|
+
return events;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
describe("ClaudeRuntime.parseEvents unit", () => {
|
|
902
|
+
test("empty stream yields no events", async () => {
|
|
903
|
+
const events = await collectEvents(toStream(""));
|
|
904
|
+
expect(events).toHaveLength(0);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("system message → status event with sessionId and subtype", async () => {
|
|
908
|
+
const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
|
|
909
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
910
|
+
expect(events).toHaveLength(1);
|
|
911
|
+
const ev = events[0];
|
|
912
|
+
expect(ev?.type).toBe("status");
|
|
913
|
+
expect(ev?.sessionId).toBe("sess-abc");
|
|
914
|
+
expect(ev?.subtype).toBe("init");
|
|
915
|
+
expect(typeof ev?.timestamp).toBe("string");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("assistant text block → assistant_message with text, model, usage", async () => {
|
|
919
|
+
const line = JSON.stringify({
|
|
920
|
+
type: "assistant",
|
|
921
|
+
message: {
|
|
922
|
+
model: "claude-sonnet-4-6",
|
|
923
|
+
content: [{ type: "text", text: "hello world" }],
|
|
924
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
928
|
+
expect(events).toHaveLength(1);
|
|
929
|
+
const ev = events[0];
|
|
930
|
+
expect(ev?.type).toBe("assistant_message");
|
|
931
|
+
expect(ev?.text).toBe("hello world");
|
|
932
|
+
expect(ev?.model).toBe("claude-sonnet-4-6");
|
|
933
|
+
expect((ev?.usage as Record<string, number>)?.input_tokens).toBe(10);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("assistant text block without model/usage omits those fields", async () => {
|
|
937
|
+
const line = JSON.stringify({
|
|
938
|
+
type: "assistant",
|
|
939
|
+
message: { content: [{ type: "text", text: "bare text" }] },
|
|
940
|
+
});
|
|
941
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
942
|
+
expect(events).toHaveLength(1);
|
|
943
|
+
const ev = events[0];
|
|
944
|
+
expect(ev).toBeDefined();
|
|
945
|
+
if (!ev) return;
|
|
946
|
+
expect(ev.type).toBe("assistant_message");
|
|
947
|
+
expect(ev.text).toBe("bare text");
|
|
948
|
+
expect(Object.hasOwn(ev, "model")).toBe(false);
|
|
949
|
+
expect(Object.hasOwn(ev, "usage")).toBe(false);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("assistant tool_use block → tool_use event with callId, name, input", async () => {
|
|
953
|
+
const line = JSON.stringify({
|
|
954
|
+
type: "assistant",
|
|
955
|
+
message: {
|
|
956
|
+
content: [
|
|
957
|
+
{
|
|
958
|
+
type: "tool_use",
|
|
959
|
+
id: "call-1",
|
|
960
|
+
name: "Read",
|
|
961
|
+
input: { path: "/tmp/foo.ts" },
|
|
962
|
+
},
|
|
963
|
+
],
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
967
|
+
expect(events).toHaveLength(1);
|
|
968
|
+
const ev = events[0];
|
|
969
|
+
expect(ev?.type).toBe("tool_use");
|
|
970
|
+
expect(ev?.callId).toBe("call-1");
|
|
971
|
+
expect(ev?.name).toBe("Read");
|
|
972
|
+
expect((ev?.input as Record<string, string>)?.path).toBe("/tmp/foo.ts");
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("assistant thinking block is skipped", async () => {
|
|
976
|
+
const line = JSON.stringify({
|
|
977
|
+
type: "assistant",
|
|
978
|
+
message: {
|
|
979
|
+
content: [{ type: "thinking", thinking: "let me think" }],
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
983
|
+
expect(events).toHaveLength(0);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("user tool_result block → tool_result event with toolUseId and content", async () => {
|
|
987
|
+
const line = JSON.stringify({
|
|
988
|
+
type: "user",
|
|
989
|
+
message: {
|
|
990
|
+
content: [
|
|
991
|
+
{
|
|
992
|
+
type: "tool_result",
|
|
993
|
+
tool_use_id: "call-1",
|
|
994
|
+
content: "file contents here",
|
|
995
|
+
},
|
|
996
|
+
],
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
1000
|
+
expect(events).toHaveLength(1);
|
|
1001
|
+
const ev = events[0];
|
|
1002
|
+
expect(ev?.type).toBe("tool_result");
|
|
1003
|
+
expect(ev?.toolUseId).toBe("call-1");
|
|
1004
|
+
expect(ev?.content).toBe("file contents here");
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("result message → result event with all fields", async () => {
|
|
1008
|
+
const line = JSON.stringify({
|
|
1009
|
+
type: "result",
|
|
1010
|
+
session_id: "sess-xyz",
|
|
1011
|
+
result: "task complete",
|
|
1012
|
+
is_error: false,
|
|
1013
|
+
duration_ms: 2500,
|
|
1014
|
+
num_turns: 3,
|
|
1015
|
+
});
|
|
1016
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
1017
|
+
expect(events).toHaveLength(1);
|
|
1018
|
+
const ev = events[0];
|
|
1019
|
+
expect(ev?.type).toBe("result");
|
|
1020
|
+
expect(ev?.sessionId).toBe("sess-xyz");
|
|
1021
|
+
expect(ev?.result).toBe("task complete");
|
|
1022
|
+
expect(ev?.isError).toBe(false);
|
|
1023
|
+
expect(ev?.durationMs).toBe(2500);
|
|
1024
|
+
expect(ev?.numTurns).toBe(3);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("unknown message type (log, control_request) is skipped", async () => {
|
|
1028
|
+
const lines = [
|
|
1029
|
+
JSON.stringify({ type: "log", message: "some log line" }),
|
|
1030
|
+
JSON.stringify({ type: "control_request", payload: {} }),
|
|
1031
|
+
].join("\n");
|
|
1032
|
+
const events = await collectEvents(toStream(`${lines}\n`));
|
|
1033
|
+
expect(events).toHaveLength(0);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("multi-block assistant message [text, tool_use, text] yields 3 events in order", async () => {
|
|
1037
|
+
const line = JSON.stringify({
|
|
1038
|
+
type: "assistant",
|
|
1039
|
+
message: {
|
|
1040
|
+
content: [
|
|
1041
|
+
{ type: "text", text: "first" },
|
|
1042
|
+
{ type: "tool_use", id: "c1", name: "Bash", input: { cmd: "ls" } },
|
|
1043
|
+
{ type: "text", text: "second" },
|
|
1044
|
+
],
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
1048
|
+
expect(events).toHaveLength(3);
|
|
1049
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1050
|
+
expect(events[0]?.text).toBe("first");
|
|
1051
|
+
expect(events[1]?.type).toBe("tool_use");
|
|
1052
|
+
expect(events[1]?.name).toBe("Bash");
|
|
1053
|
+
expect(events[2]?.type).toBe("assistant_message");
|
|
1054
|
+
expect(events[2]?.text).toBe("second");
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test("user message with multiple tool_result blocks yields one event per block", async () => {
|
|
1058
|
+
const line = JSON.stringify({
|
|
1059
|
+
type: "user",
|
|
1060
|
+
message: {
|
|
1061
|
+
content: [
|
|
1062
|
+
{ type: "tool_result", tool_use_id: "c1", content: "result 1" },
|
|
1063
|
+
{ type: "tool_result", tool_use_id: "c2", content: "result 2" },
|
|
1064
|
+
],
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
const events = await collectEvents(toStream(`${line}\n`));
|
|
1068
|
+
expect(events).toHaveLength(2);
|
|
1069
|
+
expect(events[0]?.toolUseId).toBe("c1");
|
|
1070
|
+
expect(events[1]?.toolUseId).toBe("c2");
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test("partial lines (chunked reads) are buffered until newline arrives", async () => {
|
|
1074
|
+
const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-chunked" });
|
|
1075
|
+
// Split the JSON at an arbitrary byte boundary
|
|
1076
|
+
const mid = Math.floor(line.length / 2);
|
|
1077
|
+
const chunks = [line.slice(0, mid), line.slice(mid), "\n"];
|
|
1078
|
+
const events = await collectEvents(toChunkedStream(chunks));
|
|
1079
|
+
expect(events).toHaveLength(1);
|
|
1080
|
+
expect(events[0]?.type).toBe("status");
|
|
1081
|
+
expect(events[0]?.sessionId).toBe("sess-chunked");
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("malformed lines are silently skipped", async () => {
|
|
1085
|
+
const good = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
|
|
1086
|
+
const input = `${good}\nnot json at all\n{broken\n`;
|
|
1087
|
+
const events = await collectEvents(toStream(input));
|
|
1088
|
+
expect(events).toHaveLength(1);
|
|
1089
|
+
expect(events[0]?.type).toBe("status");
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("trailing data without newline is flushed", async () => {
|
|
1093
|
+
const line = JSON.stringify({ type: "system", subtype: "init", session_id: "s-trailing" });
|
|
1094
|
+
// No trailing newline
|
|
1095
|
+
const events = await collectEvents(toStream(line));
|
|
1096
|
+
expect(events).toHaveLength(1);
|
|
1097
|
+
expect(events[0]?.sessionId).toBe("s-trailing");
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("empty lines between events are ignored", async () => {
|
|
1101
|
+
const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
|
|
1102
|
+
const l2 = JSON.stringify({
|
|
1103
|
+
type: "result",
|
|
1104
|
+
session_id: "s1",
|
|
1105
|
+
result: "ok",
|
|
1106
|
+
is_error: false,
|
|
1107
|
+
duration_ms: 1,
|
|
1108
|
+
num_turns: 1,
|
|
1109
|
+
});
|
|
1110
|
+
const input = `${l1}\n\n\n${l2}\n`;
|
|
1111
|
+
const events = await collectEvents(toStream(input));
|
|
1112
|
+
expect(events).toHaveLength(2);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test("multiple valid lines in sequence yield events in order", async () => {
|
|
1116
|
+
const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
|
|
1117
|
+
const l2 = JSON.stringify({
|
|
1118
|
+
type: "assistant",
|
|
1119
|
+
message: { content: [{ type: "text", text: "hi" }] },
|
|
1120
|
+
});
|
|
1121
|
+
const l3 = JSON.stringify({
|
|
1122
|
+
type: "result",
|
|
1123
|
+
session_id: "s1",
|
|
1124
|
+
result: "done",
|
|
1125
|
+
is_error: false,
|
|
1126
|
+
duration_ms: 0,
|
|
1127
|
+
num_turns: 1,
|
|
1128
|
+
});
|
|
1129
|
+
const events = await collectEvents(toStream(`${l1}\n${l2}\n${l3}\n`));
|
|
1130
|
+
expect(events[0]?.type).toBe("status");
|
|
1131
|
+
expect(events[1]?.type).toBe("assistant_message");
|
|
1132
|
+
expect(events[2]?.type).toBe("result");
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// ─── parseEvents onSessionId hook ────────────────────────────────────────────
|
|
1137
|
+
|
|
1138
|
+
describe("ClaudeRuntime.parseEvents onSessionId hook", () => {
|
|
1139
|
+
test("fires onSessionId once on first system event", async () => {
|
|
1140
|
+
const rt = new ClaudeRuntime();
|
|
1141
|
+
const called: string[] = [];
|
|
1142
|
+
const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
|
|
1143
|
+
for await (const _ of rt.parseEvents(toStream(`${line}\n`), {
|
|
1144
|
+
onSessionId: (sid) => called.push(sid),
|
|
1145
|
+
})) {
|
|
1146
|
+
// consume
|
|
1147
|
+
}
|
|
1148
|
+
expect(called).toHaveLength(1);
|
|
1149
|
+
expect(called[0]).toBe("sess-abc");
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
test("does not fire when stream ends before any session_id event", async () => {
|
|
1153
|
+
const rt = new ClaudeRuntime();
|
|
1154
|
+
const called: string[] = [];
|
|
1155
|
+
const line = JSON.stringify({
|
|
1156
|
+
type: "assistant",
|
|
1157
|
+
message: { content: [{ type: "text", text: "hello" }] },
|
|
1158
|
+
});
|
|
1159
|
+
for await (const _ of rt.parseEvents(toStream(`${line}\n`), {
|
|
1160
|
+
onSessionId: (sid) => called.push(sid),
|
|
1161
|
+
})) {
|
|
1162
|
+
// consume
|
|
1163
|
+
}
|
|
1164
|
+
expect(called).toHaveLength(0);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("does not fire on subsequent events with same/different session_id", async () => {
|
|
1168
|
+
const rt = new ClaudeRuntime();
|
|
1169
|
+
const called: string[] = [];
|
|
1170
|
+
const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
|
|
1171
|
+
const l2 = JSON.stringify({
|
|
1172
|
+
type: "result",
|
|
1173
|
+
session_id: "sess-abc",
|
|
1174
|
+
result: "ok",
|
|
1175
|
+
is_error: false,
|
|
1176
|
+
duration_ms: 1,
|
|
1177
|
+
num_turns: 1,
|
|
1178
|
+
});
|
|
1179
|
+
for await (const _ of rt.parseEvents(toStream(`${l1}\n${l2}\n`), {
|
|
1180
|
+
onSessionId: (sid) => called.push(sid),
|
|
1181
|
+
})) {
|
|
1182
|
+
// consume
|
|
1183
|
+
}
|
|
1184
|
+
expect(called).toHaveLength(1);
|
|
1185
|
+
expect(called[0]).toBe("sess-abc");
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
test("callback errors do not crash the parser", async () => {
|
|
1189
|
+
const rt = new ClaudeRuntime();
|
|
1190
|
+
const sysLine = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-err" });
|
|
1191
|
+
const textLine = JSON.stringify({
|
|
1192
|
+
type: "assistant",
|
|
1193
|
+
message: { content: [{ type: "text", text: "after error" }] },
|
|
1194
|
+
});
|
|
1195
|
+
const events: AgentEvent[] = [];
|
|
1196
|
+
for await (const ev of rt.parseEvents(toStream(`${sysLine}\n${textLine}\n`), {
|
|
1197
|
+
onSessionId: () => {
|
|
1198
|
+
throw new Error("intentional consumer error");
|
|
1199
|
+
},
|
|
1200
|
+
})) {
|
|
1201
|
+
events.push(ev);
|
|
1202
|
+
}
|
|
1203
|
+
// Both events should still be yielded despite the callback throwing
|
|
1204
|
+
expect(events).toHaveLength(2);
|
|
1205
|
+
expect(events[0]?.type).toBe("status");
|
|
1206
|
+
expect(events[1]?.type).toBe("assistant_message");
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test("callback runs synchronously before next yield", async () => {
|
|
1210
|
+
const rt = new ClaudeRuntime();
|
|
1211
|
+
const order: string[] = [];
|
|
1212
|
+
const sysLine = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-sync" });
|
|
1213
|
+
const textLine = JSON.stringify({
|
|
1214
|
+
type: "assistant",
|
|
1215
|
+
message: { content: [{ type: "text", text: "second" }] },
|
|
1216
|
+
});
|
|
1217
|
+
for await (const ev of rt.parseEvents(toStream(`${sysLine}\n${textLine}\n`), {
|
|
1218
|
+
onSessionId: (sid) => order.push(`callback:${sid}`),
|
|
1219
|
+
})) {
|
|
1220
|
+
order.push(`event:${ev.type}`);
|
|
1221
|
+
}
|
|
1222
|
+
// callback must appear before the second event (synchronous inline)
|
|
1223
|
+
expect(order[0]).toBe("callback:sess-sync");
|
|
1224
|
+
expect(order[1]).toBe("event:status");
|
|
1225
|
+
expect(order[2]).toBe("event:assistant_message");
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// ─── parseEvents batching tests ─────────────────────────────────────────────
|
|
1230
|
+
|
|
1231
|
+
function controllableStream(): {
|
|
1232
|
+
stream: ReadableStream<Uint8Array>;
|
|
1233
|
+
enqueue: (data: string) => void;
|
|
1234
|
+
close: () => void;
|
|
1235
|
+
} {
|
|
1236
|
+
let ctrl!: ReadableStreamDefaultController<Uint8Array>;
|
|
1237
|
+
const enc = new TextEncoder();
|
|
1238
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1239
|
+
start(c) {
|
|
1240
|
+
ctrl = c;
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
return {
|
|
1244
|
+
stream,
|
|
1245
|
+
enqueue: (data: string) => ctrl.enqueue(enc.encode(data)),
|
|
1246
|
+
close: () => ctrl.close(),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function collectWithOpts(
|
|
1251
|
+
stream: ReadableStream<Uint8Array>,
|
|
1252
|
+
opts: { flushIntervalMs?: number; flushSizeBytes?: number },
|
|
1253
|
+
): Promise<AgentEvent[]> {
|
|
1254
|
+
const rt = new ClaudeRuntime();
|
|
1255
|
+
const events: AgentEvent[] = [];
|
|
1256
|
+
for await (const ev of rt.parseEvents(stream, opts)) {
|
|
1257
|
+
events.push(ev);
|
|
1258
|
+
}
|
|
1259
|
+
return events;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
describe("ClaudeRuntime.parseEvents batching", () => {
|
|
1263
|
+
function assistantText(text: string, model?: string, usage?: Record<string, number>): string {
|
|
1264
|
+
const message: Record<string, unknown> = { content: [{ type: "text", text }] };
|
|
1265
|
+
if (model !== undefined) message.model = model;
|
|
1266
|
+
if (usage !== undefined) message.usage = usage;
|
|
1267
|
+
return JSON.stringify({ type: "assistant", message });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function assistantMixed(blocks: unknown[]): string {
|
|
1271
|
+
return JSON.stringify({ type: "assistant", message: { content: blocks } });
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function systemLine(sessionId: string): string {
|
|
1275
|
+
return JSON.stringify({ type: "system", subtype: "init", session_id: sessionId });
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function resultLine(sessionId: string): string {
|
|
1279
|
+
return JSON.stringify({
|
|
1280
|
+
type: "result",
|
|
1281
|
+
session_id: sessionId,
|
|
1282
|
+
result: "done",
|
|
1283
|
+
is_error: false,
|
|
1284
|
+
duration_ms: 0,
|
|
1285
|
+
num_turns: 1,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
test("1: multiple text fragments within window batch into one event", async () => {
|
|
1290
|
+
const fragments = ["hello", " ", "world", "!", " bye"];
|
|
1291
|
+
const lines = `${fragments.map((t) => assistantText(t)).join("\n")}\n`;
|
|
1292
|
+
const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
|
|
1293
|
+
expect(events).toHaveLength(1);
|
|
1294
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1295
|
+
expect(events[0]?.text).toBe("hello world! bye");
|
|
1296
|
+
expect(typeof events[0]?.timestamp).toBe("string");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("2: timer flush: first batch emitted after flushIntervalMs when stream is idle", async () => {
|
|
1300
|
+
const { stream, enqueue, close } = controllableStream();
|
|
1301
|
+
const collectPromise = collectWithOpts(stream, { flushIntervalMs: 50 });
|
|
1302
|
+
enqueue(`${assistantText("first")}\n`);
|
|
1303
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
1304
|
+
enqueue(`${assistantText("second")}\n`);
|
|
1305
|
+
close();
|
|
1306
|
+
const events = await collectPromise;
|
|
1307
|
+
expect(events).toHaveLength(2);
|
|
1308
|
+
expect(events[0]?.text).toBe("first");
|
|
1309
|
+
expect(events[1]?.text).toBe("second");
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("3: tool_use mid-stream flushes pending text first", async () => {
|
|
1313
|
+
const line = assistantMixed([
|
|
1314
|
+
{ type: "text", text: "before tool" },
|
|
1315
|
+
{ type: "tool_use", id: "c1", name: "Read", input: {} },
|
|
1316
|
+
]);
|
|
1317
|
+
const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
|
|
1318
|
+
expect(events).toHaveLength(2);
|
|
1319
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1320
|
+
expect(events[0]?.text).toBe("before tool");
|
|
1321
|
+
expect(events[1]?.type).toBe("tool_use");
|
|
1322
|
+
expect(events[1]?.name).toBe("Read");
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("4: multi-block [text, tool_use, text] preserves in-order delivery", async () => {
|
|
1326
|
+
const line = assistantMixed([
|
|
1327
|
+
{ type: "text", text: "first" },
|
|
1328
|
+
{ type: "tool_use", id: "c1", name: "Bash", input: {} },
|
|
1329
|
+
{ type: "text", text: "second" },
|
|
1330
|
+
]);
|
|
1331
|
+
const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
|
|
1332
|
+
expect(events).toHaveLength(3);
|
|
1333
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1334
|
+
expect(events[0]?.text).toBe("first");
|
|
1335
|
+
expect(events[1]?.type).toBe("tool_use");
|
|
1336
|
+
expect(events[1]?.name).toBe("Bash");
|
|
1337
|
+
expect(events[2]?.type).toBe("assistant_message");
|
|
1338
|
+
expect(events[2]?.text).toBe("second");
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test("5: stream-end flushes pending text when no other flush trigger fires", async () => {
|
|
1342
|
+
const line = assistantText("only text");
|
|
1343
|
+
const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
|
|
1344
|
+
expect(events).toHaveLength(1);
|
|
1345
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1346
|
+
expect(events[0]?.text).toBe("only text");
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
test("6: size cap flush: fragments summing beyond cap produce multiple batched events", async () => {
|
|
1350
|
+
const textA = "a".repeat(60);
|
|
1351
|
+
const textB = "b".repeat(60);
|
|
1352
|
+
const lines = `${assistantText(textA)}\n${assistantText(textB)}\n`;
|
|
1353
|
+
const events = await collectWithOpts(toStream(lines), {
|
|
1354
|
+
flushIntervalMs: 500,
|
|
1355
|
+
flushSizeBytes: 100,
|
|
1356
|
+
});
|
|
1357
|
+
expect(events.length).toBeGreaterThanOrEqual(2);
|
|
1358
|
+
const allText = events.map((e) => e.text as string).join("");
|
|
1359
|
+
expect(allText).toBe(textA + textB);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
test("7: single fragment exceeding size cap is emitted as its own batch", async () => {
|
|
1363
|
+
const bigText = "x".repeat(200); // 200 bytes > cap of 100
|
|
1364
|
+
const line = assistantText(bigText);
|
|
1365
|
+
const events = await collectWithOpts(toStream(`${line}\n`), {
|
|
1366
|
+
flushIntervalMs: 500,
|
|
1367
|
+
flushSizeBytes: 100,
|
|
1368
|
+
});
|
|
1369
|
+
expect(events).toHaveLength(1);
|
|
1370
|
+
expect(events[0]?.text).toBe(bigText);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test("8: non-text events between text batches reset the batch", async () => {
|
|
1374
|
+
const lines = `${[
|
|
1375
|
+
assistantText("alpha"),
|
|
1376
|
+
systemLine("s1"),
|
|
1377
|
+
assistantText("beta"),
|
|
1378
|
+
resultLine("s1"),
|
|
1379
|
+
].join("\n")}\n`;
|
|
1380
|
+
const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
|
|
1381
|
+
expect(events).toHaveLength(4);
|
|
1382
|
+
expect(events[0]?.type).toBe("assistant_message");
|
|
1383
|
+
expect(events[0]?.text).toBe("alpha");
|
|
1384
|
+
expect(events[1]?.type).toBe("status");
|
|
1385
|
+
expect(events[2]?.type).toBe("assistant_message");
|
|
1386
|
+
expect(events[2]?.text).toBe("beta");
|
|
1387
|
+
expect(events[3]?.type).toBe("result");
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test("9: batched event model/usage use the latest contributing message (latest wins)", async () => {
|
|
1391
|
+
const msg1 = assistantText("hello ", "model-A", { input_tokens: 10, output_tokens: 5 });
|
|
1392
|
+
const msg2 = assistantText("world", "model-B", { input_tokens: 20, output_tokens: 10 });
|
|
1393
|
+
const lines = `${msg1}\n${msg2}\n`;
|
|
1394
|
+
const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
|
|
1395
|
+
expect(events).toHaveLength(1);
|
|
1396
|
+
expect(events[0]?.model).toBe("model-B");
|
|
1397
|
+
expect((events[0]?.usage as Record<string, number>)?.input_tokens).toBe(20);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
test("10: model/usage are omitted on batched event when no contributing message provided them", async () => {
|
|
1401
|
+
const msg = assistantText("no model here");
|
|
1402
|
+
const events = await collectWithOpts(toStream(`${msg}\n`), { flushIntervalMs: 500 });
|
|
1403
|
+
expect(events).toHaveLength(1);
|
|
1404
|
+
expect(Object.hasOwn(events[0] ?? {}, "model")).toBe(false);
|
|
1405
|
+
expect(Object.hasOwn(events[0] ?? {}, "usage")).toBe(false);
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// ─── parseEvents + EventStore integration test ───────────────────────────────
|
|
1410
|
+
|
|
1411
|
+
describe("ClaudeRuntime integration: parseEvents + EventStore", () => {
|
|
1412
|
+
let tempDir: string;
|
|
1413
|
+
|
|
1414
|
+
beforeEach(async () => {
|
|
1415
|
+
tempDir = await mkdtemp(join(tmpdir(), "claude-parse-events-int-"));
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
afterEach(async () => {
|
|
1419
|
+
await cleanupTempDir(tempDir);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test("fixture events land in EventStore and round-trip correctly", async () => {
|
|
1423
|
+
const fixturePath = join(import.meta.dir, "__fixtures__", "claude-stream-fixture.ts");
|
|
1424
|
+
const proc = Bun.spawn(["bun", fixturePath], { stdout: "pipe" });
|
|
1425
|
+
|
|
1426
|
+
const runtime = new ClaudeRuntime();
|
|
1427
|
+
const collected: AgentEvent[] = [];
|
|
1428
|
+
for await (const ev of runtime.parseEvents(proc.stdout)) {
|
|
1429
|
+
collected.push(ev);
|
|
1430
|
+
}
|
|
1431
|
+
await proc.exited;
|
|
1432
|
+
|
|
1433
|
+
// Fixture emits: system init, assistant text, result → 3 events
|
|
1434
|
+
expect(collected).toHaveLength(3);
|
|
1435
|
+
expect(collected[0]?.type).toBe("status");
|
|
1436
|
+
expect(collected[0]?.sessionId).toBe("sess-123");
|
|
1437
|
+
expect(collected[1]?.type).toBe("assistant_message");
|
|
1438
|
+
expect(collected[1]?.text).toBe("hello");
|
|
1439
|
+
expect(collected[2]?.type).toBe("result");
|
|
1440
|
+
expect(collected[2]?.result).toBe("done");
|
|
1441
|
+
|
|
1442
|
+
// Insert each event into a fresh EventStore
|
|
1443
|
+
const dbPath = join(tempDir, "events.db");
|
|
1444
|
+
const store = createEventStore(dbPath);
|
|
1445
|
+
const agentName = "fixture-agent";
|
|
1446
|
+
|
|
1447
|
+
for (const ev of collected) {
|
|
1448
|
+
store.insert({
|
|
1449
|
+
runId: null,
|
|
1450
|
+
agentName,
|
|
1451
|
+
sessionId: typeof ev.sessionId === "string" ? ev.sessionId : null,
|
|
1452
|
+
eventType: "custom",
|
|
1453
|
+
toolName: typeof ev.name === "string" ? ev.name : null,
|
|
1454
|
+
toolArgs: null,
|
|
1455
|
+
toolDurationMs: null,
|
|
1456
|
+
level: "info",
|
|
1457
|
+
data: JSON.stringify(ev),
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Query and verify count, order, and data round-trip
|
|
1462
|
+
const stored = store.getByAgent(agentName);
|
|
1463
|
+
expect(stored).toHaveLength(3);
|
|
1464
|
+
|
|
1465
|
+
for (let i = 0; i < stored.length; i++) {
|
|
1466
|
+
const row = stored[i];
|
|
1467
|
+
const original = collected[i];
|
|
1468
|
+
if (!row || !original) continue;
|
|
1469
|
+
expect(row.data).not.toBeNull();
|
|
1470
|
+
const parsed = JSON.parse(row.data as string) as AgentEvent;
|
|
1471
|
+
expect(parsed.type).toBe(original.type);
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
});
|