@vellumai/assistant 0.8.8 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/bun.lock +2 -2
- package/examples/plugins/echo/README.md +61 -60
- package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
- package/examples/plugins/echo/hooks/stop.ts +16 -0
- package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
- package/examples/plugins/echo/package.json +1 -2
- package/examples/plugins/echo/src/emit.ts +19 -0
- package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
- package/openapi.yaml +235 -6
- package/package.json +2 -2
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
- package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
- package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
- package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
- package/src/__tests__/agent-loop-thinking.test.ts +36 -20
- package/src/__tests__/agent-loop.test.ts +441 -96
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
- package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
- package/src/__tests__/anthropic-provider.test.ts +1 -1
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-control-flow.test.ts +1 -1
- package/src/__tests__/app-dir-path-guard.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/approval-cascade.test.ts +5 -4
- package/src/__tests__/approval-routes-http.test.ts +4 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/channel-approval-routes.test.ts +1 -1
- package/src/__tests__/channel-approvals.test.ts +1 -1
- package/src/__tests__/compaction-circuit.test.ts +258 -0
- package/src/__tests__/compaction-direct.test.ts +132 -0
- package/src/__tests__/compaction-events.test.ts +5 -5
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +6 -4
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +142 -218
- package/src/__tests__/conversation-agent-loop.test.ts +297 -586
- package/src/__tests__/conversation-clean-command.test.ts +5 -2
- package/src/__tests__/conversation-confirmation-signals.test.ts +5 -4
- package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -4
- package/src/__tests__/conversation-process-callsite.test.ts +14 -14
- package/src/__tests__/conversation-provider-retry-repair.test.ts +70 -65
- package/src/__tests__/conversation-queue.test.ts +9 -9
- package/src/__tests__/conversation-runtime-assembly.test.ts +923 -231
- package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
- package/src/__tests__/conversation-slash-queue.test.ts +6 -4
- package/src/__tests__/conversation-slash-unknown.test.ts +6 -4
- package/src/__tests__/conversation-speed-override.test.ts +10 -9
- package/src/__tests__/conversation-starter-routes.test.ts +14 -6
- package/src/__tests__/conversation-workspace-cache-state.test.ts +23 -20
- package/src/__tests__/conversation-workspace-injection.test.ts +68 -6
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +14 -11
- package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
- package/src/__tests__/credential-security-invariants.test.ts +0 -1
- package/src/__tests__/db-acp-history.test.ts +101 -0
- package/src/__tests__/dynamic-page-surface.test.ts +31 -0
- package/src/__tests__/empty-response-hook.test.ts +1 -1
- package/src/__tests__/file-write-tool.test.ts +63 -0
- package/src/__tests__/gateway-only-guard.test.ts +12 -2
- package/src/__tests__/guardian-grant-minting.test.ts +1 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +2 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +1 -0
- package/src/__tests__/history-repair-hook.test.ts +1 -1
- package/src/__tests__/host-app-control-routes.test.ts +1 -1
- package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
- package/src/__tests__/inference-profile-reaper.test.ts +62 -0
- package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
- package/src/__tests__/injector-background-turn.test.ts +13 -23
- package/src/__tests__/injector-chain.test.ts +268 -44
- package/src/__tests__/injector-disk-pressure.test.ts +210 -52
- package/src/__tests__/injector-document-comments.test.ts +97 -114
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
- package/src/__tests__/injector-v3-suppression.test.ts +4 -4
- package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
- package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +131 -26
- package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
- package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
- package/src/__tests__/plugin-api-shim.test.ts +6 -9
- package/src/__tests__/plugin-bootstrap.test.ts +12 -23
- package/src/__tests__/plugin-registry.test.ts +3 -49
- package/src/__tests__/plugin-types.test.ts +0 -70
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/__tests__/reaction-persistence.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +4 -1
- package/src/__tests__/skill-feature-flags-integration.test.ts +33 -0
- package/src/__tests__/steer-tool-repair.test.ts +1 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
- package/src/__tests__/subagent-detail.test.ts +25 -7
- package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
- package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
- package/src/__tests__/subagent-manager-notify.test.ts +1 -3
- package/src/__tests__/subagent-notify-parent.test.ts +1 -3
- package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
- package/src/__tests__/title-generate-hook.test.ts +1 -1
- package/src/__tests__/tool-error-hook.test.ts +1 -1
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
- package/src/__tests__/user-plugin-loader.test.ts +54 -286
- package/src/acp/__tests__/agent-process.test.ts +161 -0
- package/src/acp/__tests__/client-handler.test.ts +40 -0
- package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
- package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
- package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
- package/src/acp/agent-process.ts +61 -1
- package/src/acp/auto-install.test.ts +280 -0
- package/src/acp/auto-install.ts +232 -0
- package/src/acp/client-handler.ts +31 -0
- package/src/acp/feature-gate.test.ts +48 -0
- package/src/acp/feature-gate.ts +34 -0
- package/src/acp/prepare-agent-env.ts +80 -27
- package/src/acp/resolve-agent.test.ts +225 -9
- package/src/acp/resolve-agent.ts +122 -17
- package/src/acp/resume-hint.ts +23 -0
- package/src/acp/session-manager.ts +507 -73
- package/src/agent/compaction-circuit.ts +60 -102
- package/src/agent/loop.ts +414 -248
- package/src/api/responses/conversation-message.ts +14 -1
- package/src/approvals/guardian-request-resolvers.ts +1 -1
- package/src/background-wake/next-wake.ts +1 -0
- package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
- package/src/cli/commands/plugins.ts +43 -37
- package/src/cli/lib/__tests__/install-from-github.test.ts +429 -111
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +372 -0
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +220 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +226 -32
- package/src/cli/lib/install-from-github.ts +464 -55
- package/src/cli/lib/plugin-catalog-cache.ts +84 -0
- package/src/cli/lib/plugin-details.ts +409 -0
- package/src/cli/lib/plugin-marketplace.ts +197 -0
- package/src/cli/lib/search-plugins.ts +195 -29
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
- package/src/config/acp-defaults.test.ts +10 -0
- package/src/config/acp-defaults.ts +6 -0
- package/src/config/bundled-skills/acp/SKILL.md +85 -33
- package/src/config/bundled-skills/acp/TOOLS.json +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
- package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
- package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
- package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
- package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
- package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
- package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +15 -6
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/heartbeat.ts +9 -0
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/context/strip-injections.ts +8 -2
- package/src/context/window-manager.ts +27 -13
- package/src/daemon/conversation-agent-loop-handlers.ts +10 -35
- package/src/daemon/conversation-agent-loop.ts +175 -1055
- package/src/daemon/conversation-lifecycle.ts +11 -255
- package/src/daemon/conversation-process.ts +8 -136
- package/src/daemon/conversation-registry.ts +159 -0
- package/src/daemon/conversation-runtime-assembly.ts +293 -392
- package/src/daemon/conversation-store.ts +9 -90
- package/src/daemon/conversation-surfaces.ts +24 -8
- package/src/daemon/conversation-workspace.ts +17 -0
- package/src/daemon/conversation.ts +404 -57
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/external-plugins-bootstrap.ts +14 -19
- package/src/daemon/handlers/conversations.ts +3 -1
- package/src/daemon/handlers/skills.ts +4 -1
- package/src/daemon/host-proxy-preactivation.ts +1 -3
- package/src/daemon/lifecycle.ts +21 -7
- package/src/daemon/server.ts +2 -0
- package/src/daemon/wake-conversation-ops.ts +269 -0
- package/src/embedded/plugin-api.ts +2 -2
- package/src/export/__tests__/transcript-formatter.test.ts +5 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
- package/src/heartbeat/heartbeat-run-store.ts +23 -1
- package/src/heartbeat/heartbeat-service.ts +26 -0
- package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
- package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
- package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
- package/src/ipc/skill-routes/memory.ts +4 -2
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
- package/src/memory/conversation-crud.ts +29 -19
- package/src/memory/conversation-starter-checkpoints.ts +1 -0
- package/src/memory/db-init.ts +2 -0
- package/src/memory/job-handlers/conversation-starters.ts +13 -2
- package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
- package/src/memory/jobs-worker.ts +25 -1
- package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema/acp.ts +4 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
- package/src/memory/v2/consolidation-job.ts +13 -4
- package/src/plugin-api/constants.ts +4 -0
- package/src/plugin-api/index.ts +6 -5
- package/src/plugin-api/types.ts +75 -0
- package/src/plugins/defaults/compaction/compact.ts +59 -0
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +57 -0
- package/src/plugins/defaults/compaction/package.json +1 -2
- package/src/plugins/defaults/empty-response/package.json +0 -1
- package/src/plugins/defaults/history-repair/package.json +0 -1
- package/src/plugins/defaults/index.ts +135 -26
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +184 -74
- package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
- package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
- package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/assign.test.ts +4 -4
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +9 -6
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/maintain-job.test.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/orchestrate.test.ts +8 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/reconcile.test.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/render-injection.test.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/router.test.ts +10 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +8 -8
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selector.test.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/shadow-plugin.test.ts +16 -17
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/assign.ts +9 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/capabilities.ts +5 -2
- package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
- package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +75 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/maintain-job.ts +15 -8
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/orchestrate.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/page-content.ts +2 -2
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/provider-blocks.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/reconcile.ts +7 -3
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/router.ts +5 -5
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selection-log-store.ts +4 -4
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selector.ts +7 -7
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/shadow-plugin.ts +32 -94
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/tree.ts +1 -1
- package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/types.ts +1 -1
- package/src/plugins/defaults/title-generate/package.json +0 -1
- package/src/plugins/defaults/tool-error/package.json +0 -1
- package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
- package/src/plugins/pipeline.ts +6 -293
- package/src/plugins/registry.ts +9 -37
- package/src/plugins/types.ts +76 -381
- package/src/plugins/user-loader.ts +30 -127
- package/src/prompts/__tests__/system-prompt.test.ts +6 -0
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +35 -3
- package/src/runtime/__tests__/agent-wake.test.ts +555 -691
- package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
- package/src/runtime/agent-wake.ts +108 -209
- package/src/runtime/assistant-event-hub.ts +1 -1
- package/src/runtime/channel-approvals.ts +1 -1
- package/src/runtime/interactive-ui.ts +1 -1
- package/src/runtime/routes/__tests__/acp-routes.test.ts +315 -55
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
- package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
- package/src/runtime/routes/acp-routes.test.ts +89 -25
- package/src/runtime/routes/acp-routes.ts +81 -29
- package/src/runtime/routes/approval-routes.ts +1 -1
- package/src/runtime/routes/browser-routes.ts +1 -1
- package/src/runtime/routes/browser-tabs-routes.ts +6 -10
- package/src/runtime/routes/conversation-cli-routes.ts +1 -1
- package/src/runtime/routes/conversation-list-routes.ts +1 -1
- package/src/runtime/routes/conversation-query-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +28 -2
- package/src/runtime/routes/conversation-starter-routes.ts +13 -7
- package/src/runtime/routes/conversations-import-routes.ts +24 -7
- package/src/runtime/routes/host-app-control-routes.ts +1 -1
- package/src/runtime/routes/host-cu-routes.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +18 -3
- package/src/runtime/routes/inbound-message-handler.ts +1 -1
- package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
- package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
- package/src/runtime/routes/memory-v3-routes.ts +16 -6
- package/src/runtime/routes/playground/helpers.ts +1 -1
- package/src/runtime/routes/plugins-routes.ts +337 -35
- package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
- package/src/runtime/routes/work-items-routes.ts +2 -4
- package/src/runtime/services/conversation-serializer.ts +1 -1
- package/src/signals/cancel.ts +2 -4
- package/src/subagent/manager.ts +21 -5
- package/src/tools/acp/context.ts +20 -0
- package/src/tools/acp/list-agents.test.ts +8 -2
- package/src/tools/acp/spawn.test.ts +176 -195
- package/src/tools/acp/spawn.ts +37 -172
- package/src/tools/acp/steer.test.ts +105 -8
- package/src/tools/acp/steer.ts +48 -17
- package/src/tools/apps/executors.ts +166 -50
- package/src/tools/filesystem/write.ts +34 -0
- package/src/tools/subagent/spawn.ts +2 -4
- package/src/tools/ui-surface/definitions.ts +25 -5
- package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
- package/docs/plugins.md +0 -832
- package/examples/plugins/echo/register.ts +0 -143
- package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
- package/src/__tests__/compaction-pipeline.test.ts +0 -210
- package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
- package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
- package/src/__tests__/pipeline-runner.test.ts +0 -554
- package/src/__tests__/plugin-external-api.test.ts +0 -68
- package/src/daemon/wake-target-adapter.ts +0 -253
- package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
- package/src/plugins/defaults/circuit-breaker/package.json +0 -15
- package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
- package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
- package/src/plugins/defaults/compaction/register.ts +0 -35
- package/src/plugins/defaults/compaction/terminal.ts +0 -73
- package/src/plugins/defaults/empty-response/register.ts +0 -23
- package/src/plugins/defaults/history-repair/register.ts +0 -24
- package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
- package/src/plugins/defaults/overflow-reduce/package.json +0 -15
- package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
- package/src/plugins/defaults/title-generate/register.ts +0 -35
- package/src/plugins/defaults/tool-error/register.ts +0 -23
- package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
- package/src/plugins/external-api.ts +0 -104
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/capabilities.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/core.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/health.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/needle.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/provider-blocks.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/snapshot.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/tree.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/core.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/health.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/needle.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/snapshot.ts +0 -0
- /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +0 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AcpSessionManager.resumeFromHistory: reattaching a terminal
|
|
3
|
+
* persisted session via ACP session/resume (preferred, no replay) or
|
|
4
|
+
* session/load (replayed history suppressed), plus the terminal upsert
|
|
5
|
+
* that lets a resumed run update its original history row.
|
|
6
|
+
*
|
|
7
|
+
* The agent process is replaced with a fake whose capabilities and replay
|
|
8
|
+
* behavior are scripted per test; the client handler is the real
|
|
9
|
+
* VellumAcpClientHandler so replay suppression is exercised end to end.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
mock.module("../../util/logger.js", () => ({
|
|
16
|
+
getLogger: () =>
|
|
17
|
+
new Proxy({} as Record<string, unknown>, {
|
|
18
|
+
get: () => () => {},
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Fake AcpAgentProcess with scriptable capabilities and history replay.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
interface FakeClient {
|
|
27
|
+
sessionUpdate(params: unknown): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fakeCaps = { loadSession: false, resume: false };
|
|
31
|
+
/** Chunks the fake replays through the client handler during loadSession. */
|
|
32
|
+
let replayChunks: string[] = [];
|
|
33
|
+
/** When set, prompt() throws synchronously (steerOrResume teardown tests). */
|
|
34
|
+
let promptThrowsSync = false;
|
|
35
|
+
/**
|
|
36
|
+
* When set, resumeSession() stalls on this gate. Lets tests observe the
|
|
37
|
+
* post-registration "initializing" window where the SessionEntry exists but
|
|
38
|
+
* the resume has not yet settled.
|
|
39
|
+
*/
|
|
40
|
+
let resumeSessionGate: Promise<void> | null = null;
|
|
41
|
+
const fakeInstances: FakeAcpAgentProcess[] = [];
|
|
42
|
+
|
|
43
|
+
class FakeAcpAgentProcess {
|
|
44
|
+
killed = false;
|
|
45
|
+
spawnedCwd: string | null = null;
|
|
46
|
+
loadSessionCalls: Array<{ sessionId: string; cwd: string }> = [];
|
|
47
|
+
resumeSessionCalls: Array<{ sessionId: string; cwd: string }> = [];
|
|
48
|
+
promptCalls: Array<{ sessionId: string; text: string }> = [];
|
|
49
|
+
resolvePrompt: ((v: { stopReason: string }) => void) | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
public readonly agentId: string,
|
|
53
|
+
public readonly config: {
|
|
54
|
+
command: string;
|
|
55
|
+
args: string[];
|
|
56
|
+
env?: Record<string, string | undefined>;
|
|
57
|
+
},
|
|
58
|
+
private readonly clientFactory: (agent: unknown) => FakeClient,
|
|
59
|
+
) {
|
|
60
|
+
fakeInstances.push(this);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
spawn(cwd: string): void {
|
|
64
|
+
this.spawnedCwd = cwd;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async initialize(): Promise<void> {}
|
|
68
|
+
|
|
69
|
+
get supportsLoadSession(): boolean {
|
|
70
|
+
return fakeCaps.loadSession;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get supportsSessionResume(): boolean {
|
|
74
|
+
return fakeCaps.resume;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async createSession(_cwd: string): Promise<string> {
|
|
78
|
+
return "proto-new";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async loadSession(sessionId: string, cwd: string): Promise<void> {
|
|
82
|
+
this.loadSessionCalls.push({ sessionId, cwd });
|
|
83
|
+
// Replay history through the client handler before resolving, exactly
|
|
84
|
+
// as a real agent does per the ACP spec for session/load.
|
|
85
|
+
for (const text of replayChunks) {
|
|
86
|
+
await this.emitChunk(text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async resumeSession(sessionId: string, cwd: string): Promise<void> {
|
|
91
|
+
if (resumeSessionGate) await resumeSessionGate;
|
|
92
|
+
this.resumeSessionCalls.push({ sessionId, cwd });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drives an agent_message_chunk through the real client handler. */
|
|
96
|
+
async emitChunk(text: string): Promise<void> {
|
|
97
|
+
await this.clientFactory(this).sessionUpdate({
|
|
98
|
+
sessionId: "proto-old",
|
|
99
|
+
update: {
|
|
100
|
+
sessionUpdate: "agent_message_chunk",
|
|
101
|
+
content: { type: "text", text },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
prompt(sessionId: string, text: string): Promise<{ stopReason: string }> {
|
|
107
|
+
if (promptThrowsSync) {
|
|
108
|
+
throw new Error("prompt transport dead");
|
|
109
|
+
}
|
|
110
|
+
this.promptCalls.push({ sessionId, text });
|
|
111
|
+
return new Promise((res) => {
|
|
112
|
+
this.resolvePrompt = res;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async cancel(): Promise<void> {}
|
|
117
|
+
|
|
118
|
+
kill(): void {
|
|
119
|
+
this.killed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
mock.module("../agent-process.js", () => ({
|
|
124
|
+
AcpAgentProcess: FakeAcpAgentProcess,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
// Env-prep stub: credential-broker plumbing has its own suite. Tests that
|
|
128
|
+
// need to observe the manager mid-resume (dispose, pending-id visibility)
|
|
129
|
+
// set `prepareAgentEnvGate` to stall the resume here. Each resolved command
|
|
130
|
+
// it is called with is recorded so resume tests can assert it ran AFTER
|
|
131
|
+
// resolution (the real helper is the sole token-injection point), and it
|
|
132
|
+
// mirrors that contract by injecting CLAUDE_CODE_OAUTH_TOKEN into the
|
|
133
|
+
// returned config — at spawn time only, never during the auto-install phase.
|
|
134
|
+
let prepareAgentEnvGate: Promise<void> | null = null;
|
|
135
|
+
let prepareAgentEnvCommands: string[] = [];
|
|
136
|
+
mock.module("../prepare-agent-env.js", () => ({
|
|
137
|
+
prepareAgentEnv: async (agentConfig: {
|
|
138
|
+
command: string;
|
|
139
|
+
args: string[];
|
|
140
|
+
env?: Record<string, string | undefined>;
|
|
141
|
+
}) => {
|
|
142
|
+
if (prepareAgentEnvGate) await prepareAgentEnvGate;
|
|
143
|
+
prepareAgentEnvCommands.push(agentConfig.command);
|
|
144
|
+
return {
|
|
145
|
+
...agentConfig,
|
|
146
|
+
env: {
|
|
147
|
+
...agentConfig.env,
|
|
148
|
+
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
// Resolver stub: defaults to resolving every id to the claude adapter.
|
|
155
|
+
type ResolveResult =
|
|
156
|
+
| {
|
|
157
|
+
ok: true;
|
|
158
|
+
agent: { command: string; args: string[] };
|
|
159
|
+
}
|
|
160
|
+
| { ok: false; reason: "binary_not_found"; hint: string; command: string };
|
|
161
|
+
let resolveImpl: (id: string) => ResolveResult = () => ({
|
|
162
|
+
ok: true,
|
|
163
|
+
agent: { command: "claude-agent-acp", args: [] },
|
|
164
|
+
});
|
|
165
|
+
const realResolveModule = await import("../resolve-agent.js");
|
|
166
|
+
mock.module("../resolve-agent.js", () => ({
|
|
167
|
+
...realResolveModule,
|
|
168
|
+
resolveAcpAgent: (id: string) => resolveImpl(id),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
// Auto-install stubs: resume now resolves through resolveAgentWithAutoInstall
|
|
172
|
+
// (the same sandboxed `bun` path as spawn), so a binary_not_found resolution
|
|
173
|
+
// reaches `ensureAdapterInstalled`, which probes `bun` via Bun.which and
|
|
174
|
+
// shells out via execFile. Stub both — process-global, installed BEFORE the
|
|
175
|
+
// session-manager (and thus auto-install) module is imported below — so the
|
|
176
|
+
// install is never a real one. Default: bun absent (no install attempted).
|
|
177
|
+
import { installExecFileStub } from "./helpers/exec-file-stub.js";
|
|
178
|
+
import { installWhichStub } from "./helpers/which-stub.js";
|
|
179
|
+
|
|
180
|
+
const { execScripts, execFileMock, reset: resetExecStub } =
|
|
181
|
+
installExecFileStub();
|
|
182
|
+
const which = installWhichStub();
|
|
183
|
+
/** Fixed resolved `bun` path so install script keys are predictable. */
|
|
184
|
+
const BUN_BIN = "/usr/local/bin/bun";
|
|
185
|
+
/** Key the exec stub uses for the global install. */
|
|
186
|
+
const BUN_ADD_KEY = `${BUN_BIN} add`;
|
|
187
|
+
|
|
188
|
+
import type { ServerMessage } from "../../daemon/message-protocol.js";
|
|
189
|
+
import type { AcpSessionUpdate } from "../../daemon/message-types/acp.js";
|
|
190
|
+
import { getSqlite } from "../../memory/db-connection.js";
|
|
191
|
+
import { initializeDb } from "../../memory/db-init.js";
|
|
192
|
+
import type { AcpSessionState } from "../types.js";
|
|
193
|
+
import {
|
|
194
|
+
clearHistory,
|
|
195
|
+
insertHistoryRow,
|
|
196
|
+
readHistoryRow,
|
|
197
|
+
} from "./helpers/acp-history-db.js";
|
|
198
|
+
|
|
199
|
+
const { AcpResumeError, AcpSessionManager, AcpSessionNotFoundError } =
|
|
200
|
+
await import("../session-manager.js");
|
|
201
|
+
// Imported dynamically (after the exec/which stubs above) so auto-install.js
|
|
202
|
+
// binds to the mocked node:child_process, exactly like session-manager.js.
|
|
203
|
+
const { _resetAdapterInstallCacheForTests } = await import("../auto-install.js");
|
|
204
|
+
|
|
205
|
+
initializeDb();
|
|
206
|
+
|
|
207
|
+
function countHistoryRows(): number {
|
|
208
|
+
const row = getSqlite()
|
|
209
|
+
.query("SELECT COUNT(*) AS n FROM acp_session_history")
|
|
210
|
+
.get() as { n: number };
|
|
211
|
+
return row.n;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Manager internals accessors (mirrors session-manager-persistence.test.ts)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
type ManagerInternals = {
|
|
219
|
+
sessions: Map<string, { clientHandler: FakeClient; command: string }>;
|
|
220
|
+
eventBuffers: Map<string, Array<{ update: AcpSessionUpdate }>>;
|
|
221
|
+
pendingResumes: Map<string, Promise<void>>;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function internals(
|
|
225
|
+
manager: InstanceType<typeof AcpSessionManager>,
|
|
226
|
+
): ManagerInternals {
|
|
227
|
+
return manager as unknown as ManagerInternals;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
afterAll(() => {
|
|
231
|
+
which.restore();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
clearHistory();
|
|
236
|
+
fakeInstances.length = 0;
|
|
237
|
+
fakeCaps.loadSession = false;
|
|
238
|
+
fakeCaps.resume = false;
|
|
239
|
+
replayChunks = [];
|
|
240
|
+
promptThrowsSync = false;
|
|
241
|
+
prepareAgentEnvGate = null;
|
|
242
|
+
prepareAgentEnvCommands = [];
|
|
243
|
+
resumeSessionGate = null;
|
|
244
|
+
resolveImpl = () => ({
|
|
245
|
+
ok: true,
|
|
246
|
+
agent: { command: "claude-agent-acp", args: [] },
|
|
247
|
+
});
|
|
248
|
+
// Default: bun absent, no install scripts, install cache cleared. Tests
|
|
249
|
+
// that exercise the auto-install path opt in via which/execScripts.
|
|
250
|
+
resetExecStub();
|
|
251
|
+
_resetAdapterInstallCacheForTests();
|
|
252
|
+
which.setWhich({});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const PERSISTED_EVENT: AcpSessionUpdate = {
|
|
256
|
+
type: "acp_session_update",
|
|
257
|
+
acpSessionId: "resume-1",
|
|
258
|
+
updateType: "agent_message_chunk",
|
|
259
|
+
content: "original-run-chunk",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
describe("AcpSessionManager.resumeFromHistory", () => {
|
|
263
|
+
test("resumes via session/load with replay suppressed, then steers the loaded session", async () => {
|
|
264
|
+
fakeCaps.loadSession = true;
|
|
265
|
+
replayChunks = ["replayed-1", "replayed-2"];
|
|
266
|
+
insertHistoryRow({
|
|
267
|
+
id: "resume-1",
|
|
268
|
+
eventLogJson: JSON.stringify([PERSISTED_EVENT]),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const manager = new AcpSessionManager(4);
|
|
272
|
+
const sent: ServerMessage[] = [];
|
|
273
|
+
await manager.resumeFromHistory("resume-1", (msg) => sent.push(msg));
|
|
274
|
+
|
|
275
|
+
const fake = fakeInstances[0]!;
|
|
276
|
+
expect(fake.spawnedCwd).toBe("/tmp/proj");
|
|
277
|
+
expect(fake.loadSessionCalls).toEqual([
|
|
278
|
+
{ sessionId: "proto-old", cwd: "/tmp/proj" },
|
|
279
|
+
]);
|
|
280
|
+
expect(fake.resumeSessionCalls).toEqual([]);
|
|
281
|
+
|
|
282
|
+
// Replayed history was never forwarded to the sender; only the
|
|
283
|
+
// spawned event went out.
|
|
284
|
+
expect(sent.filter((m) => m.type === "acp_session_update")).toEqual([]);
|
|
285
|
+
expect(sent).toEqual([
|
|
286
|
+
{
|
|
287
|
+
type: "acp_session_spawned",
|
|
288
|
+
acpSessionId: "resume-1",
|
|
289
|
+
agent: "claude",
|
|
290
|
+
parentConversationId: "conv-1",
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
// State reuses the row's identity and is running again.
|
|
295
|
+
const state = manager.getStatus("resume-1") as AcpSessionState;
|
|
296
|
+
expect(state).toMatchObject({
|
|
297
|
+
id: "resume-1",
|
|
298
|
+
agentId: "claude",
|
|
299
|
+
acpSessionId: "proto-old",
|
|
300
|
+
parentConversationId: "conv-1",
|
|
301
|
+
status: "running",
|
|
302
|
+
startedAt: 1234,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Ring buffer was re-seeded from the persisted log only; the replay
|
|
306
|
+
// never reached it.
|
|
307
|
+
const buffer = internals(manager).eventBuffers.get("resume-1")!;
|
|
308
|
+
expect(buffer.map((b) => b.update)).toEqual([PERSISTED_EVENT]);
|
|
309
|
+
|
|
310
|
+
// Updates after the load flow normally (suppression ended).
|
|
311
|
+
await fake.emitChunk("live-after-load");
|
|
312
|
+
expect(sent.filter((m) => m.type === "acp_session_update")).toHaveLength(1);
|
|
313
|
+
expect(internals(manager).eventBuffers.get("resume-1")).toHaveLength(2);
|
|
314
|
+
|
|
315
|
+
// The agent receives the new prompt in the loaded session.
|
|
316
|
+
await manager.steer("resume-1", "follow up please");
|
|
317
|
+
expect(fake.promptCalls).toEqual([
|
|
318
|
+
{ sessionId: "proto-old", text: "follow up please" },
|
|
319
|
+
]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("prefers session/resume when advertised and never calls loadSession", async () => {
|
|
323
|
+
fakeCaps.loadSession = true;
|
|
324
|
+
fakeCaps.resume = true;
|
|
325
|
+
insertHistoryRow({ id: "resume-2" });
|
|
326
|
+
|
|
327
|
+
const manager = new AcpSessionManager(4);
|
|
328
|
+
await manager.resumeFromHistory("resume-2", () => {});
|
|
329
|
+
|
|
330
|
+
const fake = fakeInstances[0]!;
|
|
331
|
+
expect(fake.resumeSessionCalls).toEqual([
|
|
332
|
+
{ sessionId: "proto-old", cwd: "/tmp/proj" },
|
|
333
|
+
]);
|
|
334
|
+
expect(fake.loadSessionCalls).toEqual([]);
|
|
335
|
+
const state = manager.getStatus("resume-2") as AcpSessionState;
|
|
336
|
+
expect(state.status).toBe("running");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("legacy row with null cwd raises an actionable error", async () => {
|
|
340
|
+
insertHistoryRow({ id: "legacy-1", cwd: null });
|
|
341
|
+
|
|
342
|
+
const manager = new AcpSessionManager(4);
|
|
343
|
+
await expect(
|
|
344
|
+
manager.resumeFromHistory("legacy-1", () => {}),
|
|
345
|
+
).rejects.toThrow(/recorded before resume support/);
|
|
346
|
+
expect(fakeInstances).toHaveLength(0);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("missing row raises the spawn-style not-found error", async () => {
|
|
350
|
+
const manager = new AcpSessionManager(4);
|
|
351
|
+
await expect(manager.resumeFromHistory("nope-1", () => {})).rejects.toThrow(
|
|
352
|
+
'ACP session "nope-1" not found',
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("row with an empty protocol session id raises an actionable error", async () => {
|
|
357
|
+
insertHistoryRow({ id: "no-proto-1", acpSessionId: "" });
|
|
358
|
+
|
|
359
|
+
const manager = new AcpSessionManager(4);
|
|
360
|
+
await expect(
|
|
361
|
+
manager.resumeFromHistory("no-proto-1", () => {}),
|
|
362
|
+
).rejects.toThrow(/no protocol session id/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("agent without either capability errors and kills the process", async () => {
|
|
366
|
+
insertHistoryRow({ id: "no-caps-1" });
|
|
367
|
+
|
|
368
|
+
const manager = new AcpSessionManager(4);
|
|
369
|
+
await expect(
|
|
370
|
+
manager.resumeFromHistory("no-caps-1", () => {}),
|
|
371
|
+
).rejects.toThrow('ACP agent "claude" does not support session resume');
|
|
372
|
+
|
|
373
|
+
const fake = fakeInstances[0]!;
|
|
374
|
+
expect(fake.killed).toBe(true);
|
|
375
|
+
expect(internals(manager).sessions.has("no-caps-1")).toBe(false);
|
|
376
|
+
expect(internals(manager).eventBuffers.has("no-caps-1")).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("re-resolves via resolveAgentWithAutoInstall: an already-installed real binary flows through resume", async () => {
|
|
380
|
+
// resumeFromHistory resolves through resolveAgentWithAutoInstall. When the
|
|
381
|
+
// adapter is already on PATH the resolver returns the real binary (a full
|
|
382
|
+
// path here) with no install, and the SessionEntry tracks its basename for
|
|
383
|
+
// resume-hint gating.
|
|
384
|
+
fakeCaps.resume = true;
|
|
385
|
+
insertHistoryRow({ id: "installed-resume-1" });
|
|
386
|
+
resolveImpl = () => ({
|
|
387
|
+
ok: true,
|
|
388
|
+
agent: {
|
|
389
|
+
command: "/usr/local/bin/claude-agent-acp",
|
|
390
|
+
args: [],
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const manager = new AcpSessionManager(4);
|
|
395
|
+
await manager.resumeFromHistory("installed-resume-1", () => {});
|
|
396
|
+
|
|
397
|
+
const fake = fakeInstances[0]!;
|
|
398
|
+
expect(fake.config.command).toBe("/usr/local/bin/claude-agent-acp");
|
|
399
|
+
expect(fake.resumeSessionCalls).toEqual([
|
|
400
|
+
{ sessionId: "proto-old", cwd: "/tmp/proj" },
|
|
401
|
+
]);
|
|
402
|
+
// The SessionEntry command is the basename (resume hints gate on it).
|
|
403
|
+
expect(
|
|
404
|
+
internals(manager).sessions.get("installed-resume-1")!.command,
|
|
405
|
+
).toBe("claude-agent-acp");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("missing adapter on resume: installs via sandboxed bun, then resumes against the real binary", async () => {
|
|
409
|
+
// The adapter binary is missing but maps to an allowlisted package and
|
|
410
|
+
// bun is present. resumeFromHistory must trigger the same one-time
|
|
411
|
+
// sandboxed install as spawn, then resume against the now-installed real
|
|
412
|
+
// binary, with the OAuth token injected only at spawn (never during the
|
|
413
|
+
// install).
|
|
414
|
+
fakeCaps.resume = true;
|
|
415
|
+
insertHistoryRow({ id: "resume-install-1" });
|
|
416
|
+
|
|
417
|
+
// Resolver: binary missing until the install flips `installed`, then the
|
|
418
|
+
// real installed binary (a full path, as a global bin would resolve).
|
|
419
|
+
let installed = false;
|
|
420
|
+
resolveImpl = () =>
|
|
421
|
+
installed
|
|
422
|
+
? {
|
|
423
|
+
ok: true,
|
|
424
|
+
agent: { command: "/usr/local/bin/claude-agent-acp", args: [] },
|
|
425
|
+
}
|
|
426
|
+
: {
|
|
427
|
+
ok: false,
|
|
428
|
+
reason: "binary_not_found",
|
|
429
|
+
hint: "bun add -g @agentclientprotocol/claude-agent-acp",
|
|
430
|
+
command: "claude-agent-acp",
|
|
431
|
+
};
|
|
432
|
+
which.setWhich({ bun: BUN_BIN });
|
|
433
|
+
execScripts.set(BUN_ADD_KEY, {
|
|
434
|
+
stdout: "",
|
|
435
|
+
onCall: () => {
|
|
436
|
+
installed = true;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Seed the secrets on the ambient env so we can assert they are stripped
|
|
441
|
+
// from the installer env and only reappear at spawn time.
|
|
442
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = "should-not-leak";
|
|
443
|
+
process.env.GEMINI_API_KEY = "should-not-leak-either";
|
|
444
|
+
|
|
445
|
+
const manager = new AcpSessionManager(4);
|
|
446
|
+
try {
|
|
447
|
+
await manager.resumeFromHistory("resume-install-1", () => {});
|
|
448
|
+
} finally {
|
|
449
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
450
|
+
delete process.env.GEMINI_API_KEY;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The install ran via bun (never npm) in a sandboxed temp dir (NOT the
|
|
454
|
+
// project dir), with the secrets stripped from its env.
|
|
455
|
+
expect(execFileMock).toHaveBeenCalledTimes(1);
|
|
456
|
+
const [command, args, options] = execFileMock.mock.calls[0];
|
|
457
|
+
expect(command).toBe(BUN_BIN);
|
|
458
|
+
expect(command).not.toBe("npm");
|
|
459
|
+
expect(args).toEqual([
|
|
460
|
+
"add",
|
|
461
|
+
"--global",
|
|
462
|
+
"@agentclientprotocol/claude-agent-acp",
|
|
463
|
+
]);
|
|
464
|
+
const { cwd, env } = options as { cwd?: string; env?: NodeJS.ProcessEnv };
|
|
465
|
+
expect(cwd).toBeDefined();
|
|
466
|
+
expect(cwd!.startsWith(tmpdir())).toBe(true);
|
|
467
|
+
expect(cwd).not.toBe(process.cwd());
|
|
468
|
+
expect(cwd).toContain("vellum-acp-install-");
|
|
469
|
+
expect(env!.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
470
|
+
expect(env!.GEMINI_API_KEY).toBeUndefined();
|
|
471
|
+
|
|
472
|
+
// Resume then succeeded against the now-installed real binary.
|
|
473
|
+
const fake = fakeInstances[0]!;
|
|
474
|
+
expect(fake.config.command).toBe("/usr/local/bin/claude-agent-acp");
|
|
475
|
+
expect(fake.resumeSessionCalls).toEqual([
|
|
476
|
+
{ sessionId: "proto-old", cwd: "/tmp/proj" },
|
|
477
|
+
]);
|
|
478
|
+
expect(
|
|
479
|
+
(manager.getStatus("resume-install-1") as AcpSessionState).status,
|
|
480
|
+
).toBe("running");
|
|
481
|
+
|
|
482
|
+
// prepareAgentEnv ran AFTER resolution, on the resolved real binary, and
|
|
483
|
+
// injected the token at spawn time — the sole point the token is in scope.
|
|
484
|
+
expect(prepareAgentEnvCommands).toEqual(["/usr/local/bin/claude-agent-acp"]);
|
|
485
|
+
expect(fake.config.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("should-not-leak");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("install failure on resume surfaces the actionable error (no process spawned)", async () => {
|
|
489
|
+
fakeCaps.resume = true;
|
|
490
|
+
insertHistoryRow({ id: "resume-install-fail-1" });
|
|
491
|
+
resolveImpl = () => ({
|
|
492
|
+
ok: false,
|
|
493
|
+
reason: "binary_not_found",
|
|
494
|
+
hint: "bun add -g @agentclientprotocol/claude-agent-acp",
|
|
495
|
+
command: "claude-agent-acp",
|
|
496
|
+
});
|
|
497
|
+
which.setWhich({ bun: BUN_BIN });
|
|
498
|
+
execScripts.set(BUN_ADD_KEY, { error: new Error("network is down") });
|
|
499
|
+
|
|
500
|
+
const manager = new AcpSessionManager(4);
|
|
501
|
+
const promise = manager.resumeFromHistory("resume-install-fail-1", () => {});
|
|
502
|
+
await expect(promise).rejects.toThrow(/claude-agent-acp is not on PATH/);
|
|
503
|
+
await expect(promise).rejects.toThrow(
|
|
504
|
+
/auto-install failed: .*network is down/,
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// The install was attempted via bun (never npm) but no child process
|
|
508
|
+
// spawned, and the reservation was released.
|
|
509
|
+
for (const call of execFileMock.mock.calls) {
|
|
510
|
+
expect(call[0]).not.toBe("npm");
|
|
511
|
+
}
|
|
512
|
+
expect(fakeInstances).toHaveLength(0);
|
|
513
|
+
expect(internals(manager).pendingResumes.size).toBe(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("bun absent on resume surfaces the actionable install hint, attempts no install", async () => {
|
|
517
|
+
insertHistoryRow({ id: "no-bin-1" });
|
|
518
|
+
resolveImpl = () => ({
|
|
519
|
+
ok: false,
|
|
520
|
+
reason: "binary_not_found",
|
|
521
|
+
hint: "bun add -g @agentclientprotocol/claude-agent-acp",
|
|
522
|
+
command: "claude-agent-acp",
|
|
523
|
+
});
|
|
524
|
+
which.setWhich({}); // bun not on PATH (the beforeEach default, made explicit)
|
|
525
|
+
|
|
526
|
+
const manager = new AcpSessionManager(4);
|
|
527
|
+
await expect(
|
|
528
|
+
manager.resumeFromHistory("no-bin-1", () => {}),
|
|
529
|
+
).rejects.toThrow(
|
|
530
|
+
"claude-agent-acp is not on PATH. bun add -g @agentclientprotocol/claude-agent-acp",
|
|
531
|
+
);
|
|
532
|
+
expect(execFileMock).not.toHaveBeenCalled();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("already-active id and concurrency limit reuse spawn's guards", async () => {
|
|
536
|
+
fakeCaps.resume = true;
|
|
537
|
+
insertHistoryRow({ id: "active-1" });
|
|
538
|
+
|
|
539
|
+
const manager = new AcpSessionManager(4);
|
|
540
|
+
await manager.resumeFromHistory("active-1", () => {});
|
|
541
|
+
await expect(
|
|
542
|
+
manager.resumeFromHistory("active-1", () => {}),
|
|
543
|
+
).rejects.toThrow('ACP session "active-1" is already active');
|
|
544
|
+
|
|
545
|
+
const full = new AcpSessionManager(0);
|
|
546
|
+
await expect(full.resumeFromHistory("active-1", () => {})).rejects.toThrow(
|
|
547
|
+
/ACP concurrency limit reached \(max 0\)/,
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("terminal persistence after a resumed run upserts the original row with the merged event log", async () => {
|
|
552
|
+
fakeCaps.resume = true;
|
|
553
|
+
insertHistoryRow({
|
|
554
|
+
id: "resume-up-1",
|
|
555
|
+
eventLogJson: JSON.stringify([PERSISTED_EVENT]),
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const manager = new AcpSessionManager(4);
|
|
559
|
+
await manager.resumeFromHistory("resume-up-1", () => {});
|
|
560
|
+
await manager.steer("resume-up-1", "continue the work");
|
|
561
|
+
|
|
562
|
+
const fake = fakeInstances[0]!;
|
|
563
|
+
expect(fake.promptCalls).toEqual([
|
|
564
|
+
{ sessionId: "proto-old", text: "continue the work" },
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
// A new update lands during the resumed run.
|
|
568
|
+
await fake.emitChunk("resumed-run-chunk");
|
|
569
|
+
|
|
570
|
+
// Drive the prompt to completion; yield twice to flush the .then()
|
|
571
|
+
// and the persist call queued behind it.
|
|
572
|
+
fake.resolvePrompt!({ stopReason: "end_turn" });
|
|
573
|
+
await Promise.resolve();
|
|
574
|
+
await Promise.resolve();
|
|
575
|
+
|
|
576
|
+
expect(countHistoryRows()).toBe(1);
|
|
577
|
+
const row = readHistoryRow("resume-up-1")!;
|
|
578
|
+
expect(row.status).toBe("completed");
|
|
579
|
+
expect(row.stop_reason).toBe("end_turn");
|
|
580
|
+
expect(row.started_at).toBe(1234);
|
|
581
|
+
expect(row.cwd).toBe("/tmp/proj");
|
|
582
|
+
|
|
583
|
+
const log = JSON.parse(row.event_log_json) as Array<{ content?: string }>;
|
|
584
|
+
expect(log).toHaveLength(2);
|
|
585
|
+
expect(log[0]!.content).toBe("original-run-chunk");
|
|
586
|
+
expect(log[1]!.content).toBe("resumed-run-chunk");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("concurrent resumes of the same id: one wins, the loser fails cleanly without leaking a process", async () => {
|
|
590
|
+
fakeCaps.resume = true;
|
|
591
|
+
insertHistoryRow({ id: "race-1" });
|
|
592
|
+
|
|
593
|
+
const manager = new AcpSessionManager(4);
|
|
594
|
+
const sent: ServerMessage[] = [];
|
|
595
|
+
const [a, b] = await Promise.allSettled([
|
|
596
|
+
manager.resumeFromHistory("race-1", (msg) => sent.push(msg)),
|
|
597
|
+
manager.resumeFromHistory("race-1", (msg) => sent.push(msg)),
|
|
598
|
+
]);
|
|
599
|
+
|
|
600
|
+
// Exactly one resume wins; the other fails the synchronous guard.
|
|
601
|
+
expect([a.status, b.status].sort()).toEqual(["fulfilled", "rejected"]);
|
|
602
|
+
const rejected = (a.status === "rejected" ? a : b) as PromiseRejectedResult;
|
|
603
|
+
expect(String(rejected.reason)).toContain("is already active");
|
|
604
|
+
|
|
605
|
+
// Only one child process was ever constructed and it is still alive
|
|
606
|
+
// (the loser never got far enough to spawn-and-leak a second one).
|
|
607
|
+
expect(fakeInstances).toHaveLength(1);
|
|
608
|
+
expect(fakeInstances[0]!.killed).toBe(false);
|
|
609
|
+
expect((manager.getStatus("race-1") as AcpSessionState).status).toBe(
|
|
610
|
+
"running",
|
|
611
|
+
);
|
|
612
|
+
// Exactly one spawned event went out (single stream, not doubled).
|
|
613
|
+
expect(sent.filter((m) => m.type === "acp_session_spawned")).toHaveLength(
|
|
614
|
+
1,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("concurrent resumes of distinct ids cannot exceed maxConcurrent", async () => {
|
|
619
|
+
fakeCaps.resume = true;
|
|
620
|
+
insertHistoryRow({ id: "cap-1" });
|
|
621
|
+
insertHistoryRow({ id: "cap-2" });
|
|
622
|
+
|
|
623
|
+
const manager = new AcpSessionManager(1);
|
|
624
|
+
const [a, b] = await Promise.allSettled([
|
|
625
|
+
manager.resumeFromHistory("cap-1", () => {}),
|
|
626
|
+
manager.resumeFromHistory("cap-2", () => {}),
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
expect([a.status, b.status].sort()).toEqual(["fulfilled", "rejected"]);
|
|
630
|
+
const rejected = (a.status === "rejected" ? a : b) as PromiseRejectedResult;
|
|
631
|
+
expect(String(rejected.reason)).toMatch(
|
|
632
|
+
/ACP concurrency limit reached \(max 1\)/,
|
|
633
|
+
);
|
|
634
|
+
expect(fakeInstances).toHaveLength(1);
|
|
635
|
+
expect(manager.getStatus() as AcpSessionState[]).toHaveLength(1);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("getActiveAndPendingIds surfaces a resume still awaiting env prep", async () => {
|
|
639
|
+
fakeCaps.resume = true;
|
|
640
|
+
insertHistoryRow({ id: "pending-vis-1" });
|
|
641
|
+
let release!: () => void;
|
|
642
|
+
prepareAgentEnvGate = new Promise((res) => {
|
|
643
|
+
release = res;
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const manager = new AcpSessionManager(4);
|
|
647
|
+
const promise = manager.resumeFromHistory("pending-vis-1", () => {});
|
|
648
|
+
|
|
649
|
+
// Mid-resume: no SessionEntry yet (getStatus throws not-found), but the
|
|
650
|
+
// id must already be visible to the delete guards.
|
|
651
|
+
expect(() => manager.getStatus("pending-vis-1")).toThrow(/not found/);
|
|
652
|
+
expect(manager.getActiveAndPendingIds()).toEqual(["pending-vis-1"]);
|
|
653
|
+
|
|
654
|
+
release();
|
|
655
|
+
await promise;
|
|
656
|
+
|
|
657
|
+
// After the resume lands the id comes from the sessions map and the
|
|
658
|
+
// reservation is gone (no duplicate).
|
|
659
|
+
expect(manager.getActiveAndPendingIds()).toEqual(["pending-vis-1"]);
|
|
660
|
+
expect(internals(manager).pendingResumes.size).toBe(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("dispose during prepareAgentEnv aborts the resume before any process spawns", async () => {
|
|
664
|
+
fakeCaps.resume = true;
|
|
665
|
+
insertHistoryRow({ id: "dispose-1" });
|
|
666
|
+
let release!: () => void;
|
|
667
|
+
prepareAgentEnvGate = new Promise((res) => {
|
|
668
|
+
release = res;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const manager = new AcpSessionManager(4);
|
|
672
|
+
const promise = manager.resumeFromHistory("dispose-1", () => {});
|
|
673
|
+
manager.dispose();
|
|
674
|
+
release();
|
|
675
|
+
|
|
676
|
+
await expect(promise).rejects.toThrow(/disposed/);
|
|
677
|
+
// No child process was ever constructed on the disposed manager, and
|
|
678
|
+
// the reservation was released.
|
|
679
|
+
expect(fakeInstances).toHaveLength(0);
|
|
680
|
+
expect(internals(manager).pendingResumes.size).toBe(0);
|
|
681
|
+
expect(internals(manager).sessions.size).toBe(0);
|
|
682
|
+
expect(internals(manager).eventBuffers.size).toBe(0);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("cancel on a resumed session with no in-flight prompt persists and tears down", async () => {
|
|
686
|
+
fakeCaps.resume = true;
|
|
687
|
+
insertHistoryRow({
|
|
688
|
+
id: "idle-1",
|
|
689
|
+
eventLogJson: JSON.stringify([PERSISTED_EVENT]),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const manager = new AcpSessionManager(4);
|
|
693
|
+
await manager.resumeFromHistory("idle-1", () => {});
|
|
694
|
+
|
|
695
|
+
// No prompt is in flight, so cancel() itself must own the terminal
|
|
696
|
+
// persistence + teardown (there is no prompt handler to do it).
|
|
697
|
+
await manager.cancel("idle-1");
|
|
698
|
+
|
|
699
|
+
expect(fakeInstances[0]!.killed).toBe(true);
|
|
700
|
+
expect(internals(manager).sessions.has("idle-1")).toBe(false);
|
|
701
|
+
expect(internals(manager).eventBuffers.has("idle-1")).toBe(false);
|
|
702
|
+
|
|
703
|
+
const row = readHistoryRow("idle-1")!;
|
|
704
|
+
expect(row.status).toBe("cancelled");
|
|
705
|
+
expect(row.completed_at).not.toBeNull();
|
|
706
|
+
// The re-seeded event log survived the cancel-side persistence.
|
|
707
|
+
const log = JSON.parse(row.event_log_json) as Array<{ content?: string }>;
|
|
708
|
+
expect(log).toHaveLength(1);
|
|
709
|
+
expect(log[0]!.content).toBe("original-run-chunk");
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe("AcpSessionManager.steerOrResume", () => {
|
|
714
|
+
test("steers an in-memory session directly without a resume", async () => {
|
|
715
|
+
fakeCaps.resume = true;
|
|
716
|
+
insertHistoryRow({ id: "sor-live-1" });
|
|
717
|
+
|
|
718
|
+
const manager = new AcpSessionManager(4);
|
|
719
|
+
await manager.resumeFromHistory("sor-live-1", () => {});
|
|
720
|
+
expect(fakeInstances).toHaveLength(1);
|
|
721
|
+
|
|
722
|
+
const result = await manager.steerOrResume(
|
|
723
|
+
"sor-live-1",
|
|
724
|
+
"go faster",
|
|
725
|
+
() => {},
|
|
726
|
+
);
|
|
727
|
+
expect(result).toEqual({ resumed: false });
|
|
728
|
+
// No second process was constructed.
|
|
729
|
+
expect(fakeInstances).toHaveLength(1);
|
|
730
|
+
expect(fakeInstances[0]!.promptCalls).toEqual([
|
|
731
|
+
{ sessionId: "proto-old", text: "go faster" },
|
|
732
|
+
]);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("session not in memory: resumes from history and fires the instruction atomically", async () => {
|
|
736
|
+
fakeCaps.resume = true;
|
|
737
|
+
insertHistoryRow({ id: "sor-resume-1" });
|
|
738
|
+
|
|
739
|
+
const manager = new AcpSessionManager(4);
|
|
740
|
+
const sent: ServerMessage[] = [];
|
|
741
|
+
const result = await manager.steerOrResume(
|
|
742
|
+
"sor-resume-1",
|
|
743
|
+
"continue the work",
|
|
744
|
+
(msg) => sent.push(msg),
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
expect(result).toEqual({ resumed: true });
|
|
748
|
+
const fake = fakeInstances[0]!;
|
|
749
|
+
expect(fake.resumeSessionCalls).toEqual([
|
|
750
|
+
{ sessionId: "proto-old", cwd: "/tmp/proj" },
|
|
751
|
+
]);
|
|
752
|
+
// The instruction prompt fired immediately after the resume - the
|
|
753
|
+
// session never sat running-idle with no in-flight prompt.
|
|
754
|
+
expect(fake.promptCalls).toEqual([
|
|
755
|
+
{ sessionId: "proto-old", text: "continue the work" },
|
|
756
|
+
]);
|
|
757
|
+
expect(sent.filter((m) => m.type === "acp_session_spawned")).toHaveLength(
|
|
758
|
+
1,
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("missing history row keeps the typed not-found error", async () => {
|
|
763
|
+
const manager = new AcpSessionManager(4);
|
|
764
|
+
const promise = manager.steerOrResume("sor-missing-1", "go", () => {});
|
|
765
|
+
await expect(promise).rejects.toBeInstanceOf(AcpSessionNotFoundError);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("resume failure surfaces as AcpResumeError with the actionable hint", async () => {
|
|
769
|
+
insertHistoryRow({ id: "sor-legacy-1", cwd: null });
|
|
770
|
+
|
|
771
|
+
const manager = new AcpSessionManager(4);
|
|
772
|
+
const promise = manager.steerOrResume("sor-legacy-1", "go", () => {});
|
|
773
|
+
await expect(promise).rejects.toBeInstanceOf(AcpResumeError);
|
|
774
|
+
await expect(promise).rejects.toThrow(/recorded before resume support/);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("concurrent steerOrResume on the same id: the loser awaits the in-flight resume and steers", async () => {
|
|
778
|
+
fakeCaps.resume = true;
|
|
779
|
+
insertHistoryRow({ id: "sor-race-1" });
|
|
780
|
+
|
|
781
|
+
const manager = new AcpSessionManager(4);
|
|
782
|
+
const sent: ServerMessage[] = [];
|
|
783
|
+
const [a, b] = await Promise.all([
|
|
784
|
+
manager.steerOrResume("sor-race-1", "first instruction", (msg) =>
|
|
785
|
+
sent.push(msg),
|
|
786
|
+
),
|
|
787
|
+
manager.steerOrResume("sor-race-1", "second instruction", (msg) =>
|
|
788
|
+
sent.push(msg),
|
|
789
|
+
),
|
|
790
|
+
]);
|
|
791
|
+
|
|
792
|
+
// Neither caller got the misleading "already active" resume error;
|
|
793
|
+
// both landed their instruction on the single resumed session.
|
|
794
|
+
expect(a).toEqual({ resumed: true });
|
|
795
|
+
expect(b).toEqual({ resumed: true });
|
|
796
|
+
expect(fakeInstances).toHaveLength(1);
|
|
797
|
+
const fake = fakeInstances[0]!;
|
|
798
|
+
expect(fake.resumeSessionCalls).toHaveLength(1);
|
|
799
|
+
expect(fake.promptCalls.map((c) => c.text).sort()).toEqual([
|
|
800
|
+
"first instruction",
|
|
801
|
+
"second instruction",
|
|
802
|
+
]);
|
|
803
|
+
// Single spawned event: one resume happened, not two.
|
|
804
|
+
expect(sent.filter((m) => m.type === "acp_session_spawned")).toHaveLength(
|
|
805
|
+
1,
|
|
806
|
+
);
|
|
807
|
+
expect((manager.getStatus("sor-race-1") as AcpSessionState).status).toBe(
|
|
808
|
+
"running",
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("steerOrResume arriving in the initializing window awaits the in-flight resume and steers", async () => {
|
|
813
|
+
fakeCaps.resume = true;
|
|
814
|
+
insertHistoryRow({ id: "sor-init-1" });
|
|
815
|
+
let release!: () => void;
|
|
816
|
+
resumeSessionGate = new Promise((res) => {
|
|
817
|
+
release = res;
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const manager = new AcpSessionManager(4);
|
|
821
|
+
const sent: ServerMessage[] = [];
|
|
822
|
+
const first = manager.steerOrResume("sor-init-1", "first instruction", (msg) =>
|
|
823
|
+
sent.push(msg),
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
// Advance microtasks until the first call's resume has registered its
|
|
827
|
+
// SessionEntry but is still gated inside resumeSession: the session
|
|
828
|
+
// exists in memory with status "initializing".
|
|
829
|
+
while (!internals(manager).sessions.has("sor-init-1")) {
|
|
830
|
+
await Promise.resolve();
|
|
831
|
+
}
|
|
832
|
+
expect((manager.getStatus("sor-init-1") as AcpSessionState).status).toBe(
|
|
833
|
+
"initializing",
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// A steerOrResume in this window previously rethrew the plain
|
|
837
|
+
// "is not running (status: initializing)" error and dropped the
|
|
838
|
+
// instruction. It must instead await the in-flight resume and retry.
|
|
839
|
+
const second = manager.steerOrResume(
|
|
840
|
+
"sor-init-1",
|
|
841
|
+
"second instruction",
|
|
842
|
+
(msg) => sent.push(msg),
|
|
843
|
+
);
|
|
844
|
+
// Let the second call hit the initializing steer failure and park on
|
|
845
|
+
// the in-flight resume before releasing the gate.
|
|
846
|
+
await Promise.resolve();
|
|
847
|
+
await Promise.resolve();
|
|
848
|
+
release();
|
|
849
|
+
|
|
850
|
+
const [a, b] = await Promise.all([first, second]);
|
|
851
|
+
expect(a).toEqual({ resumed: true });
|
|
852
|
+
expect(b).toEqual({ resumed: true });
|
|
853
|
+
|
|
854
|
+
// Exactly one process was constructed and one resume happened; both
|
|
855
|
+
// instructions landed on the single resumed session after it settled.
|
|
856
|
+
expect(fakeInstances).toHaveLength(1);
|
|
857
|
+
const fake = fakeInstances[0]!;
|
|
858
|
+
expect(fake.resumeSessionCalls).toHaveLength(1);
|
|
859
|
+
expect(fake.promptCalls.map((c) => c.text).sort()).toEqual([
|
|
860
|
+
"first instruction",
|
|
861
|
+
"second instruction",
|
|
862
|
+
]);
|
|
863
|
+
expect(sent.filter((m) => m.type === "acp_session_spawned")).toHaveLength(
|
|
864
|
+
1,
|
|
865
|
+
);
|
|
866
|
+
expect((manager.getStatus("sor-init-1") as AcpSessionState).status).toBe(
|
|
867
|
+
"running",
|
|
868
|
+
);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("post-resume steer failure tears the resumed session down instead of leaving it idle", async () => {
|
|
872
|
+
fakeCaps.resume = true;
|
|
873
|
+
insertHistoryRow({ id: "sor-fail-1" });
|
|
874
|
+
|
|
875
|
+
const manager = new AcpSessionManager(4);
|
|
876
|
+
promptThrowsSync = true;
|
|
877
|
+
const promise = manager.steerOrResume("sor-fail-1", "go", () => {});
|
|
878
|
+
await expect(promise).rejects.toBeInstanceOf(AcpResumeError);
|
|
879
|
+
await expect(promise).rejects.toThrow("prompt transport dead");
|
|
880
|
+
|
|
881
|
+
// The resumed session did not leak: process killed, maps cleared,
|
|
882
|
+
// terminal row persisted.
|
|
883
|
+
expect(fakeInstances[0]!.killed).toBe(true);
|
|
884
|
+
expect(internals(manager).sessions.has("sor-fail-1")).toBe(false);
|
|
885
|
+
expect(internals(manager).eventBuffers.has("sor-fail-1")).toBe(false);
|
|
886
|
+
expect(readHistoryRow("sor-fail-1")!.status).toBe("cancelled");
|
|
887
|
+
});
|
|
888
|
+
});
|