From e8b4bf1fe4fedc48bd0dc56b5745467542946474 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 19:03:09 +0900 Subject: fix(kernel): disable MAX_STEPS limit (0 = unlimited) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents were being cut off mid-task at 50 steps. The MAX_STEPS=50 hardcoded limit was silently terminating turns while the model was actively making tool calls, leaving conversations idle with a dangling tool-result as the last chunk. Setting MAX_STEPS to 0 disables the limit — the loop runs until the model stops making tool calls naturally or the abort signal fires. The max-steps code path is preserved for when MAX_STEPS > 0. --- packages/kernel/src/runtime/run-turn.test.ts | 36 ++++++++++++++++++---------- packages/kernel/src/runtime/run-turn.ts | 8 ++++--- 2 files changed, 29 insertions(+), 15 deletions(-) (limited to 'packages/kernel/src') diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index a9fc3d9..59e7fab 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -5,7 +5,7 @@ import type { LogDeps, Logger, LogRecord, LogSink } from "../contracts/logging.j import type { ProviderContract, ProviderEvent } from "../contracts/provider.js"; import type { ToolContract, ToolExecuteContext, ToolResult } from "../contracts/tool.js"; import { createLogger } from "../logging/logger.js"; -import { MAX_STEPS, runTurn } from "./run-turn.js"; +import { runTurn } from "./run-turn.js"; function delay(ms: number): Promise { return new Promise((resolve) => { @@ -2853,12 +2853,22 @@ describe("runTurn", () => { expect(drainCallCount).toBe(2); }); - it("drainSteering NOT called when max-steps ends the turn after a tool-call step (no next step → no drain)", async () => { + it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => { let drainCallCount = 0; - // Every step produces a tool call → the turn runs to MAX_STEPS. - const script: ProviderEvent[][] = Array.from({ length: MAX_STEPS }, () => [ - { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, + // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step + // to end the turn naturally. + const STEPS_WITH_TOOLS = 100; + const script: ProviderEvent[][] = []; + for (let i = 0; i < STEPS_WITH_TOOLS; i++) { + script.push([ + { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ]); + } + // Final step: text only, no tool calls → natural end. + script.push([ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, ]); const provider = createFakeProvider(script); @@ -2878,12 +2888,14 @@ describe("runTurn", () => { }, }); - expect(result.finishReason).toBe("max-steps"); - // MAX_STEPS tool-call steps (indices 0..MAX_STEPS-1). Drained on every - // step that is followed by a next step (0..MAX_STEPS-2 = MAX_STEPS-1 - // calls); the final step is the max-steps boundary → no next step → - // no drain (queue left intact for the caller). - expect(drainCallCount).toBe(MAX_STEPS - 1); + // Turn ended naturally, NOT via max-steps. + expect(result.finishReason).toBe("stop"); + // Every tool-call step (0..99) is followed by a next step → each + // triggers a drain. The text-only step breaks before draining. + expect(drainCallCount).toBe(STEPS_WITH_TOOLS); + // All 101 steps produced messages (100 tool steps with assistant + + // tool messages, 1 text-only step with an assistant message). + expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1); }); }); diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index ac87a1f..273482f 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -27,7 +27,9 @@ import { usageEvent, } from "./events.js"; -export const MAX_STEPS = 50; +/** Max steps per turn. 0 = unlimited (the loop runs until the model stops + * making tool calls or the abort signal fires). */ +export const MAX_STEPS = 0; function zeroUsage(): Usage { return { inputTokens: 0, outputTokens: 0 }; @@ -615,7 +617,7 @@ export async function runTurn(input: RunTurnInput): Promise { input.emit(turnStartEvent(conversationId, turnId)); try { - for (let step = 0; step < MAX_STEPS; step++) { + for (let step = 0; MAX_STEPS === 0 || step < MAX_STEPS; step++) { if (signal.aborted) { finishReason = "aborted"; break; @@ -708,7 +710,7 @@ export async function runTurn(input: RunTurnInput): Promise { break; } - if (step === MAX_STEPS - 1) { + if (MAX_STEPS > 0 && step === MAX_STEPS - 1) { finishReason = "max-steps"; // No next step → no tool-result boundary. Leave any pending // steering messages for the caller (it owns the queue). -- cgit v1.2.3 From 727c98c9dae516a2070eb950410314380a20c974 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 22:03:19 +0900 Subject: style: switch from tabs to 2-space indentation --- AGENTS.md | 2 +- biome.json | 12 +- package.json | 48 +- packages/auth-apikey/package.json | 18 +- packages/auth-apikey/src/extension.ts | 28 +- packages/auth-apikey/src/resolver.test.ts | 58 +- packages/auth-apikey/src/resolver.ts | 14 +- packages/auth-apikey/tsconfig.json | 8 +- packages/cache-warming/package.json | 24 +- packages/cache-warming/src/extension.ts | 272 +- packages/cache-warming/src/index.ts | 40 +- packages/cache-warming/src/pure.test.ts | 486 +- packages/cache-warming/src/pure.ts | 236 +- packages/cache-warming/src/warmer.test.ts | 1072 +-- packages/cache-warming/src/warmer.ts | 550 +- packages/cache-warming/tsconfig.json | 18 +- packages/cli/package.json | 20 +- packages/cli/src/args.test.ts | 956 +-- packages/cli/src/args.ts | 700 +- packages/cli/src/catalog.test.ts | 22 +- packages/cli/src/catalog.ts | 2 +- packages/cli/src/http.test.ts | 970 +-- packages/cli/src/http.ts | 338 +- packages/cli/src/index.ts | 24 +- packages/cli/src/main.ts | 414 +- packages/cli/src/message.test.ts | 270 +- packages/cli/src/message.ts | 66 +- packages/cli/src/ndjson.test.ts | 86 +- packages/cli/src/ndjson.ts | 12 +- packages/cli/src/render.test.ts | 464 +- packages/cli/src/render.ts | 100 +- packages/cli/tsconfig.json | 8 +- packages/conversation-store/package.json | 20 +- packages/conversation-store/src/extension.ts | 42 +- packages/conversation-store/src/index.ts | 8 +- packages/conversation-store/src/keys.ts | 52 +- packages/conversation-store/src/reconcile.test.ts | 798 +- packages/conversation-store/src/reconcile.ts | 176 +- .../conversation-store/src/store-workspace.test.ts | 1322 ++-- packages/conversation-store/src/store.test.ts | 3130 ++++---- packages/conversation-store/src/store.ts | 2428 +++--- packages/conversation-store/tsconfig.json | 8 +- packages/credential-store/package.json | 18 +- packages/credential-store/src/extension.ts | 36 +- packages/credential-store/src/index.ts | 8 +- packages/credential-store/src/registry.test.ts | 118 +- packages/credential-store/src/registry.ts | 176 +- packages/credential-store/tsconfig.json | 8 +- packages/exec-backend/package.json | 18 +- packages/exec-backend/src/backend.test.ts | 98 +- packages/exec-backend/src/backend.ts | 50 +- packages/exec-backend/src/extension.test.ts | 166 +- packages/exec-backend/src/extension.ts | 66 +- packages/exec-backend/src/index.ts | 4 +- packages/exec-backend/src/local.test.ts | 372 +- packages/exec-backend/src/local.ts | 216 +- packages/exec-backend/src/service.ts | 2 +- packages/exec-backend/tsconfig.json | 8 +- packages/host-bin/package.json | 70 +- packages/host-bin/src/collector-supervisor.test.ts | 574 +- packages/host-bin/src/collector-supervisor.ts | 244 +- packages/host-bin/src/config.test.ts | 152 +- packages/host-bin/src/config.ts | 108 +- packages/host-bin/src/load-external.test.ts | 70 +- packages/host-bin/src/load-external.ts | 46 +- packages/host-bin/src/main.ts | 342 +- packages/host-bin/tsconfig.json | 126 +- packages/journal-sink/package.json | 18 +- packages/journal-sink/src/journal-sink.test.ts | 508 +- packages/journal-sink/src/journal-sink.ts | 262 +- packages/journal-sink/tsconfig.json | 8 +- packages/kernel/package.json | 18 +- packages/kernel/src/bus/bus.test.ts | 658 +- packages/kernel/src/bus/bus.ts | 236 +- packages/kernel/src/bus/pure.ts | 122 +- packages/kernel/src/contracts/auth.ts | 28 +- packages/kernel/src/contracts/conversation.ts | 38 +- packages/kernel/src/contracts/dispatch.ts | 4 +- packages/kernel/src/contracts/events.ts | 32 +- packages/kernel/src/contracts/extension.ts | 338 +- packages/kernel/src/contracts/hooks.ts | 24 +- packages/kernel/src/contracts/index.ts | 192 +- packages/kernel/src/contracts/logging.ts | 164 +- packages/kernel/src/contracts/provider.ts | 140 +- packages/kernel/src/contracts/runtime.ts | 282 +- packages/kernel/src/contracts/tool.ts | 178 +- packages/kernel/src/host/dag.test.ts | 196 +- packages/kernel/src/host/dag.ts | 104 +- packages/kernel/src/host/host.test.ts | 2438 +++--- packages/kernel/src/host/host.ts | 412 +- packages/kernel/src/host/version.test.ts | 190 +- packages/kernel/src/host/version.ts | 82 +- packages/kernel/src/logging/logger.test.ts | 64 +- packages/kernel/src/logging/logger.ts | 516 +- packages/kernel/src/runtime/dispatch.test.ts | 980 +-- packages/kernel/src/runtime/dispatch.ts | 308 +- packages/kernel/src/runtime/events.ts | 270 +- packages/kernel/src/runtime/index.ts | 16 +- packages/kernel/src/runtime/run-turn.test.ts | 6810 ++++++++-------- packages/kernel/src/runtime/run-turn.ts | 1482 ++-- packages/kernel/tsconfig.json | 8 +- packages/lsp/package.json | 18 +- packages/lsp/src/aggregate.test.ts | 222 +- packages/lsp/src/aggregate.ts | 86 +- packages/lsp/src/client.test.ts | 850 +- packages/lsp/src/client.ts | 1052 +-- packages/lsp/src/config.test.ts | 394 +- packages/lsp/src/config.ts | 292 +- packages/lsp/src/diagnostics.test.ts | 82 +- packages/lsp/src/diagnostics.ts | 156 +- packages/lsp/src/diff.test.ts | 192 +- packages/lsp/src/diff.ts | 96 +- packages/lsp/src/extension.ts | 268 +- packages/lsp/src/framing.test.ts | 268 +- packages/lsp/src/framing.ts | 116 +- packages/lsp/src/index.ts | 30 +- packages/lsp/src/language.test.ts | 36 +- packages/lsp/src/language.ts | 86 +- packages/lsp/src/manager.test.ts | 872 +-- packages/lsp/src/manager.ts | 478 +- packages/lsp/src/root.test.ts | 92 +- packages/lsp/src/root.ts | 60 +- packages/lsp/src/rpc.test.ts | 190 +- packages/lsp/src/rpc.ts | 288 +- packages/lsp/src/tool.test.ts | 492 +- packages/lsp/src/tool.ts | 466 +- packages/lsp/src/types.ts | 78 +- packages/lsp/src/watched-files.test.ts | 124 +- packages/lsp/src/watched-files.ts | 140 +- packages/lsp/tsconfig.json | 8 +- packages/mcp/package.json | 20 +- packages/mcp/src/client.test.ts | 390 +- packages/mcp/src/client.ts | 210 +- packages/mcp/src/config.test.ts | 218 +- packages/mcp/src/config.ts | 114 +- packages/mcp/src/extension.test.ts | 594 +- packages/mcp/src/extension.ts | 334 +- packages/mcp/src/framing.test.ts | 146 +- packages/mcp/src/framing.ts | 114 +- packages/mcp/src/index.ts | 40 +- packages/mcp/src/manager.test.ts | 392 +- packages/mcp/src/manager.ts | 346 +- packages/mcp/src/registry.test.ts | 416 +- packages/mcp/src/registry.ts | 104 +- packages/mcp/src/rpc.test.ts | 170 +- packages/mcp/src/rpc.ts | 164 +- packages/mcp/src/transport.test.ts | 228 +- packages/mcp/src/transport.ts | 146 +- packages/mcp/src/types.ts | 76 +- packages/mcp/tsconfig.json | 8 +- packages/message-queue/package.json | 24 +- packages/message-queue/src/extension.ts | 106 +- packages/message-queue/src/index.ts | 26 +- packages/message-queue/src/pure.test.ts | 238 +- packages/message-queue/src/pure.ts | 74 +- packages/message-queue/src/service.test.ts | 148 +- packages/message-queue/src/service.ts | 94 +- packages/message-queue/tsconfig.json | 18 +- packages/observability-collector/package.json | 20 +- .../observability-collector/src/collector.test.ts | 756 +- packages/observability-collector/src/collector.ts | 194 +- packages/observability-collector/src/index.ts | 14 +- packages/observability-collector/src/main.ts | 174 +- packages/observability-collector/tsconfig.json | 8 +- packages/openai-stream/package.json | 22 +- .../src/__fixtures__/flash-text-turn.json | 48 +- .../src/__fixtures__/tool-call-turn.json | 46 +- .../openai-stream/src/convert-messages.test.ts | 660 +- packages/openai-stream/src/convert-messages.ts | 160 +- packages/openai-stream/src/convert-tools.test.ts | 188 +- packages/openai-stream/src/convert-tools.ts | 30 +- packages/openai-stream/src/listModels.test.ts | 150 +- packages/openai-stream/src/listModels.ts | 88 +- packages/openai-stream/src/parse-sse.test.ts | 456 +- packages/openai-stream/src/parse-sse.ts | 222 +- packages/openai-stream/src/provider.test.ts | 258 +- packages/openai-stream/src/provider.ts | 104 +- packages/openai-stream/src/stream.test.ts | 1652 ++-- packages/openai-stream/src/stream.ts | 786 +- packages/openai-stream/tsconfig.json | 8 +- packages/provider-openai-compat/package.json | 22 +- .../provider-openai-compat/src/extension.test.ts | 140 +- packages/provider-openai-compat/src/extension.ts | 78 +- packages/provider-openai-compat/src/index.ts | 18 +- packages/provider-openai-compat/tsconfig.json | 16 +- packages/provider-umans/package.json | 20 +- packages/provider-umans/src/extension.test.ts | 68 +- packages/provider-umans/src/extension.ts | 58 +- packages/provider-umans/src/index.ts | 8 +- packages/provider-umans/src/reasoning.test.ts | 36 +- packages/provider-umans/src/reasoning.ts | 18 +- packages/provider-umans/src/resolver.test.ts | 64 +- packages/provider-umans/src/resolver.ts | 22 +- packages/provider-umans/tsconfig.json | 8 +- packages/session-orchestrator/package.json | 26 +- packages/session-orchestrator/src/extension.ts | 294 +- packages/session-orchestrator/src/index.ts | 84 +- packages/session-orchestrator/src/metrics.test.ts | 720 +- packages/session-orchestrator/src/metrics.ts | 226 +- .../session-orchestrator/src/orchestrator.test.ts | 7774 +++++++++--------- packages/session-orchestrator/src/orchestrator.ts | 2066 ++--- packages/session-orchestrator/src/pure.test.ts | 282 +- packages/session-orchestrator/src/pure.ts | 60 +- packages/session-orchestrator/src/queue.test.ts | 1058 +-- .../session-orchestrator/src/tools-filter.test.ts | 118 +- packages/session-orchestrator/src/tools-filter.ts | 56 +- packages/session-orchestrator/tsconfig.json | 20 +- packages/skills/package.json | 20 +- packages/skills/src/extension.ts | 34 +- packages/skills/src/index.ts | 16 +- packages/skills/src/load-skill.ts | 268 +- packages/skills/src/pure.test.ts | 300 +- packages/skills/src/pure.ts | 106 +- packages/skills/src/skills.test.ts | 478 +- packages/skills/src/tools-filter.ts | 102 +- packages/skills/tsconfig.json | 8 +- packages/ssh/package.json | 36 +- packages/ssh/src/backend.ts | 292 +- packages/ssh/src/config.test.ts | 650 +- packages/ssh/src/config.ts | 394 +- packages/ssh/src/errors.test.ts | 134 +- packages/ssh/src/errors.ts | 106 +- packages/ssh/src/extension.ts | 240 +- packages/ssh/src/hostkey.test.ts | 152 +- packages/ssh/src/hostkey.ts | 142 +- packages/ssh/src/index.ts | 28 +- packages/ssh/src/integration.test.ts | 272 +- packages/ssh/src/pool.ts | 650 +- packages/ssh/src/service.ts | 340 +- packages/ssh/tsconfig.json | 20 +- packages/storage-sqlite/package.json | 18 +- packages/storage-sqlite/src/extension.ts | 20 +- packages/storage-sqlite/src/migrate.ts | 16 +- packages/storage-sqlite/src/storage.test.ts | 408 +- packages/storage-sqlite/src/storage.ts | 120 +- packages/storage-sqlite/tsconfig.json | 8 +- packages/surface-loaded-extensions/package.json | 22 +- .../surface-loaded-extensions/src/extension.ts | 62 +- .../surface-loaded-extensions/src/spec.test.ts | 188 +- packages/surface-loaded-extensions/src/spec.ts | 48 +- packages/surface-loaded-extensions/tsconfig.json | 16 +- packages/surface-registry/package.json | 20 +- packages/surface-registry/src/extension.ts | 28 +- packages/surface-registry/src/registry.test.ts | 224 +- packages/surface-registry/src/registry.ts | 100 +- packages/surface-registry/tsconfig.json | 8 +- packages/system-prompt/package.json | 22 +- packages/system-prompt/src/catalog.test.ts | 46 +- packages/system-prompt/src/catalog.ts | 36 +- packages/system-prompt/src/extension.ts | 206 +- packages/system-prompt/src/index.ts | 12 +- packages/system-prompt/src/parser.test.ts | 356 +- packages/system-prompt/src/parser.ts | 346 +- packages/system-prompt/src/resolver.test.ts | 542 +- packages/system-prompt/src/resolver.ts | 236 +- packages/system-prompt/src/service.test.ts | 414 +- packages/system-prompt/src/service.ts | 126 +- packages/system-prompt/src/types.ts | 74 +- packages/system-prompt/tsconfig.json | 8 +- packages/throughput-store/package.json | 18 +- packages/throughput-store/src/aggregate.test.ts | 76 +- packages/throughput-store/src/aggregate.ts | 80 +- packages/throughput-store/src/extension.ts | 30 +- packages/throughput-store/src/index.ts | 18 +- packages/throughput-store/src/period.test.ts | 100 +- packages/throughput-store/src/period.ts | 132 +- packages/throughput-store/src/store.test.ts | 108 +- packages/throughput-store/src/store.ts | 100 +- packages/throughput-store/tsconfig.json | 8 +- packages/todo/package.json | 22 +- packages/todo/src/extension.test.ts | 230 +- packages/todo/src/extension.ts | 98 +- packages/todo/src/format.test.ts | 24 +- packages/todo/src/index.ts | 24 +- packages/todo/src/pure.ts | 118 +- packages/todo/src/store.test.ts | 70 +- packages/todo/src/tool.test.ts | 172 +- packages/todo/src/tool.ts | 104 +- packages/todo/src/validate.test.ts | 110 +- packages/todo/tsconfig.json | 16 +- packages/tool-edit-file/package.json | 22 +- packages/tool-edit-file/src/edit-file.test.ts | 786 +- packages/tool-edit-file/src/edit-file.ts | 474 +- packages/tool-edit-file/src/extension.ts | 90 +- packages/tool-edit-file/tsconfig.json | 8 +- packages/tool-read-file/package.json | 20 +- packages/tool-read-file/src/extension.ts | 32 +- packages/tool-read-file/src/read-file.test.ts | 688 +- packages/tool-read-file/src/read-file.ts | 330 +- packages/tool-read-file/tsconfig.json | 8 +- packages/tool-shell/package.json | 20 +- packages/tool-shell/src/extension.ts | 32 +- packages/tool-shell/src/shell.test.ts | 816 +- packages/tool-shell/src/shell.ts | 298 +- packages/tool-shell/tsconfig.json | 8 +- packages/tool-web-search/package.json | 18 +- packages/tool-web-search/src/client.test.ts | 356 +- packages/tool-web-search/src/client.ts | 368 +- packages/tool-web-search/src/extension.test.ts | 162 +- packages/tool-web-search/src/extension.ts | 22 +- packages/tool-web-search/src/format.test.ts | 126 +- packages/tool-web-search/src/format.ts | 116 +- packages/tool-web-search/src/index.ts | 64 +- packages/tool-web-search/src/tool.ts | 216 +- packages/tool-web-search/src/validate.test.ts | 150 +- packages/tool-web-search/src/validate.ts | 304 +- packages/tool-web-search/tsconfig.json | 8 +- packages/tool-write-file/package.json | 20 +- packages/tool-write-file/src/extension.ts | 32 +- packages/tool-write-file/src/index.ts | 6 +- packages/tool-write-file/src/write-file.test.ts | 420 +- packages/tool-write-file/src/write-file.ts | 258 +- packages/tool-write-file/tsconfig.json | 8 +- packages/tool-youtube-transcript/package.json | 18 +- .../tool-youtube-transcript/src/client.test.ts | 218 +- packages/tool-youtube-transcript/src/client.ts | 92 +- .../tool-youtube-transcript/src/extension.test.ts | 172 +- packages/tool-youtube-transcript/src/extension.ts | 22 +- .../tool-youtube-transcript/src/format.test.ts | 206 +- packages/tool-youtube-transcript/src/format.ts | 98 +- packages/tool-youtube-transcript/src/index.ts | 32 +- packages/tool-youtube-transcript/src/tool.test.ts | 278 +- packages/tool-youtube-transcript/src/tool.ts | 168 +- .../tool-youtube-transcript/src/validate.test.ts | 54 +- packages/tool-youtube-transcript/src/validate.ts | 24 +- packages/tool-youtube-transcript/tsconfig.json | 8 +- packages/trace-replay/package.json | 12 +- packages/trace-replay/src/fixture.test.ts | 274 +- packages/trace-replay/src/fixture.ts | 108 +- packages/trace-replay/src/record.test.ts | 206 +- packages/trace-replay/src/record.ts | 172 +- packages/trace-replay/src/replay.test.ts | 228 +- packages/trace-replay/src/replay.ts | 222 +- packages/trace-replay/src/types.ts | 34 +- packages/trace-replay/tsconfig.json | 8 +- packages/trace-store/package.json | 18 +- packages/trace-store/src/cli.ts | 10 +- packages/trace-store/src/easy-view.test.ts | 674 +- packages/trace-store/src/easy-view.ts | 344 +- packages/trace-store/src/store.test.ts | 1234 +-- packages/trace-store/src/store.ts | 954 +-- packages/trace-store/tsconfig.json | 8 +- packages/transport-contract/package.json | 20 +- .../transport-contract/src/contract.types.test.ts | 414 +- packages/transport-contract/src/index.ts | 602 +- packages/transport-contract/tsconfig.json | 8 +- packages/transport-http/package.json | 38 +- packages/transport-http/src/app.test.ts | 8222 ++++++++++---------- packages/transport-http/src/app.ts | 2822 +++---- packages/transport-http/src/extension.ts | 246 +- packages/transport-http/src/index.ts | 70 +- packages/transport-http/src/logic.test.ts | 800 +- packages/transport-http/src/logic.ts | 408 +- packages/transport-http/src/seam.ts | 32 +- packages/transport-http/src/server.bun.test.ts | 504 +- packages/transport-http/tsconfig.json | 26 +- packages/transport-ws/package.json | 26 +- packages/transport-ws/src/extension.ts | 698 +- packages/transport-ws/src/index.ts | 14 +- packages/transport-ws/src/manifest.ts | 18 +- packages/transport-ws/src/router.test.ts | 1296 +-- packages/transport-ws/src/router.ts | 382 +- packages/transport-ws/src/server.bun.test.ts | 2268 +++--- packages/transport-ws/tsconfig.json | 20 +- packages/ui-contract/package.json | 12 +- packages/ui-contract/src/index.ts | 164 +- packages/ui-contract/tsconfig.json | 6 +- packages/wire/package.json | 12 +- packages/wire/src/index.test.ts | 84 +- packages/wire/src/index.ts | 594 +- packages/wire/tsconfig.json | 6 +- tsconfig.base.json | 42 +- tsconfig.json | 234 +- vitest.config.ts | 26 +- 374 files changed, 54955 insertions(+), 54955 deletions(-) (limited to 'packages/kernel/src') diff --git a/AGENTS.md b/AGENTS.md index f173a7e..b5ba0f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ the same methodology and consuming the backend's typed contracts (see ## Stack Bun + TypeScript (strict, project references via `tsc -b`). Biome for -lint/format (tabs, double quotes, semicolons, width 100). Vitest for tests. +lint/format (2-space indent, double quotes, semicolons, width 100). Vitest for tests. SQLite via `bun:sqlite`. AI SDK (`ai` + `@ai-sdk/*`) for providers. ## The non-negotiable architecture rules diff --git a/biome.json b/biome.json index 63b7a19..a72d9ce 100644 --- a/biome.json +++ b/biome.json @@ -1,8 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", - "assist": { "actions": { "source": { "organizeImports": "on" } } }, - "linter": { "enabled": true, "rules": { "recommended": true } }, - "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 100 }, - "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, - "files": { "includes": ["**", "!**/node_modules", "!**/dist", "!**/build"] } + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, + "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, + "files": { "includes": ["**", "!**/node_modules", "!**/dist", "!**/build"] } } diff --git a/package.json b/package.json index 85a6c4b..f5935d3 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,26 @@ { - "name": "dispatch", - "private": true, - "type": "module", - "workspaces": [ - "packages/*" - ], - "scripts": { - "check": "biome check .", - "check:fix": "biome check --write .", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc -b --pretty", - "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts packages/transport-http/src/server.bun.test.ts", - "test:all": "bun run test && bun run test:bun", - "dev": "bun packages/host-bin/src/main.ts", - "dev:all": "./bin/up", - "dispatch": "bun packages/cli/src/main.ts" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.15", - "@types/bun": "^1.3.14", - "typescript": "^5.7.0", - "vitest": "^3.0.0" - } + "name": "dispatch", + "private": true, + "type": "module", + "workspaces": [ + "packages/*" + ], + "scripts": { + "check": "biome check .", + "check:fix": "biome check --write .", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -b --pretty", + "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts packages/transport-http/src/server.bun.test.ts", + "test:all": "bun run test && bun run test:bun", + "dev": "bun packages/host-bin/src/main.ts", + "dev:all": "./bin/up", + "dispatch": "bun packages/cli/src/main.ts" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.15", + "@types/bun": "^1.3.14", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } } diff --git a/packages/auth-apikey/package.json b/packages/auth-apikey/package.json index 74ee65f..db6e7b6 100644 --- a/packages/auth-apikey/package.json +++ b/packages/auth-apikey/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/auth-apikey", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/auth-apikey", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/auth-apikey/src/extension.ts b/packages/auth-apikey/src/extension.ts index 05d6462..ee68b60 100644 --- a/packages/auth-apikey/src/extension.ts +++ b/packages/auth-apikey/src/extension.ts @@ -2,21 +2,21 @@ import type { AuthContract, Extension } from "@dispatch/kernel"; import { resolveApiKeyCredentials } from "./resolver.js"; export const apikeyAuth: AuthContract = { - id: "apikey", - resolve: async () => - resolveApiKeyCredentials(process.env as Readonly>), + id: "apikey", + resolve: async () => + resolveApiKeyCredentials(process.env as Readonly>), }; export const extension: Extension = { - manifest: { - id: "auth-apikey", - name: "API Key Auth", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - contributes: { auth: ["apikey"] }, - }, - activate(host) { - host.defineAuth(apikeyAuth); - }, + manifest: { + id: "auth-apikey", + name: "API Key Auth", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + contributes: { auth: ["apikey"] }, + }, + activate(host) { + host.defineAuth(apikeyAuth); + }, }; diff --git a/packages/auth-apikey/src/resolver.test.ts b/packages/auth-apikey/src/resolver.test.ts index d6c1f85..bef734d 100644 --- a/packages/auth-apikey/src/resolver.test.ts +++ b/packages/auth-apikey/src/resolver.test.ts @@ -2,36 +2,36 @@ import { describe, expect, it } from "vitest"; import { resolveApiKeyCredentials } from "./resolver.js"; describe("resolveApiKeyCredentials", () => { - it("resolves key with default baseURL from minimal env", () => { - const env = { DISPATCH_API_KEY: "sk-test-123" }; - const result = resolveApiKeyCredentials(env); - expect(result).toEqual({ - type: "api-key", - apiKey: "sk-test-123", - baseURL: "https://opencode.ai/zen/go/v1", - }); - }); + it("resolves key with default baseURL from minimal env", () => { + const env = { DISPATCH_API_KEY: "sk-test-123" }; + const result = resolveApiKeyCredentials(env); + expect(result).toEqual({ + type: "api-key", + apiKey: "sk-test-123", + baseURL: "https://opencode.ai/zen/go/v1", + }); + }); - it("honors an explicit DISPATCH_BASE_URL", () => { - const env = { - DISPATCH_API_KEY: "sk-test-456", - DISPATCH_BASE_URL: "https://custom.example.com/v2", - }; - const result = resolveApiKeyCredentials(env); - expect(result).toEqual({ - type: "api-key", - apiKey: "sk-test-456", - baseURL: "https://custom.example.com/v2", - }); - }); + it("honors an explicit DISPATCH_BASE_URL", () => { + const env = { + DISPATCH_API_KEY: "sk-test-456", + DISPATCH_BASE_URL: "https://custom.example.com/v2", + }; + const result = resolveApiKeyCredentials(env); + expect(result).toEqual({ + type: "api-key", + apiKey: "sk-test-456", + baseURL: "https://custom.example.com/v2", + }); + }); - it("throws a clear error when DISPATCH_API_KEY is absent", () => { - const env = {}; - expect(() => resolveApiKeyCredentials(env)).toThrow("DISPATCH_API_KEY"); - }); + it("throws a clear error when DISPATCH_API_KEY is absent", () => { + const env = {}; + expect(() => resolveApiKeyCredentials(env)).toThrow("DISPATCH_API_KEY"); + }); - it("throws when DISPATCH_API_KEY is empty string", () => { - const env = { DISPATCH_API_KEY: "" }; - expect(() => resolveApiKeyCredentials(env)).toThrow("DISPATCH_API_KEY"); - }); + it("throws when DISPATCH_API_KEY is empty string", () => { + const env = { DISPATCH_API_KEY: "" }; + expect(() => resolveApiKeyCredentials(env)).toThrow("DISPATCH_API_KEY"); + }); }); diff --git a/packages/auth-apikey/src/resolver.ts b/packages/auth-apikey/src/resolver.ts index 2e63a1c..a90c45e 100644 --- a/packages/auth-apikey/src/resolver.ts +++ b/packages/auth-apikey/src/resolver.ts @@ -3,12 +3,12 @@ import type { ApiKeyCredentials } from "@dispatch/kernel"; const DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1"; export function resolveApiKeyCredentials( - env: Readonly>, + env: Readonly>, ): ApiKeyCredentials { - const apiKey = env.DISPATCH_API_KEY; - if (!apiKey) { - throw new Error("DISPATCH_API_KEY is not set. Set it in your environment or .env file."); - } - const baseURL = env.DISPATCH_BASE_URL ?? DEFAULT_BASE_URL; - return { type: "api-key", apiKey, baseURL }; + const apiKey = env.DISPATCH_API_KEY; + if (!apiKey) { + throw new Error("DISPATCH_API_KEY is not set. Set it in your environment or .env file."); + } + const baseURL = env.DISPATCH_BASE_URL ?? DEFAULT_BASE_URL; + return { type: "api-key", apiKey, baseURL }; } diff --git a/packages/auth-apikey/tsconfig.json b/packages/auth-apikey/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/auth-apikey/tsconfig.json +++ b/packages/auth-apikey/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/cache-warming/package.json b/packages/cache-warming/package.json index eaf3fda..af7cd2f 100644 --- a/packages/cache-warming/package.json +++ b/packages/cache-warming/package.json @@ -1,14 +1,14 @@ { - "name": "@dispatch/cache-warming", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/ui-contract": "workspace:*" - } + "name": "@dispatch/cache-warming", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } } diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts index 920177f..99b9e1e 100644 --- a/packages/cache-warming/src/extension.ts +++ b/packages/cache-warming/src/extension.ts @@ -1,154 +1,154 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { - cacheWarmHandle, - conversationClosed, - turnSettled, - turnStarted, - warmCompleted, + cacheWarmHandle, + conversationClosed, + turnSettled, + turnStarted, + warmCompleted, } from "@dispatch/session-orchestrator"; import type { SurfaceContext, SurfaceProvider } from "@dispatch/surface-registry"; import { surfaceRegistryHandle } from "@dispatch/surface-registry"; import type { SurfaceSpec } from "@dispatch/ui-contract"; import { - buildConversationSpec, - buildDefaultSpec, - parseIntervalPayload, - secondsToMs, + buildConversationSpec, + buildDefaultSpec, + parseIntervalPayload, + secondsToMs, } from "./pure.js"; import { createCacheWarmer } from "./warmer.js"; export const manifest: Manifest = { - id: "cache-warming", - name: "Cache Warming", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["session-orchestrator", "surface-registry"], - capabilities: {}, - contributes: { - services: ["cache-warming/surface"], - }, + id: "cache-warming", + name: "Cache Warming", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["session-orchestrator", "surface-registry"], + capabilities: {}, + contributes: { + services: ["cache-warming/surface"], + }, }; export function activate(host: HostAPI): void { - const warmService = host.getService(cacheWarmHandle); - const registry = host.getService(surfaceRegistryHandle); - const storage = host.storage("cache-warming"); - - const subscribers = new Set<() => void>(); - - const timeoutMap = new Map>(); - let nextTimerId = 1; - - const warmer = createCacheWarmer({ - warm: warmService.warm, - storage, - logger: host.logger, - timers: { - setTimer(fn, ms) { - const id = nextTimerId++; - timeoutMap.set(id, setTimeout(fn, ms)); - return id; - }, - clearTimer(id) { - const timeout = timeoutMap.get(id); - if (timeout !== undefined) { - clearTimeout(timeout); - timeoutMap.delete(id); - } - }, - }, - now: () => Date.now(), - onSurfaceChange: () => { - for (const notify of subscribers) { - notify(); - } - }, - }); - - host.on(turnStarted, (payload) => { - warmer.onTurnStarted(payload.conversationId); - }); - - host.on(turnSettled, (payload) => { - warmer.onTurnSettled(payload.conversationId, { - ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}), - ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), - }); - }); - - host.on(warmCompleted, (payload) => { - warmer.onWarmCompleted(payload); - }); - - host.on(conversationClosed, (payload) => { - // Sync part (cancel + disable) runs before the first await inside; - // only the settings persist is deferred. - void warmer.onConversationClosed(payload.conversationId); - }); - - function getSpec(context?: SurfaceContext): SurfaceSpec { - const convId = context?.conversationId; - if (convId === undefined) { - return buildDefaultSpec(); - } - const state = warmer.getState(convId); - return buildConversationSpec( - state.enabled, - state.intervalMs, - state.lastPct, - state.lastExpectedPct, - state.nextWarmAt, - state.lastWarmAt, - ); - } - - async function invoke( - actionId: string, - payload?: unknown, - context?: SurfaceContext, - ): Promise { - const convId = context?.conversationId; - if (convId === undefined) return; - - if (actionId === "cache-warming/toggle") { - const current = warmer.getState(convId); - await warmer.setEnabled(convId, !current.enabled); - } - - if (actionId === "cache-warming/set-interval") { - const seconds = parseIntervalPayload(payload); - if (seconds === null) return; - const ms = secondsToMs(seconds); - if (ms === null) return; - await warmer.setIntervalMs(convId, ms); - } - } - - const provider: SurfaceProvider = { - catalogEntry: { - id: "cache-warming", - region: "side", - title: "Cache Warming", - scope: "conversation", - }, - getSpec, - invoke, - subscribe(onChange) { - subscribers.add(onChange); - return () => { - subscribers.delete(onChange); - }; - }, - }; - - registry.register(provider); - - host.logger.info("cache-warming: registered"); + const warmService = host.getService(cacheWarmHandle); + const registry = host.getService(surfaceRegistryHandle); + const storage = host.storage("cache-warming"); + + const subscribers = new Set<() => void>(); + + const timeoutMap = new Map>(); + let nextTimerId = 1; + + const warmer = createCacheWarmer({ + warm: warmService.warm, + storage, + logger: host.logger, + timers: { + setTimer(fn, ms) { + const id = nextTimerId++; + timeoutMap.set(id, setTimeout(fn, ms)); + return id; + }, + clearTimer(id) { + const timeout = timeoutMap.get(id); + if (timeout !== undefined) { + clearTimeout(timeout); + timeoutMap.delete(id); + } + }, + }, + now: () => Date.now(), + onSurfaceChange: () => { + for (const notify of subscribers) { + notify(); + } + }, + }); + + host.on(turnStarted, (payload) => { + warmer.onTurnStarted(payload.conversationId); + }); + + host.on(turnSettled, (payload) => { + warmer.onTurnSettled(payload.conversationId, { + ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}), + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }); + }); + + host.on(warmCompleted, (payload) => { + warmer.onWarmCompleted(payload); + }); + + host.on(conversationClosed, (payload) => { + // Sync part (cancel + disable) runs before the first await inside; + // only the settings persist is deferred. + void warmer.onConversationClosed(payload.conversationId); + }); + + function getSpec(context?: SurfaceContext): SurfaceSpec { + const convId = context?.conversationId; + if (convId === undefined) { + return buildDefaultSpec(); + } + const state = warmer.getState(convId); + return buildConversationSpec( + state.enabled, + state.intervalMs, + state.lastPct, + state.lastExpectedPct, + state.nextWarmAt, + state.lastWarmAt, + ); + } + + async function invoke( + actionId: string, + payload?: unknown, + context?: SurfaceContext, + ): Promise { + const convId = context?.conversationId; + if (convId === undefined) return; + + if (actionId === "cache-warming/toggle") { + const current = warmer.getState(convId); + await warmer.setEnabled(convId, !current.enabled); + } + + if (actionId === "cache-warming/set-interval") { + const seconds = parseIntervalPayload(payload); + if (seconds === null) return; + const ms = secondsToMs(seconds); + if (ms === null) return; + await warmer.setIntervalMs(convId, ms); + } + } + + const provider: SurfaceProvider = { + catalogEntry: { + id: "cache-warming", + region: "side", + title: "Cache Warming", + scope: "conversation", + }, + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; + }, + }; + + registry.register(provider); + + host.logger.info("cache-warming: registered"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts index 88cab3b..a6c3e99 100644 --- a/packages/cache-warming/src/index.ts +++ b/packages/cache-warming/src/index.ts @@ -1,25 +1,25 @@ export { extension, manifest } from "./extension.js"; export { - buildConversationSpec, - buildDefaultSpec, - type ConversationSettings, - type ConversationState, - computeCachePct, - computeExpectedCacheRate, - DEFAULT_INTERVAL_MS, - isTokenCurrent, - MIN_INTERVAL_MS, - msToSeconds, - parseIntervalPayload, - parseSettings, - secondsToMs, - serializeSettings, - settingsKey, - shouldWarm, + buildConversationSpec, + buildDefaultSpec, + type ConversationSettings, + type ConversationState, + computeCachePct, + computeExpectedCacheRate, + DEFAULT_INTERVAL_MS, + isTokenCurrent, + MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, + parseSettings, + secondsToMs, + serializeSettings, + settingsKey, + shouldWarm, } from "./pure.js"; export { - type CacheWarmer, - type CacheWarmerDeps, - createCacheWarmer, - type TimerDeps, + type CacheWarmer, + type CacheWarmerDeps, + createCacheWarmer, + type TimerDeps, } from "./warmer.js"; diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts index 357fcb9..02adaf7 100644 --- a/packages/cache-warming/src/pure.test.ts +++ b/packages/cache-warming/src/pure.test.ts @@ -1,297 +1,297 @@ import { describe, expect, it } from "vitest"; import type { ConversationState } from "./pure.js"; import { - buildConversationSpec, - buildDefaultSpec, - computeCachePct, - computeExpectedCacheRate, - isTokenCurrent, - MIN_INTERVAL_MS, - msToSeconds, - parseIntervalPayload, - parseSettings, - secondsToMs, - serializeSettings, - shouldWarm, + buildConversationSpec, + buildDefaultSpec, + computeCachePct, + computeExpectedCacheRate, + isTokenCurrent, + MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, + parseSettings, + secondsToMs, + serializeSettings, + shouldWarm, } from "./pure.js"; describe("computeCachePct", () => { - it("cacheRead/input rounded and clamped to 0..100", () => { - expect(computeCachePct(1000, 800)).toBe(80); - expect(computeCachePct(1000, 1200)).toBe(100); - expect(computeCachePct(1000, -100)).toBe(0); - expect(computeCachePct(1000, 0)).toBe(0); - expect(computeCachePct(1000, 333)).toBe(33); - }); + it("cacheRead/input rounded and clamped to 0..100", () => { + expect(computeCachePct(1000, 800)).toBe(80); + expect(computeCachePct(1000, 1200)).toBe(100); + expect(computeCachePct(1000, -100)).toBe(0); + expect(computeCachePct(1000, 0)).toBe(0); + expect(computeCachePct(1000, 333)).toBe(33); + }); - it("zero input tokens → 0", () => { - expect(computeCachePct(0, 500)).toBe(0); - expect(computeCachePct(-1, 500)).toBe(0); - }); + it("zero input tokens → 0", () => { + expect(computeCachePct(0, 500)).toBe(0); + expect(computeCachePct(-1, 500)).toBe(0); + }); }); describe("computeExpectedCacheRate", () => { - it("cacheRead/(cacheRead+cacheWrite) rounded", () => { - expect(computeExpectedCacheRate(800, 200)).toBe(80); - expect(computeExpectedCacheRate(500, 500)).toBe(50); - expect(computeExpectedCacheRate(1000, 0)).toBe(100); - expect(computeExpectedCacheRate(0, 1000)).toBe(0); - expect(computeExpectedCacheRate(333, 667)).toBe(33); - }); + it("cacheRead/(cacheRead+cacheWrite) rounded", () => { + expect(computeExpectedCacheRate(800, 200)).toBe(80); + expect(computeExpectedCacheRate(500, 500)).toBe(50); + expect(computeExpectedCacheRate(1000, 0)).toBe(100); + expect(computeExpectedCacheRate(0, 1000)).toBe(0); + expect(computeExpectedCacheRate(333, 667)).toBe(33); + }); - it("0 when cacheRead+cacheWrite is 0", () => { - expect(computeExpectedCacheRate(0, 0)).toBe(0); - }); + it("0 when cacheRead+cacheWrite is 0", () => { + expect(computeExpectedCacheRate(0, 0)).toBe(0); + }); }); describe("shouldWarm", () => { - it("returns true when enabled, idle, and token matches", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(true); - }); + it("returns true when enabled, idle, and token matches", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(true); + }); - it("returns false when disabled", () => { - const state: ConversationState = { - enabled: false, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(false); - }); + it("returns false when disabled", () => { + const state: ConversationState = { + enabled: false, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(false); + }); - it("returns false when active", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: true, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(false); - }); + it("returns false when active", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: true, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(false); + }); - it("returns false when token is superseded", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 6)).toBe(false); - }); + it("returns false when token is superseded", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 6)).toBe(false); + }); }); describe("isTokenCurrent", () => { - it("returns true when tokens match", () => { - expect(isTokenCurrent(5, 5)).toBe(true); - }); + it("returns true when tokens match", () => { + expect(isTokenCurrent(5, 5)).toBe(true); + }); - it("returns false when tokens differ", () => { - expect(isTokenCurrent(5, 6)).toBe(false); - }); + it("returns false when tokens differ", () => { + expect(isTokenCurrent(5, 6)).toBe(false); + }); }); describe("parseSettings/serializeSettings round-trip", () => { - it("round-trips enabled + intervalMs", () => { - const original = { enabled: false, intervalMs: 120_000 }; - const serialized = serializeSettings(original); - const parsed = parseSettings(serialized); - expect(parsed).toEqual(original); - }); + it("round-trips enabled + intervalMs", () => { + const original = { enabled: false, intervalMs: 120_000 }; + const serialized = serializeSettings(original); + const parsed = parseSettings(serialized); + expect(parsed).toEqual(original); + }); - it("returns defaults for null input (warming OFF by default — CR-4a)", () => { - const parsed = parseSettings(null); - expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); - }); + it("returns defaults for null input (warming OFF by default — CR-4a)", () => { + const parsed = parseSettings(null); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); + }); - it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => { - const parsed = parseSettings("not-json{{{"); - expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); - }); + it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => { + const parsed = parseSettings("not-json{{{"); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); + }); - it("clamps non-positive interval to MIN_INTERVAL_MS", () => { - const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 })); - expect(parsed.intervalMs).toBe(1000); - }); + it("clamps non-positive interval to MIN_INTERVAL_MS", () => { + const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 })); + expect(parsed.intervalMs).toBe(1000); + }); - it("uses default for NaN interval", () => { - const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN })); - expect(parsed.intervalMs).toBe(240_000); - }); + it("uses default for NaN interval", () => { + const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN })); + expect(parsed.intervalMs).toBe(240_000); + }); }); describe("msToSeconds", () => { - it("converts ms to seconds, rounded", () => { - expect(msToSeconds(240_000)).toBe(240); - expect(msToSeconds(1500)).toBe(2); - expect(msToSeconds(1000)).toBe(1); - expect(msToSeconds(0)).toBe(0); - }); + it("converts ms to seconds, rounded", () => { + expect(msToSeconds(240_000)).toBe(240); + expect(msToSeconds(1500)).toBe(2); + expect(msToSeconds(1000)).toBe(1); + expect(msToSeconds(0)).toBe(0); + }); }); describe("secondsToMs", () => { - it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => { - expect(secondsToMs(240)).toBe(240_000); - expect(secondsToMs(1)).toBe(1000); - expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS); - }); + it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => { + expect(secondsToMs(240)).toBe(240_000); + expect(secondsToMs(1)).toBe(1000); + expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS); + }); - it("returns null for NaN / non-positive", () => { - expect(secondsToMs(Number.NaN)).toBeNull(); - expect(secondsToMs(0)).toBeNull(); - expect(secondsToMs(-5)).toBeNull(); - expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull(); - }); + it("returns null for NaN / non-positive", () => { + expect(secondsToMs(Number.NaN)).toBeNull(); + expect(secondsToMs(0)).toBeNull(); + expect(secondsToMs(-5)).toBeNull(); + expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull(); + }); }); describe("parseIntervalPayload", () => { - it("accepts a bare positive number", () => { - expect(parseIntervalPayload(30)).toBe(30); - expect(parseIntervalPayload(1)).toBe(1); - }); + it("accepts a bare positive number", () => { + expect(parseIntervalPayload(30)).toBe(30); + expect(parseIntervalPayload(1)).toBe(1); + }); - it("accepts { value: number }", () => { - expect(parseIntervalPayload({ value: 30 })).toBe(30); - expect(parseIntervalPayload({ value: 1 })).toBe(1); - }); + it("accepts { value: number }", () => { + expect(parseIntervalPayload({ value: 30 })).toBe(30); + expect(parseIntervalPayload({ value: 1 })).toBe(1); + }); - it("returns null for NaN / non-positive / wrong shape", () => { - expect(parseIntervalPayload(Number.NaN)).toBeNull(); - expect(parseIntervalPayload(0)).toBeNull(); - expect(parseIntervalPayload(-5)).toBeNull(); - expect(parseIntervalPayload("30")).toBeNull(); - expect(parseIntervalPayload({ value: "30" })).toBeNull(); - expect(parseIntervalPayload({})).toBeNull(); - expect(parseIntervalPayload(null)).toBeNull(); - expect(parseIntervalPayload(undefined)).toBeNull(); - }); + it("returns null for NaN / non-positive / wrong shape", () => { + expect(parseIntervalPayload(Number.NaN)).toBeNull(); + expect(parseIntervalPayload(0)).toBeNull(); + expect(parseIntervalPayload(-5)).toBeNull(); + expect(parseIntervalPayload("30")).toBeNull(); + expect(parseIntervalPayload({ value: "30" })).toBeNull(); + expect(parseIntervalPayload({})).toBeNull(); + expect(parseIntervalPayload(null)).toBeNull(); + expect(parseIntervalPayload(undefined)).toBeNull(); + }); }); describe("buildConversationSpec", () => { - it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => { - const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500); - expect(spec.id).toBe("cache-warming"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Cache Warming"); - expect(spec.fields).toHaveLength(5); + it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => { + const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500); + expect(spec.id).toBe("cache-warming"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Cache Warming"); + expect(spec.fields).toHaveLength(5); - const toggle = spec.fields[0]; - expect(toggle).toEqual({ - kind: "toggle", - label: "Enabled", - value: true, - action: { actionId: "cache-warming/toggle" }, - }); + const toggle = spec.fields[0]; + expect(toggle).toEqual({ + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }); - const number = spec.fields[1]; - expect(number).toEqual({ - kind: "number", - label: "Refresh Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }); + const number = spec.fields[1]; + expect(number).toEqual({ + kind: "number", + label: "Refresh Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }); - const stat = spec.fields[2]; - expect(stat).toEqual({ - kind: "stat", - label: "Last Cache %", - value: "80%", - }); + const stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "80%", + }); - const retention = spec.fields[3]; - expect(retention).toEqual({ - kind: "stat", - label: "Cache retention", - value: "95%", - }); + const retention = spec.fields[3]; + expect(retention).toEqual({ + kind: "stat", + label: "Cache retention", + value: "95%", + }); - const timer = spec.fields[4]; - expect(timer).toEqual({ - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: 1000, lastWarmAt: 500 }, - }); - }); + const timer = spec.fields[4]; + expect(timer).toEqual({ + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1000, lastWarmAt: 500 }, + }); + }); - it("shows — when lastPct and lastExpectedPct are null", () => { - const spec = buildConversationSpec(true, 240_000, null, null, null, null); - const stat = spec.fields[2]; - expect(stat).toEqual({ - kind: "stat", - label: "Last Cache %", - value: "—", - }); - const retention = spec.fields[3]; - expect(retention).toEqual({ - kind: "stat", - label: "Cache retention", - value: "—", - }); - const timer = spec.fields[4]; - expect(timer).toEqual({ - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: null, lastWarmAt: null }, - }); - }); + it("shows — when lastPct and lastExpectedPct are null", () => { + const spec = buildConversationSpec(true, 240_000, null, null, null, null); + const stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "—", + }); + const retention = spec.fields[3]; + expect(retention).toEqual({ + kind: "stat", + label: "Cache retention", + value: "—", + }); + const timer = spec.fields[4]; + expect(timer).toEqual({ + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: null, lastWarmAt: null }, + }); + }); - it("reflects disabled state", () => { - const spec = buildConversationSpec(false, 120_000, 50, 75, null, null); - const toggle = spec.fields[0]; - expect(toggle).toEqual({ - kind: "toggle", - label: "Enabled", - value: false, - action: { actionId: "cache-warming/toggle" }, - }); - const number = spec.fields[1]; - expect(number).toEqual({ - kind: "number", - label: "Refresh Interval", - value: 120, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }); - }); + it("reflects disabled state", () => { + const spec = buildConversationSpec(false, 120_000, 50, 75, null, null); + const toggle = spec.fields[0]; + expect(toggle).toEqual({ + kind: "toggle", + label: "Enabled", + value: false, + action: { actionId: "cache-warming/toggle" }, + }); + const number = spec.fields[1]; + expect(number).toEqual({ + kind: "number", + label: "Refresh Interval", + value: 120, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }); + }); }); describe("buildDefaultSpec", () => { - it("returns a default spec with no conversationId", () => { - const spec = buildDefaultSpec(); - expect(spec.id).toBe("cache-warming"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Cache Warming"); - expect(spec.fields).toHaveLength(1); - expect(spec.fields[0]).toEqual({ - kind: "stat", - label: "Status", - value: "No conversation focused", - }); - }); + it("returns a default spec with no conversationId", () => { + const spec = buildDefaultSpec(); + expect(spec.id).toBe("cache-warming"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Cache Warming"); + expect(spec.fields).toHaveLength(1); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Status", + value: "No conversation focused", + }); + }); }); diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts index 2a2fd1b..16b3cb1 100644 --- a/packages/cache-warming/src/pure.ts +++ b/packages/cache-warming/src/pure.ts @@ -4,35 +4,35 @@ */ import type { - CustomField, - NumberField, - StatField, - SurfaceSpec, - ToggleField, + CustomField, + NumberField, + StatField, + SurfaceSpec, + ToggleField, } from "@dispatch/ui-contract"; // --- Types --- /** Persisted per-conversation settings (storage-facing). */ export interface ConversationSettings { - readonly enabled: boolean; - readonly intervalMs: number; + readonly enabled: boolean; + readonly intervalMs: number; } /** Full per-conversation runtime state (in-memory, not persisted). */ export interface ConversationState extends ConversationSettings { - readonly active: boolean; - readonly lastPct: number | null; - readonly lastExpectedPct: number | null; - readonly lastWarmAt: number | null; - readonly nextWarmAt: number | null; - readonly token: number; + readonly active: boolean; + readonly lastPct: number | null; + readonly lastExpectedPct: number | null; + readonly lastWarmAt: number | null; + readonly nextWarmAt: number | null; + readonly token: number; } /** Context stored per-conversation from the latest lifecycle event. */ export interface ConversationContext { - readonly cwd?: string; - readonly modelName?: string; + readonly cwd?: string; + readonly modelName?: string; } export const DEFAULT_INTERVAL_MS = 240_000; @@ -45,10 +45,10 @@ export const MIN_INTERVAL_MS = 1000; * Returns an integer in [0, 100]. inputTokens ≤ 0 → 0. */ export function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - const ratio = cacheReadTokens / inputTokens; - const clamped = Math.max(0, Math.min(1, ratio)); - return Math.round(clamped * 100); + if (inputTokens <= 0) return 0; + const ratio = cacheReadTokens / inputTokens; + const clamped = Math.max(0, Math.min(1, ratio)); + return Math.round(clamped * 100); } /** @@ -58,12 +58,12 @@ export function computeCachePct(inputTokens: number, cacheReadTokens: number): n * Returns an integer in [0, 100]. cacheRead + cacheWrite ≤ 0 → 0. */ export function computeExpectedCacheRate( - cacheReadTokens: number, - cacheWriteTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, ): number { - const total = cacheReadTokens + cacheWriteTokens; - if (total <= 0) return 0; - return Math.round((cacheReadTokens / total) * 100); + const total = cacheReadTokens + cacheWriteTokens; + if (total <= 0) return 0; + return Math.round((cacheReadTokens / total) * 100); } /** @@ -71,14 +71,14 @@ export function computeExpectedCacheRate( * Requires: enabled, idle (not active), and the token is current (not superseded). */ export function shouldWarm(state: ConversationState, currentToken: number): boolean { - return state.enabled && !state.active && state.token === currentToken; + return state.enabled && !state.active && state.token === currentToken; } /** * Check whether a token is still current (not superseded by a newer cancel/fire). */ export function isTokenCurrent(current: number, expected: number): boolean { - return current === expected; + return current === expected; } const SETTINGS_KEY = "settings"; @@ -91,43 +91,43 @@ const SETTINGS_KEY = "settings"; * until the user explicitly opts in via the toggle. */ export function parseSettings(raw: string | null): ConversationSettings { - if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) { - return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; - } - const obj = parsed as Record; - const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false; - const rawInterval = obj.intervalMs; - let intervalMs = DEFAULT_INTERVAL_MS; - if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) { - intervalMs = - rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval)); - } - return { enabled, intervalMs }; - } catch { - return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; - } + if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) { + return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; + } + const obj = parsed as Record; + const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false; + const rawInterval = obj.intervalMs; + let intervalMs = DEFAULT_INTERVAL_MS; + if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) { + intervalMs = + rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval)); + } + return { enabled, intervalMs }; + } catch { + return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; + } } /** * Serialize settings for storage. */ export function serializeSettings(settings: ConversationSettings): string { - return JSON.stringify(settings); + return JSON.stringify(settings); } /** The storage key for a conversation's settings. */ export function settingsKey(conversationId: string): string { - return `${SETTINGS_KEY}:${conversationId}`; + return `${SETTINGS_KEY}:${conversationId}`; } // --- Surface spec builders (pure) --- /** Convert intervalMs to display seconds (rounded). */ export function msToSeconds(intervalMs: number): number { - return Math.round(intervalMs / 1000); + return Math.round(intervalMs / 1000); } /** @@ -135,8 +135,8 @@ export function msToSeconds(intervalMs: number): number { * Returns null for NaN / non-positive (caller should ignore). */ export function secondsToMs(seconds: number): number | null { - if (!Number.isFinite(seconds) || seconds <= 0) return null; - return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000)); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000)); } /** @@ -144,51 +144,51 @@ export function secondsToMs(seconds: number): number | null { * Pure — no I/O. */ export function buildConversationSpec( - enabled: boolean, - intervalMs: number, - lastPct: number | null, - lastExpectedPct: number | null, - nextWarmAt: number | null, - lastWarmAt: number | null, + enabled: boolean, + intervalMs: number, + lastPct: number | null, + lastExpectedPct: number | null, + nextWarmAt: number | null, + lastWarmAt: number | null, ): SurfaceSpec { - const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; - const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`; - const toggle: ToggleField = { - kind: "toggle", - label: "Enabled", - value: enabled, - action: { actionId: "cache-warming/toggle" }, - }; - const interval: NumberField = { - kind: "number", - label: "Refresh Interval", - value: msToSeconds(intervalMs), - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }; - const stat: StatField = { - kind: "stat", - label: "Last Cache %", - value: pctDisplay, - }; - const retentionStat: StatField = { - kind: "stat", - label: "Cache retention", - value: retentionDisplay, - }; - const timer: CustomField = { - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt, lastWarmAt }, - }; - return { - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields: [toggle, interval, stat, retentionStat, timer], - }; + const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; + const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`; + const toggle: ToggleField = { + kind: "toggle", + label: "Enabled", + value: enabled, + action: { actionId: "cache-warming/toggle" }, + }; + const interval: NumberField = { + kind: "number", + label: "Refresh Interval", + value: msToSeconds(intervalMs), + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }; + const stat: StatField = { + kind: "stat", + label: "Last Cache %", + value: pctDisplay, + }; + const retentionStat: StatField = { + kind: "stat", + label: "Cache retention", + value: retentionDisplay, + }; + const timer: CustomField = { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt, lastWarmAt }, + }; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [toggle, interval, stat, retentionStat, timer], + }; } /** @@ -196,18 +196,18 @@ export function buildConversationSpec( * Pure — no I/O. */ export function buildDefaultSpec(): SurfaceSpec { - return { - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields: [ - { - kind: "stat", - label: "Status", - value: "No conversation focused", - }, - ], - }; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [ + { + kind: "stat", + label: "Status", + value: "No conversation focused", + }, + ], + }; } /** @@ -216,17 +216,17 @@ export function buildDefaultSpec(): SurfaceSpec { * null if the payload is invalid (NaN / non-positive / wrong shape). */ export function parseIntervalPayload(payload: unknown): number | null { - if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) { - return payload; - } - if ( - typeof payload === "object" && - payload !== null && - "value" in payload && - typeof (payload as Record).value === "number" - ) { - const v = (payload as Record).value as number; - if (Number.isFinite(v) && v > 0) return v; - } - return null; + if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) { + return payload; + } + if ( + typeof payload === "object" && + payload !== null && + "value" in payload && + typeof (payload as Record).value === "number" + ) { + const v = (payload as Record).value as number; + if (Number.isFinite(v) && v > 0) return v; + } + return null; } diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts index 98fa634..26445a2 100644 --- a/packages/cache-warming/src/warmer.test.ts +++ b/packages/cache-warming/src/warmer.test.ts @@ -5,559 +5,559 @@ import { MIN_INTERVAL_MS } from "./pure.js"; import { createCacheWarmer, type TimerDeps } from "./warmer.js"; function memStorage(): StorageNamespace { - const map = new Map(); - return { - get: async (k) => map.get(k) ?? null, - set: async (k, v) => { - map.set(k, v); - }, - delete: async (k) => { - map.delete(k); - }, - has: async (k) => map.has(k), - keys: async (prefix) => - [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const map = new Map(); + return { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + delete: async (k) => { + map.delete(k); + }, + has: async (k) => map.has(k), + keys: async (prefix) => + [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } function makeSpan(): Span { - const span: Span = { - id: "span", - log: makeLogger(), - setAttributes: () => {}, - addLink: () => {}, - child: () => makeSpan(), - end: () => {}, - }; - return span; + const span: Span = { + id: "span", + log: makeLogger(), + setAttributes: () => {}, + addLink: () => {}, + child: () => makeSpan(), + end: () => {}, + }; + return span; } function makeLogger(): Logger { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => makeLogger(), - span: () => makeSpan(), - }; + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => makeLogger(), + span: () => makeSpan(), + }; } function fakeTimers(): TimerDeps & { flush: () => void } { - let nextId = 1; - const pending = new Map void>(); - return { - setTimer(fn, _ms) { - const id = nextId++; - pending.set(id, fn); - return id; - }, - clearTimer(id) { - pending.delete(id); - }, - flush() { - const fns = [...pending.values()]; - pending.clear(); - for (const fn of fns) fn(); - }, - }; + let nextId = 1; + const pending = new Map void>(); + return { + setTimer(fn, _ms) { + const id = nextId++; + pending.set(id, fn); + return id; + }, + clearTimer(id) { + pending.delete(id); + }, + flush() { + const fns = [...pending.values()]; + pending.clear(); + for (const fn of fns) fn(); + }, + }; } const WARM_RESULT: WarmResult = { - inputTokens: 1000, - outputTokens: 10, - cacheReadTokens: 800, - cacheWriteTokens: 0, + inputTokens: 1000, + outputTokens: 10, + cacheReadTokens: 800, + cacheWriteTokens: 0, }; function makeDeps( - overrides: Partial<{ - warm: (conversationId: string) => Promise; - onSurfaceChange: () => void; - now: () => number; - }> = {}, + overrides: Partial<{ + warm: (conversationId: string) => Promise; + onSurfaceChange: () => void; + now: () => number; + }> = {}, ) { - const timers = fakeTimers(); - let nowMs = 1000; - return { - timers, - warm: overrides.warm ?? (async () => WARM_RESULT), - storage: memStorage(), - logger: makeLogger(), - now: overrides.now ?? (() => nowMs), - onSurfaceChange: overrides.onSurfaceChange ?? (() => {}), - setNow(ms: number) { - nowMs = ms; - }, - }; + const timers = fakeTimers(); + let nowMs = 1000; + return { + timers, + warm: overrides.warm ?? (async () => WARM_RESULT), + storage: memStorage(), + logger: makeLogger(), + now: overrides.now ?? (() => nowMs), + onSurfaceChange: overrides.onSurfaceChange ?? (() => {}), + setNow(ms: number) { + nowMs = ms; + }, + }; } describe("CacheWarmer", () => { - it("arms a timer on turnSettled and warms when it fires (enabled)", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - deps.timers.flush(); - - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toContain("conv-1"); - }); - - it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - expect(warmer.getState("conv-1").enabled).toBe(false); - warmer.onTurnSettled("conv-1", {}); - expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - deps.timers.flush(); - - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toHaveLength(0); - }); - - it("cancels the timer on turnStarted (no warm while generating)", () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onTurnStarted("conv-1"); - deps.timers.flush(); - - expect(warmCalls).toHaveLength(0); - }); - - it("disabled conversation does not warm", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", false); - warmer.onTurnSettled("conv-1", {}); - deps.timers.flush(); - - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toHaveLength(0); - }); - - it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - // Enable and settle to arm the timer - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - - // Set interval to 30 seconds (30000ms) - const settings = await warmer.setIntervalMs("conv-1", 30_000); - expect(settings.intervalMs).toBe(30_000); - - const state = warmer.getState("conv-1"); - expect(state.intervalMs).toBe(30_000); - - // Timer should still be armed — flush fires it - deps.timers.flush(); - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toContain("conv-1"); - }); - - it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - - // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000) - const settings = await warmer.setIntervalMs("conv-1", 500); - expect(settings.intervalMs).toBe(1000); - }); - - it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - - const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN); - expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS); - - const settings2 = await warmer.setIntervalMs("conv-1", -5000); - expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS); - - const settings3 = await warmer.setIntervalMs("conv-1", 0); - expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS); - }); - - it("setEnabled flips enabled for a conversation", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - // Default is DISABLED (opt-in per conversation) - expect(warmer.getState("conv-1").enabled).toBe(false); - - // Toggle on - await warmer.setEnabled("conv-1", true); - expect(warmer.getState("conv-1").enabled).toBe(true); - - // Toggle off - await warmer.setEnabled("conv-1", false); - expect(warmer.getState("conv-1").enabled).toBe(false); - }); - - it("re-enabling restores the PERSISTED interval into runtime state", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - await warmer.setIntervalMs("conv-1", 30_000); - await warmer.setEnabled("conv-1", false); - - // Fresh warmer over the same storage (simulates restart) - const warmer2 = createCacheWarmer(deps); - expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default - await warmer2.setEnabled("conv-1", true); - expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored - }); - - it("onSurfaceChange is called when settings change", async () => { - let changeCount = 0; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", false); - expect(changeCount).toBe(1); - - await warmer.setIntervalMs("conv-1", 30_000); - expect(changeCount).toBe(2); - }); - - it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => { - let changeCount = 0; - let nowMs = 5000; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => nowMs, - }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - const stateBefore = warmer.getState("conv-1"); - expect(stateBefore.lastPct).toBeNull(); - expect(stateBefore.lastExpectedPct).toBeNull(); - expect(stateBefore.lastWarmAt).toBeNull(); - - const countBefore = changeCount; - nowMs = 6000; - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(70); - expect(state.lastExpectedPct).toBe(70); - expect(state.lastWarmAt).toBe(6000); - expect(state.nextWarmAt).not.toBeNull(); - expect(changeCount).toBe(countBefore + 1); - }); - - it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => { - let nowMs = 5000; - const observed: (number | null)[] = []; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - deps.onSurfaceChange = () => { - observed.push(warmer.getState("conv-1").nextWarmAt); - }; - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - observed.length = 0; - - nowMs = 9000; - warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); - - // Exactly one notify, and AT NOTIFY TIME the state already carried the - // re-armed, future fire time (lastWarmAt + interval) — never the past one. - expect(observed).toEqual([9000 + 240_000]); - }); - - it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => { - let nowMs = 5000; - const observed: (number | null)[] = []; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - deps.onSurfaceChange = () => { - observed.push(warmer.getState("conv-1").nextWarmAt); - }; - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - await warmer.setEnabled("conv-1", false); - observed.length = 0; - - nowMs = 9000; - warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); - - // Not re-armed (disabled) — the notify must NOT carry a stale past value. - expect(observed).toEqual([null]); - }); - - it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => { - let nowMs = 5000; - const observed: (number | null)[] = []; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - deps.onSurfaceChange = () => { - observed.push(warmer.getState("conv-1").nextWarmAt); - }; - - await warmer.setEnabled("conv-1", true); - warmer.onTurnStarted("conv-1"); - observed.length = 0; - - nowMs = 12_000; - warmer.onTurnSettled("conv-1", {}); - - // At notify time the new schedule is already armed. - expect(observed).toEqual([12_000 + 240_000]); - }); - - it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => { - let changeCount = 0; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => 5000, - }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - warmer.onTurnStarted("conv-1"); - - const countBefore = changeCount; - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBeNull(); - expect(state.lastWarmAt).toBeNull(); - expect(state.nextWarmAt).toBeNull(); - expect(changeCount).toBe(countBefore); - }); - - it("nextWarmAt is set when armed and null when disabled or active", async () => { - let nowMs = 1000; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - - // Before any event — not armed - expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - - // After enable + turnSettled — armed with nextWarmAt - await warmer.setEnabled("conv-1", true); - nowMs = 2000; - warmer.onTurnSettled("conv-1", {}); - const stateArmed = warmer.getState("conv-1"); - expect(stateArmed.nextWarmAt).toBe(2000 + 240_000); - - // After turnStarted — cancelled (null) - warmer.onTurnStarted("conv-1"); - expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - - // After disabling — null - await warmer.setEnabled("conv-2", true); - warmer.onTurnSettled("conv-2", {}); - await warmer.setEnabled("conv-2", false); - expect(warmer.getState("conv-2").nextWarmAt).toBeNull(); - }); - - it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => { - let changeCount = 0; - let nowMs = 5000; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => nowMs, - }); - const warmer = createCacheWarmer(deps); - - // Enable + settle to arm the timer - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - const armed = warmer.getState("conv-1"); - expect(armed.nextWarmAt).toBe(5000 + 240_000); - - // Simulate a manual warm completing at t=8000 - const countBefore = changeCount; - nowMs = 8000; - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, - }); - - const after = warmer.getState("conv-1"); - expect(after.lastPct).toBe(90); - expect(after.lastExpectedPct).toBe(90); - expect(after.lastWarmAt).toBe(8000); - // Timer should be re-armed with new nextWarmAt - expect(after.nextWarmAt).toBe(8000 + 240_000); - expect(changeCount).toBe(countBefore + 1); - }); - - it("stores lastPct from the warmCompleted event", () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: WARM_RESULT, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(80); - }); - - it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(70); - expect(state.lastExpectedPct).toBe(70); - }); - - it("re-arms timer after warmCompleted", async () => { - let nowMs = 1000; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt; - - nowMs = 5000; - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: WARM_RESULT, - }); - - const after = warmer.getState("conv-1"); - expect(after.nextWarmAt).not.toBeNull(); - expect(after.nextWarmAt).not.toBe(firstNextWarmAt); - expect(after.nextWarmAt).toBe(5000 + 240_000); - }); - - it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => { - let changeCount = 0; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => 5000, - }); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnSettled("conv-1", {}); - expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull(); - - const countBefore = changeCount; - await warmer.onConversationClosed("conv-1"); - - const state = warmer.getState("conv-1"); - expect(state.enabled).toBe(false); - expect(state.nextWarmAt).toBeNull(); - expect(changeCount).toBe(countBefore + 1); - - // The pending timer is cancelled — flushing fires nothing. - deps.timers.flush(); - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toHaveLength(0); - - // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read. - const raw = await deps.storage.get("settings:conv-1"); - expect(raw).not.toBeNull(); - expect(JSON.parse(raw as string).enabled).toBe(false); - }); - - it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", true); - warmer.onTurnStarted("conv-1"); - - // Close while "generating" — do NOT await: the sync part must suffice. - const closed = warmer.onConversationClosed("conv-1"); - // The turn settles immediately after the close was issued. - warmer.onTurnSettled("conv-1", {}); - - expect(warmer.getState("conv-1").enabled).toBe(false); - expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - await closed; - }); - - it("the per-conversation spec includes a cache-retention stat", () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastExpectedPct).toBe(90); - }); + it("arms a timer on turnSettled and warms when it fires (enabled)", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toContain("conv-1"); + }); + + it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + expect(warmer.getState("conv-1").enabled).toBe(false); + warmer.onTurnSettled("conv-1", {}); + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + }); + + it("cancels the timer on turnStarted (no warm while generating)", () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onTurnStarted("conv-1"); + deps.timers.flush(); + + expect(warmCalls).toHaveLength(0); + }); + + it("disabled conversation does not warm", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", false); + warmer.onTurnSettled("conv-1", {}); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + }); + + it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + // Enable and settle to arm the timer + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + + // Set interval to 30 seconds (30000ms) + const settings = await warmer.setIntervalMs("conv-1", 30_000); + expect(settings.intervalMs).toBe(30_000); + + const state = warmer.getState("conv-1"); + expect(state.intervalMs).toBe(30_000); + + // Timer should still be armed — flush fires it + deps.timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toContain("conv-1"); + }); + + it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + + // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000) + const settings = await warmer.setIntervalMs("conv-1", 500); + expect(settings.intervalMs).toBe(1000); + }); + + it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + + const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN); + expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS); + + const settings2 = await warmer.setIntervalMs("conv-1", -5000); + expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS); + + const settings3 = await warmer.setIntervalMs("conv-1", 0); + expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS); + }); + + it("setEnabled flips enabled for a conversation", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + // Default is DISABLED (opt-in per conversation) + expect(warmer.getState("conv-1").enabled).toBe(false); + + // Toggle on + await warmer.setEnabled("conv-1", true); + expect(warmer.getState("conv-1").enabled).toBe(true); + + // Toggle off + await warmer.setEnabled("conv-1", false); + expect(warmer.getState("conv-1").enabled).toBe(false); + }); + + it("re-enabling restores the PERSISTED interval into runtime state", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + await warmer.setIntervalMs("conv-1", 30_000); + await warmer.setEnabled("conv-1", false); + + // Fresh warmer over the same storage (simulates restart) + const warmer2 = createCacheWarmer(deps); + expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default + await warmer2.setEnabled("conv-1", true); + expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored + }); + + it("onSurfaceChange is called when settings change", async () => { + let changeCount = 0; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", false); + expect(changeCount).toBe(1); + + await warmer.setIntervalMs("conv-1", 30_000); + expect(changeCount).toBe(2); + }); + + it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => { + let changeCount = 0; + let nowMs = 5000; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => nowMs, + }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + const stateBefore = warmer.getState("conv-1"); + expect(stateBefore.lastPct).toBeNull(); + expect(stateBefore.lastExpectedPct).toBeNull(); + expect(stateBefore.lastWarmAt).toBeNull(); + + const countBefore = changeCount; + nowMs = 6000; + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(70); + expect(state.lastExpectedPct).toBe(70); + expect(state.lastWarmAt).toBe(6000); + expect(state.nextWarmAt).not.toBeNull(); + expect(changeCount).toBe(countBefore + 1); + }); + + it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + observed.length = 0; + + nowMs = 9000; + warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); + + // Exactly one notify, and AT NOTIFY TIME the state already carried the + // re-armed, future fire time (lastWarmAt + interval) — never the past one. + expect(observed).toEqual([9000 + 240_000]); + }); + + it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + await warmer.setEnabled("conv-1", false); + observed.length = 0; + + nowMs = 9000; + warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); + + // Not re-armed (disabled) — the notify must NOT carry a stale past value. + expect(observed).toEqual([null]); + }); + + it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnStarted("conv-1"); + observed.length = 0; + + nowMs = 12_000; + warmer.onTurnSettled("conv-1", {}); + + // At notify time the new schedule is already armed. + expect(observed).toEqual([12_000 + 240_000]); + }); + + it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => { + let changeCount = 0; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => 5000, + }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + warmer.onTurnStarted("conv-1"); + + const countBefore = changeCount; + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBeNull(); + expect(state.lastWarmAt).toBeNull(); + expect(state.nextWarmAt).toBeNull(); + expect(changeCount).toBe(countBefore); + }); + + it("nextWarmAt is set when armed and null when disabled or active", async () => { + let nowMs = 1000; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + + // Before any event — not armed + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + + // After enable + turnSettled — armed with nextWarmAt + await warmer.setEnabled("conv-1", true); + nowMs = 2000; + warmer.onTurnSettled("conv-1", {}); + const stateArmed = warmer.getState("conv-1"); + expect(stateArmed.nextWarmAt).toBe(2000 + 240_000); + + // After turnStarted — cancelled (null) + warmer.onTurnStarted("conv-1"); + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + + // After disabling — null + await warmer.setEnabled("conv-2", true); + warmer.onTurnSettled("conv-2", {}); + await warmer.setEnabled("conv-2", false); + expect(warmer.getState("conv-2").nextWarmAt).toBeNull(); + }); + + it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => { + let changeCount = 0; + let nowMs = 5000; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => nowMs, + }); + const warmer = createCacheWarmer(deps); + + // Enable + settle to arm the timer + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + const armed = warmer.getState("conv-1"); + expect(armed.nextWarmAt).toBe(5000 + 240_000); + + // Simulate a manual warm completing at t=8000 + const countBefore = changeCount; + nowMs = 8000; + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, + }); + + const after = warmer.getState("conv-1"); + expect(after.lastPct).toBe(90); + expect(after.lastExpectedPct).toBe(90); + expect(after.lastWarmAt).toBe(8000); + // Timer should be re-armed with new nextWarmAt + expect(after.nextWarmAt).toBe(8000 + 240_000); + expect(changeCount).toBe(countBefore + 1); + }); + + it("stores lastPct from the warmCompleted event", () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: WARM_RESULT, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(80); + }); + + it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(70); + expect(state.lastExpectedPct).toBe(70); + }); + + it("re-arms timer after warmCompleted", async () => { + let nowMs = 1000; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt; + + nowMs = 5000; + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: WARM_RESULT, + }); + + const after = warmer.getState("conv-1"); + expect(after.nextWarmAt).not.toBeNull(); + expect(after.nextWarmAt).not.toBe(firstNextWarmAt); + expect(after.nextWarmAt).toBe(5000 + 240_000); + }); + + it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => { + let changeCount = 0; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => 5000, + }); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull(); + + const countBefore = changeCount; + await warmer.onConversationClosed("conv-1"); + + const state = warmer.getState("conv-1"); + expect(state.enabled).toBe(false); + expect(state.nextWarmAt).toBeNull(); + expect(changeCount).toBe(countBefore + 1); + + // The pending timer is cancelled — flushing fires nothing. + deps.timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + + // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read. + const raw = await deps.storage.get("settings:conv-1"); + expect(raw).not.toBeNull(); + expect(JSON.parse(raw as string).enabled).toBe(false); + }); + + it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnStarted("conv-1"); + + // Close while "generating" — do NOT await: the sync part must suffice. + const closed = warmer.onConversationClosed("conv-1"); + // The turn settles immediately after the close was issued. + warmer.onTurnSettled("conv-1", {}); + + expect(warmer.getState("conv-1").enabled).toBe(false); + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + await closed; + }); + + it("the per-conversation spec includes a cache-retention stat", () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastExpectedPct).toBe(90); + }); }); diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts index 6ad5c33..1d504e9 100644 --- a/packages/cache-warming/src/warmer.ts +++ b/packages/cache-warming/src/warmer.ts @@ -1,308 +1,308 @@ import type { Logger, StorageNamespace } from "@dispatch/kernel"; import type { WarmCompletedPayload, WarmService } from "@dispatch/session-orchestrator"; import { - type ConversationContext, - type ConversationSettings, - type ConversationState, - computeCachePct, - computeExpectedCacheRate, - DEFAULT_INTERVAL_MS, - MIN_INTERVAL_MS, - parseSettings, - serializeSettings, - settingsKey, - shouldWarm, + type ConversationContext, + type ConversationSettings, + type ConversationState, + computeCachePct, + computeExpectedCacheRate, + DEFAULT_INTERVAL_MS, + MIN_INTERVAL_MS, + parseSettings, + serializeSettings, + settingsKey, + shouldWarm, } from "./pure.js"; // --- Timer abstraction (injectable for tests) --- export interface TimerDeps { - readonly setTimer: (fn: () => void, ms: number) => number; - readonly clearTimer: (id: number) => void; + readonly setTimer: (fn: () => void, ms: number) => number; + readonly clearTimer: (id: number) => void; } // --- Warmer interface --- export interface CacheWarmer { - /** Handle a turnStarted event — mark conversation active, cancel pending warm. */ - readonly onTurnStarted: (conversationId: string) => void; + /** Handle a turnStarted event — mark conversation active, cancel pending warm. */ + readonly onTurnStarted: (conversationId: string) => void; - /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */ - readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void; + /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */ + readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void; - /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */ - readonly onWarmCompleted: (payload: WarmCompletedPayload) => void; + /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */ + readonly onWarmCompleted: (payload: WarmCompletedPayload) => void; - /** - * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the - * schedule and persist warming OFF for the conversation. The enabled flip is - * applied to in-memory state synchronously (so a turnSettled racing this close - * can never re-arm); only the settings persist is awaited. - */ - readonly onConversationClosed: (conversationId: string) => Promise; + /** + * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the + * schedule and persist warming OFF for the conversation. The enabled flip is + * applied to in-memory state synchronously (so a turnSettled racing this close + * can never re-arm); only the settings persist is awaited. + */ + readonly onConversationClosed: (conversationId: string) => Promise; - /** Get the current state for a conversation (for surface rendering). */ - readonly getState: (conversationId: string) => ConversationState; + /** Get the current state for a conversation (for surface rendering). */ + readonly getState: (conversationId: string) => ConversationState; - /** Get the stored context for a conversation. */ - readonly getContext: (conversationId: string) => ConversationContext; + /** Get the stored context for a conversation. */ + readonly getContext: (conversationId: string) => ConversationContext; - /** Toggle enabled for a conversation. Returns updated settings. */ - readonly setEnabled: (conversationId: string, enabled: boolean) => Promise; + /** Toggle enabled for a conversation. Returns updated settings. */ + readonly setEnabled: (conversationId: string, enabled: boolean) => Promise; - /** Set the refresh interval for a conversation. Returns updated settings. */ - readonly setIntervalMs: ( - conversationId: string, - intervalMs: number, - ) => Promise; + /** Set the refresh interval for a conversation. Returns updated settings. */ + readonly setIntervalMs: ( + conversationId: string, + intervalMs: number, + ) => Promise; - /** Dispose all timers (for cleanup). */ - readonly dispose: () => void; + /** Dispose all timers (for cleanup). */ + readonly dispose: () => void; } export interface CacheWarmerDeps { - readonly warm: WarmService["warm"]; - readonly storage: StorageNamespace; - readonly logger: Logger; - readonly timers: TimerDeps; - /** Injected clock — returns epoch-ms. */ - readonly now: () => number; - /** Called when surface subscribers should re-fetch the spec. */ - readonly onSurfaceChange: () => void; + readonly warm: WarmService["warm"]; + readonly storage: StorageNamespace; + readonly logger: Logger; + readonly timers: TimerDeps; + /** Injected clock — returns epoch-ms. */ + readonly now: () => number; + /** Called when surface subscribers should re-fetch the spec. */ + readonly onSurfaceChange: () => void; } // Warming is OPT-IN per conversation (CR-4a): default OFF, no warm scheduled // until the user enables it. const DEFAULT_STATE: ConversationState = { - enabled: false, - intervalMs: DEFAULT_INTERVAL_MS, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 0, + enabled: false, + intervalMs: DEFAULT_INTERVAL_MS, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 0, }; export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer { - // Per-conversation runtime state (not persisted — reconstructed from storage + events) - const states = new Map(); - // Per-conversation context from latest lifecycle event - const contexts = new Map(); - // Per-conversation pending timer id - const timers = new Map(); - // Monotonic token per conversation for in-flight invalidation - let nextToken = 1; - - function getState(conversationId: string): ConversationState { - return states.get(conversationId) ?? DEFAULT_STATE; - } - - function getContext(conversationId: string): ConversationContext { - return contexts.get(conversationId) ?? {}; - } - - function setState(conversationId: string, state: ConversationState): void { - states.set(conversationId, state); - } - - function cancelTimer(conversationId: string): void { - const existing = timers.get(conversationId); - if (existing !== undefined) { - deps.timers.clearTimer(existing); - timers.delete(conversationId); - } - mergeState(conversationId, { nextWarmAt: null }); - } - - function armTimer(conversationId: string): void { - cancelTimer(conversationId); - const state = getState(conversationId); - if (!state.enabled || state.active) return; - - const token = nextToken++; - const nowMs = deps.now(); - const nextWarmAt = nowMs + state.intervalMs; - setState(conversationId, { ...state, token, nextWarmAt }); - - const timerId = deps.timers.setTimer(() => { - timers.delete(conversationId); - void fireWarm(conversationId, token); - }, state.intervalMs); - - timers.set(conversationId, timerId); - } - - async function fireWarm(conversationId: string, token: number): Promise { - const state = getState(conversationId); - if (!shouldWarm(state, token)) { - deps.logger.debug("cache-warming: skip warm (superseded or disabled)", { - conversationId, - }); - return; - } - - const ctx = getContext(conversationId); - deps.logger.debug("cache-warming: firing warm", { conversationId }); - - await deps.warm(conversationId, { - ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}), - ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}), - }); - - // Result processing is handled by the warmCompleted event handler. - // Timer re-arm is also handled there on success. - } - - async function loadSettings(conversationId: string): Promise { - const raw = await deps.storage.get(settingsKey(conversationId)); - return parseSettings(raw); - } - - async function persistSettings( - conversationId: string, - settings: ConversationSettings, - ): Promise { - await deps.storage.set(settingsKey(conversationId), serializeSettings(settings)); - } - - function mergeState( - conversationId: string, - partial: Partial, - ): ConversationState { - const current = getState(conversationId); - const updated = { ...current, ...partial }; - setState(conversationId, updated); - return updated; - } - - return { - onTurnStarted(conversationId) { - deps.logger.debug("cache-warming: turn started", { conversationId }); - mergeState(conversationId, { active: true }); - cancelTimer(conversationId); - // Push the cleared schedule (nextWarmAt: null) so subscribers see - // "no warm scheduled" while the turn is generating. - deps.onSurfaceChange(); - }, - - onTurnSettled(conversationId, ctx) { - deps.logger.debug("cache-warming: turn settled", { conversationId }); - contexts.set(conversationId, ctx); - mergeState(conversationId, { active: false }); - - const state = getState(conversationId); - if (state.enabled) { - armTimer(conversationId); - } - // Push the post-seal reschedule so subscribers get the NEW (future) - // nextWarmAt instead of a stale pre-turn one (CR-4b). - deps.onSurfaceChange(); - }, - - onWarmCompleted(payload) { - const { conversationId, usage } = payload; - const state = getState(conversationId); - - // Drop if the conversation became active while the warm was in flight - if (state.active) { - deps.logger.debug("cache-warming: dropping warm result (conversation active)", { - conversationId, - }); - return; - } - - const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens); - const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens); - const nowMs = deps.now(); - setState(conversationId, { - ...state, - lastPct: pct, - lastExpectedPct: expectedPct, - lastWarmAt: nowMs, - // The just-fired schedule is consumed; cleared here so a non-re-armed - // path never reports a stale (past) nextWarmAt. - nextWarmAt: null, - }); - - // Re-arm the automatic timer if enabled and not active — BEFORE the - // surface notify, so the pushed update carries the NEW future nextWarmAt - // instead of the fire time of the warm that just completed (CR-4b). - const updated = getState(conversationId); - if (updated.enabled && !updated.active) { - armTimer(conversationId); - } - - deps.onSurfaceChange(); - deps.logger.debug("cache-warming: warm complete", { - conversationId, - pct, - expectedPct, - }); - }, - - getState, - getContext, - - async onConversationClosed(conversationId) { - deps.logger.debug("cache-warming: conversation closed", { conversationId }); - // Synchronous part FIRST: stop the schedule + flip enabled in memory so - // any racing turnSettled sees enabled=false and never re-arms. - cancelTimer(conversationId); - const updated = mergeState(conversationId, { enabled: false }); - deps.onSurfaceChange(); - // Persist OFF so a reopened conversation stays opt-in. - await persistSettings(conversationId, { - enabled: false, - intervalMs: updated.intervalMs, - }); - }, - - async setEnabled(conversationId, enabled) { - const settings = await loadSettings(conversationId); - const updated = { ...settings, enabled }; - await persistSettings(conversationId, updated); - // Merge the FULL settings (not just `enabled`) so re-enabling restores - // the persisted interval into runtime state. - mergeState(conversationId, updated); - - if (enabled) { - const state = getState(conversationId); - if (!state.active) { - armTimer(conversationId); - } - } else { - cancelTimer(conversationId); - } - - deps.onSurfaceChange(); - return updated; - }, - - async setIntervalMs(conversationId, intervalMs) { - const clamped = - !Number.isFinite(intervalMs) || intervalMs <= 0 - ? MIN_INTERVAL_MS - : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs)); - const settings = await loadSettings(conversationId); - const updated = { ...settings, intervalMs: clamped }; - await persistSettings(conversationId, updated); - mergeState(conversationId, { intervalMs: clamped }); - - // Re-arm with new interval if currently armed - const state = getState(conversationId); - if (state.enabled && !state.active && timers.has(conversationId)) { - armTimer(conversationId); - } - - deps.onSurfaceChange(); - return updated; - }, - - dispose() { - for (const [conversationId] of timers) { - cancelTimer(conversationId); - } - }, - }; + // Per-conversation runtime state (not persisted — reconstructed from storage + events) + const states = new Map(); + // Per-conversation context from latest lifecycle event + const contexts = new Map(); + // Per-conversation pending timer id + const timers = new Map(); + // Monotonic token per conversation for in-flight invalidation + let nextToken = 1; + + function getState(conversationId: string): ConversationState { + return states.get(conversationId) ?? DEFAULT_STATE; + } + + function getContext(conversationId: string): ConversationContext { + return contexts.get(conversationId) ?? {}; + } + + function setState(conversationId: string, state: ConversationState): void { + states.set(conversationId, state); + } + + function cancelTimer(conversationId: string): void { + const existing = timers.get(conversationId); + if (existing !== undefined) { + deps.timers.clearTimer(existing); + timers.delete(conversationId); + } + mergeState(conversationId, { nextWarmAt: null }); + } + + function armTimer(conversationId: string): void { + cancelTimer(conversationId); + const state = getState(conversationId); + if (!state.enabled || state.active) return; + + const token = nextToken++; + const nowMs = deps.now(); + const nextWarmAt = nowMs + state.intervalMs; + setState(conversationId, { ...state, token, nextWarmAt }); + + const timerId = deps.timers.setTimer(() => { + timers.delete(conversationId); + void fireWarm(conversationId, token); + }, state.intervalMs); + + timers.set(conversationId, timerId); + } + + async function fireWarm(conversationId: string, token: number): Promise { + const state = getState(conversationId); + if (!shouldWarm(state, token)) { + deps.logger.debug("cache-warming: skip warm (superseded or disabled)", { + conversationId, + }); + return; + } + + const ctx = getContext(conversationId); + deps.logger.debug("cache-warming: firing warm", { conversationId }); + + await deps.warm(conversationId, { + ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}), + ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}), + }); + + // Result processing is handled by the warmCompleted event handler. + // Timer re-arm is also handled there on success. + } + + async function loadSettings(conversationId: string): Promise { + const raw = await deps.storage.get(settingsKey(conversationId)); + return parseSettings(raw); + } + + async function persistSettings( + conversationId: string, + settings: ConversationSettings, + ): Promise { + await deps.storage.set(settingsKey(conversationId), serializeSettings(settings)); + } + + function mergeState( + conversationId: string, + partial: Partial, + ): ConversationState { + const current = getState(conversationId); + const updated = { ...current, ...partial }; + setState(conversationId, updated); + return updated; + } + + return { + onTurnStarted(conversationId) { + deps.logger.debug("cache-warming: turn started", { conversationId }); + mergeState(conversationId, { active: true }); + cancelTimer(conversationId); + // Push the cleared schedule (nextWarmAt: null) so subscribers see + // "no warm scheduled" while the turn is generating. + deps.onSurfaceChange(); + }, + + onTurnSettled(conversationId, ctx) { + deps.logger.debug("cache-warming: turn settled", { conversationId }); + contexts.set(conversationId, ctx); + mergeState(conversationId, { active: false }); + + const state = getState(conversationId); + if (state.enabled) { + armTimer(conversationId); + } + // Push the post-seal reschedule so subscribers get the NEW (future) + // nextWarmAt instead of a stale pre-turn one (CR-4b). + deps.onSurfaceChange(); + }, + + onWarmCompleted(payload) { + const { conversationId, usage } = payload; + const state = getState(conversationId); + + // Drop if the conversation became active while the warm was in flight + if (state.active) { + deps.logger.debug("cache-warming: dropping warm result (conversation active)", { + conversationId, + }); + return; + } + + const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens); + const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens); + const nowMs = deps.now(); + setState(conversationId, { + ...state, + lastPct: pct, + lastExpectedPct: expectedPct, + lastWarmAt: nowMs, + // The just-fired schedule is consumed; cleared here so a non-re-armed + // path never reports a stale (past) nextWarmAt. + nextWarmAt: null, + }); + + // Re-arm the automatic timer if enabled and not active — BEFORE the + // surface notify, so the pushed update carries the NEW future nextWarmAt + // instead of the fire time of the warm that just completed (CR-4b). + const updated = getState(conversationId); + if (updated.enabled && !updated.active) { + armTimer(conversationId); + } + + deps.onSurfaceChange(); + deps.logger.debug("cache-warming: warm complete", { + conversationId, + pct, + expectedPct, + }); + }, + + getState, + getContext, + + async onConversationClosed(conversationId) { + deps.logger.debug("cache-warming: conversation closed", { conversationId }); + // Synchronous part FIRST: stop the schedule + flip enabled in memory so + // any racing turnSettled sees enabled=false and never re-arms. + cancelTimer(conversationId); + const updated = mergeState(conversationId, { enabled: false }); + deps.onSurfaceChange(); + // Persist OFF so a reopened conversation stays opt-in. + await persistSettings(conversationId, { + enabled: false, + intervalMs: updated.intervalMs, + }); + }, + + async setEnabled(conversationId, enabled) { + const settings = await loadSettings(conversationId); + const updated = { ...settings, enabled }; + await persistSettings(conversationId, updated); + // Merge the FULL settings (not just `enabled`) so re-enabling restores + // the persisted interval into runtime state. + mergeState(conversationId, updated); + + if (enabled) { + const state = getState(conversationId); + if (!state.active) { + armTimer(conversationId); + } + } else { + cancelTimer(conversationId); + } + + deps.onSurfaceChange(); + return updated; + }, + + async setIntervalMs(conversationId, intervalMs) { + const clamped = + !Number.isFinite(intervalMs) || intervalMs <= 0 + ? MIN_INTERVAL_MS + : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs)); + const settings = await loadSettings(conversationId); + const updated = { ...settings, intervalMs: clamped }; + await persistSettings(conversationId, updated); + mergeState(conversationId, { intervalMs: clamped }); + + // Re-arm with new interval if currently armed + const state = getState(conversationId); + if (state.enabled && !state.active && timers.has(conversationId)) { + armTimer(conversationId); + } + + deps.onSurfaceChange(); + return updated; + }, + + dispose() { + for (const [conversationId] of timers) { + cancelTimer(conversationId); + } + }, + }; } diff --git a/packages/cache-warming/tsconfig.json b/packages/cache-warming/tsconfig.json index 6557731..dc01f63 100644 --- a/packages/cache-warming/tsconfig.json +++ b/packages/cache-warming/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../session-orchestrator" }, - { "path": "../surface-registry" }, - { "path": "../ui-contract" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../session-orchestrator" }, + { "path": "../surface-registry" }, + { "path": "../ui-contract" } + ] } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d99629..2a406cf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/cli", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/transport-contract": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/cli", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index e613f31..e6b43cf 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -4,482 +4,482 @@ import { parseArgs } from "./args.js"; const defaultServer = "http://localhost:24203"; describe("parseArgs", () => { - it("returns help for empty argv", () => { - expect(parseArgs([], { defaultServer })).toEqual({ kind: "help" }); - }); - - it("returns help for --help", () => { - expect(parseArgs(["--help"], { defaultServer })).toEqual({ kind: "help" }); - }); - - it("returns help for -h", () => { - expect(parseArgs(["-h"], { defaultServer })).toEqual({ kind: "help" }); - }); - - describe("models", () => { - it("parses 'models' with default server", () => { - expect(parseArgs(["models"], { defaultServer })).toEqual({ - kind: "models", - server: "http://localhost:24203", - }); - }); - - it("parses 'models --server '", () => { - expect(parseArgs(["models", "--server", "http://example.com"], { defaultServer })).toEqual({ - kind: "models", - server: "http://example.com", - }); - }); - - it("errors on unknown argument for models", () => { - const result = parseArgs(["models", "--foo"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("Unknown argument"); - }); - }); - - describe("chat", () => { - it("parses a chat with --text", () => { - const result = parseArgs(["my-model", "--text", "hello"], { defaultServer }); - expect(result).toEqual({ - kind: "chat", - server: "http://localhost:24203", - modelName: "my-model", - text: "hello", - file: undefined, - cwd: undefined, - conversationId: undefined, - reasoningEffort: undefined, - showReasoning: false, - open: false, - workspaceId: undefined, - }); - }); - - it("parses a chat with --file", () => { - const result = parseArgs(["my-model", "--file", "foo.txt"], { defaultServer }); - expect(result).toEqual({ - kind: "chat", - server: "http://localhost:24203", - modelName: "my-model", - text: undefined, - file: "foo.txt", - cwd: undefined, - conversationId: undefined, - reasoningEffort: undefined, - showReasoning: false, - open: false, - workspaceId: undefined, - }); - }); - - it("parses a chat with both --text and --file", () => { - const result = parseArgs(["m", "--text", "hi", "--file", "f.txt"], { defaultServer }); - expect(result).toMatchObject({ kind: "chat", text: "hi", file: "f.txt" }); - }); - - it("parses --cwd, --conversation, --server, --show-reasoning", () => { - const result = parseArgs( - [ - "m", - "--text", - "x", - "--cwd", - "/tmp", - "--conversation", - "abc", - "--server", - "http://s", - "--show-reasoning", - ], - { defaultServer }, - ); - expect(result).toEqual({ - kind: "chat", - server: "http://s", - modelName: "m", - text: "x", - file: undefined, - cwd: "/tmp", - conversationId: "abc", - reasoningEffort: undefined, - showReasoning: true, - open: false, - workspaceId: undefined, - }); - }); - - it("parses --effort high", () => { - const result = parseArgs(["m", "--text", "x", "--effort", "high"], { defaultServer }); - expect(result).toEqual({ - kind: "chat", - server: "http://localhost:24203", - modelName: "m", - text: "x", - file: undefined, - cwd: undefined, - conversationId: undefined, - reasoningEffort: "high", - showReasoning: false, - open: false, - workspaceId: undefined, - }); - }); - - it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts --effort %s", (level) => { - const result = parseArgs(["m", "--text", "x", "--effort", level], { defaultServer }); - expect(result.kind).toBe("chat"); - if (result.kind === "chat") expect(result.reasoningEffort).toBe(level); - }); - - it("errors when --effort has no value", () => { - const result = parseArgs(["m", "--text", "x", "--effort"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--effort requires a value"); - }); - - it("errors on invalid effort level", () => { - const result = parseArgs(["m", "--text", "x", "--effort", "banana"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") { - expect(result.message).toContain("banana"); - expect(result.message).toContain("low"); - expect(result.message).toContain("medium"); - expect(result.message).toContain("high"); - expect(result.message).toContain("xhigh"); - expect(result.message).toContain("max"); - } - }); - - it("errors when text and file are both missing", () => { - const result = parseArgs(["my-model"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--text or --file"); - }); - - it("errors on unknown flag", () => { - const result = parseArgs(["my-model", "--text", "hi", "--bogus"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("Unknown flag"); - }); - - it("errors when --text has no value", () => { - const result = parseArgs(["m", "--text"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("errors when --file has no value", () => { - const result = parseArgs(["m", "--file"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("errors when --server has no value", () => { - const result = parseArgs(["models", "--server"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("errors when --cwd has no value", () => { - const result = parseArgs(["m", "--text", "x", "--cwd"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("errors when --conversation has no value", () => { - const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("parses --workspace flag", () => { - const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer }); - expect(result).toEqual({ - kind: "chat", - server: "http://localhost:24203", - modelName: "m", - text: "x", - file: undefined, - cwd: undefined, - conversationId: undefined, - reasoningEffort: undefined, - showReasoning: false, - open: false, - workspaceId: "my-work", - }); - }); - - it("parses -w shorthand", () => { - const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer }); - expect(result.kind).toBe("chat"); - if (result.kind === "chat") expect(result.workspaceId).toBe("ws"); - }); - - it("errors when --workspace has no value", () => { - const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); - }); - }); - - describe("list", () => { - it("parses 'list' with no query", () => { - expect(parseArgs(["list"], { defaultServer })).toEqual({ - kind: "list", - server: "http://localhost:24203", - all: false, - }); - }); - - it("parses 'list' with a query prefix", () => { - expect(parseArgs(["list", "abc12345"], { defaultServer })).toEqual({ - kind: "list", - server: "http://localhost:24203", - query: "abc12345", - all: false, - }); - }); - - it("parses 'list' with --server after the prefix", () => { - expect(parseArgs(["list", "abc", "--server", "http://s"], { defaultServer })).toEqual({ - kind: "list", - server: "http://s", - query: "abc", - all: false, - }); - }); - - it("parses 'list' with --status", () => { - expect(parseArgs(["list", "--status", "closed"], { defaultServer })).toEqual({ - kind: "list", - server: "http://localhost:24203", - status: "closed", - all: false, - }); - }); - - it("parses 'list' with --workspace", () => { - expect(parseArgs(["list", "--workspace", "proj"], { defaultServer })).toEqual({ - kind: "list", - server: "http://localhost:24203", - workspaceId: "proj", - all: false, - }); - }); - - it("parses 'list' with -w shorthand", () => { - const result = parseArgs(["list", "-w", "ws"], { defaultServer }); - expect(result.kind).toBe("list"); - if (result.kind === "list") expect(result.workspaceId).toBe("ws"); - }); - - it("parses 'list' with --workspace, --status, and a prefix together", () => { - const result = parseArgs(["list", "abc", "--status", "active", "--workspace", "proj"], { - defaultServer, - }); - expect(result).toEqual({ - kind: "list", - server: "http://localhost:24203", - query: "abc", - status: "active", - workspaceId: "proj", - all: false, - }); - }); - - it("errors when --workspace has no value (list)", () => { - const result = parseArgs(["list", "--workspace"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); - }); - - it("parses 'list' with --all", () => { - expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({ - kind: "list", - server: "http://localhost:24203", - all: true, - }); - }); - - it("parses 'list' with --status and --all ( --all takes precedence)", () => { - const result = parseArgs(["list", "--status", "active", "--all"], { defaultServer }); - expect(result.kind).toBe("list"); - if (result.kind === "list") { - expect(result.all).toBe(true); - expect(result.status).toBe("active"); - } - }); - - it("errors on a second positional argument", () => { - const result = parseArgs(["list", "abc", "def"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("Unexpected argument"); - }); - - it("errors on an unknown flag", () => { - const result = parseArgs(["list", "--bogus"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("Unknown flag"); - }); - }); - - describe("read", () => { - it("parses 'read' with a conversation id", () => { - expect(parseArgs(["read", "deadbeef"], { defaultServer })).toEqual({ - kind: "read", - server: "http://localhost:24203", - conversationId: "deadbeef", - }); - }); - - it("parses 'read' with --server", () => { - expect(parseArgs(["read", "deadbeef", "--server", "http://s"], { defaultServer })).toEqual({ - kind: "read", - server: "http://s", - conversationId: "deadbeef", - }); - }); - - it("errors when no conversation id is given", () => { - const result = parseArgs(["read"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("conversation id"); - }); - - it("errors on a second positional argument", () => { - const result = parseArgs(["read", "a", "b"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - }); - - describe("send", () => { - it("parses 'send' with --text", () => { - expect(parseArgs(["send", "deadbeef", "--text", "hi"], { defaultServer })).toEqual({ - kind: "send", - server: "http://localhost:24203", - conversationId: "deadbeef", - text: "hi", - file: undefined, - queue: false, - open: false, - }); - }); - - it("parses 'send' with --file", () => { - expect(parseArgs(["send", "deadbeef", "--file", "foo.txt"], { defaultServer })).toEqual({ - kind: "send", - server: "http://localhost:24203", - conversationId: "deadbeef", - text: undefined, - file: "foo.txt", - queue: false, - open: false, - }); - }); - - it("parses 'send' with both --text and --file", () => { - const result = parseArgs(["send", "deadbeef", "--text", "hi", "--file", "f.txt"], { - defaultServer, - }); - expect(result).toMatchObject({ kind: "send", text: "hi", file: "f.txt" }); - }); - - it("parses 'send' with --queue", () => { - const result = parseArgs(["send", "deadbeef", "--text", "hi", "--queue"], { - defaultServer, - }); - expect(result).toEqual({ - kind: "send", - server: "http://localhost:24203", - conversationId: "deadbeef", - text: "hi", - file: undefined, - queue: true, - open: false, - }); - }); - - it("parses 'send' with --open", () => { - const result = parseArgs(["send", "deadbeef", "--text", "hi", "--open"], { - defaultServer, - }); - expect(result).toEqual({ - kind: "send", - server: "http://localhost:24203", - conversationId: "deadbeef", - text: "hi", - file: undefined, - queue: false, - open: true, - }); - }); - - it("parses 'send' with --cwd and --effort", () => { - const result = parseArgs( - ["send", "deadbeef", "--text", "hi", "--cwd", "/tmp", "--effort", "xhigh"], - { defaultServer }, - ); - expect(result).toEqual({ - kind: "send", - server: "http://localhost:24203", - conversationId: "deadbeef", - text: "hi", - file: undefined, - queue: false, - open: false, - cwd: "/tmp", - reasoningEffort: "xhigh", - }); - }); - - it("errors when --text and --file are both missing", () => { - const result = parseArgs(["send", "deadbeef"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--text or --file"); - }); - - it("requires a conversation id", () => { - const result = parseArgs(["send", "--text", "hi"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("conversation id"); - }); - - it("errors when --text has no value", () => { - const result = parseArgs(["send", "deadbeef", "--text"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - - it("errors when --file has no value", () => { - const result = parseArgs(["send", "deadbeef", "--file"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--file requires a value"); - }); - }); - - describe("open", () => { - it("parses 'open' with conversation id", () => { - expect(parseArgs(["open", "deadbeef"], { defaultServer })).toEqual({ - kind: "open", - server: "http://localhost:24203", - conversationId: "deadbeef", - }); - }); - - it("parses 'open' with --server", () => { - expect( - parseArgs(["open", "deadbeef", "--server", "http://example.com"], { defaultServer }), - ).toEqual({ - kind: "open", - server: "http://example.com", - conversationId: "deadbeef", - }); - }); - - it("requires a conversation id", () => { - const result = parseArgs(["open"], { defaultServer }); - expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("conversation id"); - }); - - it("rejects unknown flags", () => { - const result = parseArgs(["open", "deadbeef", "--bogus"], { defaultServer }); - expect(result.kind).toBe("error"); - }); - }); + it("returns help for empty argv", () => { + expect(parseArgs([], { defaultServer })).toEqual({ kind: "help" }); + }); + + it("returns help for --help", () => { + expect(parseArgs(["--help"], { defaultServer })).toEqual({ kind: "help" }); + }); + + it("returns help for -h", () => { + expect(parseArgs(["-h"], { defaultServer })).toEqual({ kind: "help" }); + }); + + describe("models", () => { + it("parses 'models' with default server", () => { + expect(parseArgs(["models"], { defaultServer })).toEqual({ + kind: "models", + server: "http://localhost:24203", + }); + }); + + it("parses 'models --server '", () => { + expect(parseArgs(["models", "--server", "http://example.com"], { defaultServer })).toEqual({ + kind: "models", + server: "http://example.com", + }); + }); + + it("errors on unknown argument for models", () => { + const result = parseArgs(["models", "--foo"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unknown argument"); + }); + }); + + describe("chat", () => { + it("parses a chat with --text", () => { + const result = parseArgs(["my-model", "--text", "hello"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "my-model", + text: "hello", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: undefined, + showReasoning: false, + open: false, + workspaceId: undefined, + }); + }); + + it("parses a chat with --file", () => { + const result = parseArgs(["my-model", "--file", "foo.txt"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "my-model", + text: undefined, + file: "foo.txt", + cwd: undefined, + conversationId: undefined, + reasoningEffort: undefined, + showReasoning: false, + open: false, + workspaceId: undefined, + }); + }); + + it("parses a chat with both --text and --file", () => { + const result = parseArgs(["m", "--text", "hi", "--file", "f.txt"], { defaultServer }); + expect(result).toMatchObject({ kind: "chat", text: "hi", file: "f.txt" }); + }); + + it("parses --cwd, --conversation, --server, --show-reasoning", () => { + const result = parseArgs( + [ + "m", + "--text", + "x", + "--cwd", + "/tmp", + "--conversation", + "abc", + "--server", + "http://s", + "--show-reasoning", + ], + { defaultServer }, + ); + expect(result).toEqual({ + kind: "chat", + server: "http://s", + modelName: "m", + text: "x", + file: undefined, + cwd: "/tmp", + conversationId: "abc", + reasoningEffort: undefined, + showReasoning: true, + open: false, + workspaceId: undefined, + }); + }); + + it("parses --effort high", () => { + const result = parseArgs(["m", "--text", "x", "--effort", "high"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "m", + text: "x", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: "high", + showReasoning: false, + open: false, + workspaceId: undefined, + }); + }); + + it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts --effort %s", (level) => { + const result = parseArgs(["m", "--text", "x", "--effort", level], { defaultServer }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") expect(result.reasoningEffort).toBe(level); + }); + + it("errors when --effort has no value", () => { + const result = parseArgs(["m", "--text", "x", "--effort"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--effort requires a value"); + }); + + it("errors on invalid effort level", () => { + const result = parseArgs(["m", "--text", "x", "--effort", "banana"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toContain("banana"); + expect(result.message).toContain("low"); + expect(result.message).toContain("medium"); + expect(result.message).toContain("high"); + expect(result.message).toContain("xhigh"); + expect(result.message).toContain("max"); + } + }); + + it("errors when text and file are both missing", () => { + const result = parseArgs(["my-model"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--text or --file"); + }); + + it("errors on unknown flag", () => { + const result = parseArgs(["my-model", "--text", "hi", "--bogus"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unknown flag"); + }); + + it("errors when --text has no value", () => { + const result = parseArgs(["m", "--text"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("errors when --file has no value", () => { + const result = parseArgs(["m", "--file"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("errors when --server has no value", () => { + const result = parseArgs(["models", "--server"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("errors when --cwd has no value", () => { + const result = parseArgs(["m", "--text", "x", "--cwd"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("errors when --conversation has no value", () => { + const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("parses --workspace flag", () => { + const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "m", + text: "x", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: undefined, + showReasoning: false, + open: false, + workspaceId: "my-work", + }); + }); + + it("parses -w shorthand", () => { + const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") expect(result.workspaceId).toBe("ws"); + }); + + it("errors when --workspace has no value", () => { + const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); + }); + }); + + describe("list", () => { + it("parses 'list' with no query", () => { + expect(parseArgs(["list"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + all: false, + }); + }); + + it("parses 'list' with a query prefix", () => { + expect(parseArgs(["list", "abc12345"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + query: "abc12345", + all: false, + }); + }); + + it("parses 'list' with --server after the prefix", () => { + expect(parseArgs(["list", "abc", "--server", "http://s"], { defaultServer })).toEqual({ + kind: "list", + server: "http://s", + query: "abc", + all: false, + }); + }); + + it("parses 'list' with --status", () => { + expect(parseArgs(["list", "--status", "closed"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + status: "closed", + all: false, + }); + }); + + it("parses 'list' with --workspace", () => { + expect(parseArgs(["list", "--workspace", "proj"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + workspaceId: "proj", + all: false, + }); + }); + + it("parses 'list' with -w shorthand", () => { + const result = parseArgs(["list", "-w", "ws"], { defaultServer }); + expect(result.kind).toBe("list"); + if (result.kind === "list") expect(result.workspaceId).toBe("ws"); + }); + + it("parses 'list' with --workspace, --status, and a prefix together", () => { + const result = parseArgs(["list", "abc", "--status", "active", "--workspace", "proj"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "list", + server: "http://localhost:24203", + query: "abc", + status: "active", + workspaceId: "proj", + all: false, + }); + }); + + it("errors when --workspace has no value (list)", () => { + const result = parseArgs(["list", "--workspace"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); + }); + + it("parses 'list' with --all", () => { + expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + all: true, + }); + }); + + it("parses 'list' with --status and --all ( --all takes precedence)", () => { + const result = parseArgs(["list", "--status", "active", "--all"], { defaultServer }); + expect(result.kind).toBe("list"); + if (result.kind === "list") { + expect(result.all).toBe(true); + expect(result.status).toBe("active"); + } + }); + + it("errors on a second positional argument", () => { + const result = parseArgs(["list", "abc", "def"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unexpected argument"); + }); + + it("errors on an unknown flag", () => { + const result = parseArgs(["list", "--bogus"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unknown flag"); + }); + }); + + describe("read", () => { + it("parses 'read' with a conversation id", () => { + expect(parseArgs(["read", "deadbeef"], { defaultServer })).toEqual({ + kind: "read", + server: "http://localhost:24203", + conversationId: "deadbeef", + }); + }); + + it("parses 'read' with --server", () => { + expect(parseArgs(["read", "deadbeef", "--server", "http://s"], { defaultServer })).toEqual({ + kind: "read", + server: "http://s", + conversationId: "deadbeef", + }); + }); + + it("errors when no conversation id is given", () => { + const result = parseArgs(["read"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("conversation id"); + }); + + it("errors on a second positional argument", () => { + const result = parseArgs(["read", "a", "b"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + }); + + describe("send", () => { + it("parses 'send' with --text", () => { + expect(parseArgs(["send", "deadbeef", "--text", "hi"], { defaultServer })).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + file: undefined, + queue: false, + open: false, + }); + }); + + it("parses 'send' with --file", () => { + expect(parseArgs(["send", "deadbeef", "--file", "foo.txt"], { defaultServer })).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: undefined, + file: "foo.txt", + queue: false, + open: false, + }); + }); + + it("parses 'send' with both --text and --file", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--file", "f.txt"], { + defaultServer, + }); + expect(result).toMatchObject({ kind: "send", text: "hi", file: "f.txt" }); + }); + + it("parses 'send' with --queue", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--queue"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + file: undefined, + queue: true, + open: false, + }); + }); + + it("parses 'send' with --open", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--open"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + file: undefined, + queue: false, + open: true, + }); + }); + + it("parses 'send' with --cwd and --effort", () => { + const result = parseArgs( + ["send", "deadbeef", "--text", "hi", "--cwd", "/tmp", "--effort", "xhigh"], + { defaultServer }, + ); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + file: undefined, + queue: false, + open: false, + cwd: "/tmp", + reasoningEffort: "xhigh", + }); + }); + + it("errors when --text and --file are both missing", () => { + const result = parseArgs(["send", "deadbeef"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--text or --file"); + }); + + it("requires a conversation id", () => { + const result = parseArgs(["send", "--text", "hi"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("conversation id"); + }); + + it("errors when --text has no value", () => { + const result = parseArgs(["send", "deadbeef", "--text"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + + it("errors when --file has no value", () => { + const result = parseArgs(["send", "deadbeef", "--file"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--file requires a value"); + }); + }); + + describe("open", () => { + it("parses 'open' with conversation id", () => { + expect(parseArgs(["open", "deadbeef"], { defaultServer })).toEqual({ + kind: "open", + server: "http://localhost:24203", + conversationId: "deadbeef", + }); + }); + + it("parses 'open' with --server", () => { + expect( + parseArgs(["open", "deadbeef", "--server", "http://example.com"], { defaultServer }), + ).toEqual({ + kind: "open", + server: "http://example.com", + conversationId: "deadbeef", + }); + }); + + it("requires a conversation id", () => { + const result = parseArgs(["open"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("conversation id"); + }); + + it("rejects unknown flags", () => { + const result = parseArgs(["open", "deadbeef", "--bogus"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + }); }); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 74cc56a..52f1fba 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -10,378 +10,378 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; const VALID_EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; export function isValidEffort(value: string): value is ReasoningEffort { - return (VALID_EFFORTS as readonly string[]).includes(value); + return (VALID_EFFORTS as readonly string[]).includes(value); } export type ParsedCommand = - | { readonly kind: "models"; readonly server: string } - | { - readonly kind: "chat"; - readonly server: string; - readonly modelName: string; - readonly text?: string | undefined; - readonly file?: string | undefined; - readonly cwd?: string | undefined; - readonly conversationId?: string | undefined; - readonly reasoningEffort?: ReasoningEffort | undefined; - readonly showReasoning: boolean; - readonly open: boolean; - readonly workspaceId?: string | undefined; - } - | { - readonly kind: "list"; - readonly server: string; - readonly query?: string; - readonly status?: string; - readonly workspaceId?: string; - readonly all: boolean; - } - | { readonly kind: "compact"; readonly server: string; readonly conversationId: string } - | { readonly kind: "open"; readonly server: string; readonly conversationId: string } - | { readonly kind: "read"; readonly server: string; readonly conversationId: string } - | { - readonly kind: "send"; - readonly server: string; - readonly conversationId: string; - readonly text?: string | undefined; - readonly file?: string | undefined; - readonly queue: boolean; - readonly open: boolean; - readonly cwd?: string; - readonly reasoningEffort?: ReasoningEffort; - readonly workspaceId?: string; - } - | { readonly kind: "stop"; readonly server: string; readonly conversationId: string } - | { readonly kind: "help" } - | { readonly kind: "error"; readonly message: string }; + | { readonly kind: "models"; readonly server: string } + | { + readonly kind: "chat"; + readonly server: string; + readonly modelName: string; + readonly text?: string | undefined; + readonly file?: string | undefined; + readonly cwd?: string | undefined; + readonly conversationId?: string | undefined; + readonly reasoningEffort?: ReasoningEffort | undefined; + readonly showReasoning: boolean; + readonly open: boolean; + readonly workspaceId?: string | undefined; + } + | { + readonly kind: "list"; + readonly server: string; + readonly query?: string; + readonly status?: string; + readonly workspaceId?: string; + readonly all: boolean; + } + | { readonly kind: "compact"; readonly server: string; readonly conversationId: string } + | { readonly kind: "open"; readonly server: string; readonly conversationId: string } + | { readonly kind: "read"; readonly server: string; readonly conversationId: string } + | { + readonly kind: "send"; + readonly server: string; + readonly conversationId: string; + readonly text?: string | undefined; + readonly file?: string | undefined; + readonly queue: boolean; + readonly open: boolean; + readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; + } + | { readonly kind: "stop"; readonly server: string; readonly conversationId: string } + | { readonly kind: "help" } + | { readonly kind: "error"; readonly message: string }; interface ParseOpts { - readonly defaultServer: string; + readonly defaultServer: string; } export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedCommand { - if (argv.length === 0) { - return { kind: "help" }; - } + if (argv.length === 0) { + return { kind: "help" }; + } - const first = argv[0] as string; + const first = argv[0] as string; - if (first === "--help" || first === "-h") { - return { kind: "help" }; - } + if (first === "--help" || first === "-h") { + return { kind: "help" }; + } - if (first === "models") { - let server = opts.defaultServer; - for (let i = 1; i < argv.length; i++) { - if (argv[i] === "--server" && i + 1 < argv.length) { - server = argv[++i] as string; - } else { - return { kind: "error", message: `Unknown argument for 'models': ${argv[i]}` }; - } - } - return { kind: "models", server }; - } + if (first === "models") { + let server = opts.defaultServer; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--server" && i + 1 < argv.length) { + server = argv[++i] as string; + } else { + return { kind: "error", message: `Unknown argument for 'models': ${argv[i]}` }; + } + } + return { kind: "models", server }; + } - if (first === "list") { - let server = opts.defaultServer; - let query: string | undefined; - let status: string | undefined; - let workspaceId: string | undefined; - let all = false; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - if (arg === "--server") { - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - } else if (arg === "--status") { - if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" }; - status = argv[++i]; - } else if (arg === "--workspace" || arg === "-w") { - if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; - workspaceId = argv[++i]; - } else if (arg === "--all") { - all = true; - } else if (arg.startsWith("--")) { - return { kind: "error", message: `Unknown flag: ${arg}` }; - } else if (query !== undefined) { - return { kind: "error", message: `Unexpected argument for 'list': ${arg}` }; - } else { - query = arg; - } - } - return { - kind: "list", - server, - ...(query !== undefined && { query }), - ...(status !== undefined && { status }), - ...(workspaceId !== undefined && { workspaceId }), - all, - }; - } + if (first === "list") { + let server = opts.defaultServer; + let query: string | undefined; + let status: string | undefined; + let workspaceId: string | undefined; + let all = false; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg === "--status") { + if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" }; + status = argv[++i]; + } else if (arg === "--workspace" || arg === "-w") { + if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; + } else if (arg === "--all") { + all = true; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (query !== undefined) { + return { kind: "error", message: `Unexpected argument for 'list': ${arg}` }; + } else { + query = arg; + } + } + return { + kind: "list", + server, + ...(query !== undefined && { query }), + ...(status !== undefined && { status }), + ...(workspaceId !== undefined && { workspaceId }), + all, + }; + } - if (first === "compact") { - let server = opts.defaultServer; - let conversationId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - if (arg === "--server") { - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - } else if (arg.startsWith("--")) { - return { kind: "error", message: `Unknown flag: ${arg}` }; - } else if (conversationId !== undefined) { - return { kind: "error", message: `Unexpected argument for 'compact': ${arg}` }; - } else { - conversationId = arg; - } - } - if (conversationId === undefined) { - return { kind: "error", message: "'compact' requires a conversation id" }; - } - return { kind: "compact", server, conversationId }; - } + if (first === "compact") { + let server = opts.defaultServer; + let conversationId: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (conversationId !== undefined) { + return { kind: "error", message: `Unexpected argument for 'compact': ${arg}` }; + } else { + conversationId = arg; + } + } + if (conversationId === undefined) { + return { kind: "error", message: "'compact' requires a conversation id" }; + } + return { kind: "compact", server, conversationId }; + } - if (first === "stop") { - let server = opts.defaultServer; - let conversationId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - if (arg === "--server") { - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - } else if (arg.startsWith("--")) { - return { kind: "error", message: `Unknown flag: ${arg}` }; - } else if (conversationId !== undefined) { - return { kind: "error", message: `Unexpected argument for 'stop': ${arg}` }; - } else { - conversationId = arg; - } - } - if (conversationId === undefined) { - return { kind: "error", message: "'stop' requires a conversation id" }; - } - return { kind: "stop", server, conversationId }; - } + if (first === "stop") { + let server = opts.defaultServer; + let conversationId: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (conversationId !== undefined) { + return { kind: "error", message: `Unexpected argument for 'stop': ${arg}` }; + } else { + conversationId = arg; + } + } + if (conversationId === undefined) { + return { kind: "error", message: "'stop' requires a conversation id" }; + } + return { kind: "stop", server, conversationId }; + } - if (first === "read") { - let server = opts.defaultServer; - let conversationId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - if (arg === "--server") { - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - } else if (arg.startsWith("--")) { - return { kind: "error", message: `Unknown flag: ${arg}` }; - } else if (conversationId !== undefined) { - return { kind: "error", message: `Unexpected argument for 'read': ${arg}` }; - } else { - conversationId = arg; - } - } - if (conversationId === undefined) { - return { kind: "error", message: "'read' requires a conversation id" }; - } - return { kind: "read", server, conversationId }; - } + if (first === "read") { + let server = opts.defaultServer; + let conversationId: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (conversationId !== undefined) { + return { kind: "error", message: `Unexpected argument for 'read': ${arg}` }; + } else { + conversationId = arg; + } + } + if (conversationId === undefined) { + return { kind: "error", message: "'read' requires a conversation id" }; + } + return { kind: "read", server, conversationId }; + } - if (first === "open") { - let server = opts.defaultServer; - let conversationId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - if (arg === "--server") { - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - } else if (arg.startsWith("--")) { - return { kind: "error", message: `Unknown flag: ${arg}` }; - } else if (conversationId !== undefined) { - return { kind: "error", message: `Unexpected argument for 'open': ${arg}` }; - } else { - conversationId = arg; - } - } - if (conversationId === undefined) { - return { kind: "error", message: "'open' requires a conversation id" }; - } - return { kind: "open", server, conversationId }; - } + if (first === "open") { + let server = opts.defaultServer; + let conversationId: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (conversationId !== undefined) { + return { kind: "error", message: `Unexpected argument for 'open': ${arg}` }; + } else { + conversationId = arg; + } + } + if (conversationId === undefined) { + return { kind: "error", message: "'open' requires a conversation id" }; + } + return { kind: "open", server, conversationId }; + } - if (first === "send") { - let server = opts.defaultServer; - let conversationId: string | undefined; - let text: string | undefined; - let file: string | undefined; - let queue = false; - let open = false; - let cwd: string | undefined; - let reasoningEffort: ReasoningEffort | undefined; - let workspaceId: string | undefined; + if (first === "send") { + let server = opts.defaultServer; + let conversationId: string | undefined; + let text: string | undefined; + let file: string | undefined; + let queue = false; + let open = false; + let cwd: string | undefined; + let reasoningEffort: ReasoningEffort | undefined; + let workspaceId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - switch (arg) { - case "--server": - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - break; - case "--text": - if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" }; - text = argv[++i]; - break; - case "--file": - if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" }; - file = argv[++i]; - break; - case "--queue": - queue = true; - break; - case "--open": - open = true; - break; - case "--cwd": - if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" }; - cwd = argv[++i]; - break; - case "--effort": { - if (i + 1 >= argv.length) - return { - kind: "error", - message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, - }; - const val = argv[++i] as string; - if (!isValidEffort(val)) - return { - kind: "error", - message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, - }; - reasoningEffort = val; - break; - } - case "--workspace": - case "-w": - if (i + 1 >= argv.length) - return { kind: "error", message: "--workspace requires a value" }; - workspaceId = argv[++i]; - break; - default: - if (arg.startsWith("--")) return { kind: "error", message: `Unknown flag: ${arg}` }; - if (conversationId !== undefined) - return { kind: "error", message: `Unexpected argument for 'send': ${arg}` }; - conversationId = arg; - } - } + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case "--server": + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + break; + case "--text": + if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" }; + text = argv[++i]; + break; + case "--file": + if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" }; + file = argv[++i]; + break; + case "--queue": + queue = true; + break; + case "--open": + open = true; + break; + case "--cwd": + if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" }; + cwd = argv[++i]; + break; + case "--effort": { + if (i + 1 >= argv.length) + return { + kind: "error", + message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, + }; + const val = argv[++i] as string; + if (!isValidEffort(val)) + return { + kind: "error", + message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, + }; + reasoningEffort = val; + break; + } + case "--workspace": + case "-w": + if (i + 1 >= argv.length) + return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; + break; + default: + if (arg.startsWith("--")) return { kind: "error", message: `Unknown flag: ${arg}` }; + if (conversationId !== undefined) + return { kind: "error", message: `Unexpected argument for 'send': ${arg}` }; + conversationId = arg; + } + } - if (conversationId === undefined) { - return { kind: "error", message: "'send' requires a conversation id" }; - } - if (!text && !file) { - return { - kind: "error", - message: "At least one of --text or --file is required for 'send'", - }; - } + if (conversationId === undefined) { + return { kind: "error", message: "'send' requires a conversation id" }; + } + if (!text && !file) { + return { + kind: "error", + message: "At least one of --text or --file is required for 'send'", + }; + } - return { - kind: "send", - server, - conversationId, - text, - file, - queue, - open, - ...(cwd !== undefined && { cwd }), - ...(reasoningEffort !== undefined && { reasoningEffort }), - ...(workspaceId !== undefined && { workspaceId }), - }; - } + return { + kind: "send", + server, + conversationId, + text, + file, + queue, + open, + ...(cwd !== undefined && { cwd }), + ...(reasoningEffort !== undefined && { reasoningEffort }), + ...(workspaceId !== undefined && { workspaceId }), + }; + } - // Chat mode: first arg is the model name - const modelName = first; - let text: string | undefined; - let file: string | undefined; - let cwd: string | undefined; - let conversationId: string | undefined; - let reasoningEffort: ReasoningEffort | undefined; - let showReasoning = false; - let open = false; - let server = opts.defaultServer; - let workspaceId: string | undefined; + // Chat mode: first arg is the model name + const modelName = first; + let text: string | undefined; + let file: string | undefined; + let cwd: string | undefined; + let conversationId: string | undefined; + let reasoningEffort: ReasoningEffort | undefined; + let showReasoning = false; + let open = false; + let server = opts.defaultServer; + let workspaceId: string | undefined; - for (let i = 1; i < argv.length; i++) { - const arg = argv[i] as string; - switch (arg) { - case "--text": - if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" }; - text = argv[++i]; - break; - case "--file": - if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" }; - file = argv[++i]; - break; - case "--cwd": - if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" }; - cwd = argv[++i]; - break; - case "--conversation": - if (i + 1 >= argv.length) - return { kind: "error", message: "--conversation requires a value" }; - conversationId = argv[++i]; - break; - case "--server": - if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; - server = argv[++i] as string; - break; - case "--show-reasoning": - showReasoning = true; - break; - case "--open": - open = true; - break; - case "--effort": - if (i + 1 >= argv.length) - return { - kind: "error", - message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, - }; - { - const val = argv[++i] as string; - if (!isValidEffort(val)) - return { - kind: "error", - message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, - }; - reasoningEffort = val; - } - break; - case "--workspace": - case "-w": - if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; - workspaceId = argv[++i]; - break; - default: - return { kind: "error", message: `Unknown flag: ${arg}` }; - } - } + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case "--text": + if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" }; + text = argv[++i]; + break; + case "--file": + if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" }; + file = argv[++i]; + break; + case "--cwd": + if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" }; + cwd = argv[++i]; + break; + case "--conversation": + if (i + 1 >= argv.length) + return { kind: "error", message: "--conversation requires a value" }; + conversationId = argv[++i]; + break; + case "--server": + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + break; + case "--show-reasoning": + showReasoning = true; + break; + case "--open": + open = true; + break; + case "--effort": + if (i + 1 >= argv.length) + return { + kind: "error", + message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, + }; + { + const val = argv[++i] as string; + if (!isValidEffort(val)) + return { + kind: "error", + message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, + }; + reasoningEffort = val; + } + break; + case "--workspace": + case "-w": + if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; + break; + default: + return { kind: "error", message: `Unknown flag: ${arg}` }; + } + } - if (!text && !file) { - return { - kind: "error", - message: "At least one of --text or --file is required for a chat command", - }; - } + if (!text && !file) { + return { + kind: "error", + message: "At least one of --text or --file is required for a chat command", + }; + } - return { - kind: "chat", - server, - modelName, - text, - file, - cwd, - conversationId, - reasoningEffort, - showReasoning, - open, - ...(workspaceId !== undefined && { workspaceId }), - }; + return { + kind: "chat", + server, + modelName, + text, + file, + cwd, + conversationId, + reasoningEffort, + showReasoning, + open, + ...(workspaceId !== undefined && { workspaceId }), + }; } diff --git a/packages/cli/src/catalog.test.ts b/packages/cli/src/catalog.test.ts index 3b32efc..a6d27f2 100644 --- a/packages/cli/src/catalog.test.ts +++ b/packages/cli/src/catalog.test.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest"; import { formatCatalog } from "./catalog.js"; describe("formatCatalog", () => { - it("formats a single model", () => { - expect(formatCatalog({ models: ["openai/gpt-4"] })).toBe("openai/gpt-4"); - }); + it("formats a single model", () => { + expect(formatCatalog({ models: ["openai/gpt-4"] })).toBe("openai/gpt-4"); + }); - it("formats multiple models one per line", () => { - expect(formatCatalog({ models: ["openai/gpt-4", "anthropic/claude-3", "local/llama"] })).toBe( - "openai/gpt-4\nanthropic/claude-3\nlocal/llama", - ); - }); + it("formats multiple models one per line", () => { + expect(formatCatalog({ models: ["openai/gpt-4", "anthropic/claude-3", "local/llama"] })).toBe( + "openai/gpt-4\nanthropic/claude-3\nlocal/llama", + ); + }); - it("returns empty string for empty models", () => { - expect(formatCatalog({ models: [] })).toBe(""); - }); + it("returns empty string for empty models", () => { + expect(formatCatalog({ models: [] })).toBe(""); + }); }); diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts index a2b0780..51717d0 100644 --- a/packages/cli/src/catalog.ts +++ b/packages/cli/src/catalog.ts @@ -7,5 +7,5 @@ import type { ModelsResponse } from "@dispatch/transport-contract"; export function formatCatalog(r: ModelsResponse): string { - return r.models.join("\n"); + return r.models.join("\n"); } diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index ab39813..18de728 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -1,517 +1,517 @@ import type { - AgentEvent, - ChatRequest, - ConversationListResponse, + AgentEvent, + ChatRequest, + ConversationListResponse, } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { - enqueueMessage, - fetchConversations, - fetchLastMessage, - fetchModels, - openConversation, - resolveConversationId, - streamChat, + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + streamChat, } from "./http.js"; function ndjsonLines(...events: AgentEvent[]): string { - return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`; + return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`; } function makeFakeFetch(responseBody: string, headers?: Record) { - const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise => { - const encoder = new TextEncoder(); - const chunks = responseBody.split("|||"); - let i = 0; - const stream = new ReadableStream({ - pull(controller) { - if (i < chunks.length) { - const chunk = chunks[i]; - if (chunk !== undefined) controller.enqueue(encoder.encode(chunk)); - i++; - } else { - controller.close(); - } - }, - }); - return new Response(stream, { - status: 200, - headers: headers ?? {}, - }); - }; - return fn as unknown as typeof fetch; + const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise => { + const encoder = new TextEncoder(); + const chunks = responseBody.split("|||"); + let i = 0; + const stream = new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + const chunk = chunks[i]; + if (chunk !== undefined) controller.enqueue(encoder.encode(chunk)); + i++; + } else { + controller.close(); + } + }, + }); + return new Response(stream, { + status: 200, + headers: headers ?? {}, + }); + }; + return fn as unknown as typeof fetch; } describe("streamChat", () => { - it("parses NDJSON events and returns conversationId", async () => { - const event1: AgentEvent = { - type: "text-delta", - conversationId: "c1", - turnId: "t1", - delta: "Hello", - }; - const event2: AgentEvent = { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "completed", - }; - - const body = ndjsonLines(event1, event2); - const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" }); - - const { conversationId, events } = await streamChat( - { fetchImpl: fakeFetch }, - { - server: "http://localhost:24203", - request: { message: "hi", model: "openai/gpt-4" }, - }, - ); - - expect(conversationId).toBe("c1"); - - const collected: AgentEvent[] = []; - for await (const e of events) { - collected.push(e); - } - - expect(collected).toEqual([event1, event2]); - }); - - it("handles NDJSON split across chunks", async () => { - const event1: AgentEvent = { - type: "text-delta", - conversationId: "c", - turnId: "t", - delta: "Hi", - }; - const event2: AgentEvent = { - type: "usage", - conversationId: "c", - turnId: "t", - usage: { inputTokens: 10, outputTokens: 5 }, - }; - - const fullNdjson = ndjsonLines(event1, event2); - // Split mid-line: after 20 chars - const mid = 20; - const chunk1 = fullNdjson.slice(0, mid); - const chunk2 = fullNdjson.slice(mid); - - const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, { - "X-Conversation-Id": "c", - }); - - const { events } = await streamChat( - { fetchImpl: fakeFetch }, - { - server: "http://localhost:24203", - request: { message: "hi" }, - }, - ); - - const collected: AgentEvent[] = []; - for await (const e of events) { - collected.push(e); - } - - expect(collected).toEqual([event1, event2]); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("not found", { status: 404 })) as unknown as typeof fetch; - - await expect( - streamChat( - { fetchImpl: fakeFetch }, - { - server: "http://localhost:24203", - request: { message: "hi" }, - }, - ), - ).rejects.toThrow("POST /chat failed with status 404"); - }); - - it("throws when response has no body", async () => { - const fakeFetch = (async (): Promise => - new Response(null, { status: 200 })) as unknown as typeof fetch; - - await expect( - streamChat( - { fetchImpl: fakeFetch }, - { - server: "http://localhost:24203", - request: { message: "hi" }, - }, - ), - ).rejects.toThrow("no body"); - }); - - it("includes reasoningEffort in request body when set", async () => { - let capturedBody: string | undefined; - const doneEvent: AgentEvent = { - type: "done", - conversationId: "c", - turnId: "t", - reason: "completed", - }; - const fakeFetch = async ( - _url: string | URL | Request, - init?: RequestInit, - ): Promise => { - capturedBody = init?.body as string; - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); - controller.close(); - }, - }); - return new Response(stream, { status: 200 }); - }; - - await streamChat( - { fetchImpl: fakeFetch as unknown as typeof fetch }, - { - server: "http://localhost:24203", - request: { message: "hi", reasoningEffort: "xhigh" }, - }, - ); - - expect(capturedBody).toBeDefined(); - const parsed = JSON.parse(capturedBody as string) as ChatRequest; - expect(parsed.reasoningEffort).toBe("xhigh"); - }); - - it("omits reasoningEffort from request body when not set", async () => { - let capturedBody: string | undefined; - const doneEvent: AgentEvent = { - type: "done", - conversationId: "c", - turnId: "t", - reason: "completed", - }; - const fakeFetch = async ( - _url: string | URL | Request, - init?: RequestInit, - ): Promise => { - capturedBody = init?.body as string; - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); - controller.close(); - }, - }); - return new Response(stream, { status: 200 }); - }; - - await streamChat( - { fetchImpl: fakeFetch as unknown as typeof fetch }, - { - server: "http://localhost:24203", - request: { message: "hi" }, - }, - ); - - expect(capturedBody).toBeDefined(); - const parsed = JSON.parse(capturedBody as string) as ChatRequest; - expect(parsed).not.toHaveProperty("reasoningEffort"); - }); + it("parses NDJSON events and returns conversationId", async () => { + const event1: AgentEvent = { + type: "text-delta", + conversationId: "c1", + turnId: "t1", + delta: "Hello", + }; + const event2: AgentEvent = { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "completed", + }; + + const body = ndjsonLines(event1, event2); + const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" }); + + const { conversationId, events } = await streamChat( + { fetchImpl: fakeFetch }, + { + server: "http://localhost:24203", + request: { message: "hi", model: "openai/gpt-4" }, + }, + ); + + expect(conversationId).toBe("c1"); + + const collected: AgentEvent[] = []; + for await (const e of events) { + collected.push(e); + } + + expect(collected).toEqual([event1, event2]); + }); + + it("handles NDJSON split across chunks", async () => { + const event1: AgentEvent = { + type: "text-delta", + conversationId: "c", + turnId: "t", + delta: "Hi", + }; + const event2: AgentEvent = { + type: "usage", + conversationId: "c", + turnId: "t", + usage: { inputTokens: 10, outputTokens: 5 }, + }; + + const fullNdjson = ndjsonLines(event1, event2); + // Split mid-line: after 20 chars + const mid = 20; + const chunk1 = fullNdjson.slice(0, mid); + const chunk2 = fullNdjson.slice(mid); + + const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, { + "X-Conversation-Id": "c", + }); + + const { events } = await streamChat( + { fetchImpl: fakeFetch }, + { + server: "http://localhost:24203", + request: { message: "hi" }, + }, + ); + + const collected: AgentEvent[] = []; + for await (const e of events) { + collected.push(e); + } + + expect(collected).toEqual([event1, event2]); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("not found", { status: 404 })) as unknown as typeof fetch; + + await expect( + streamChat( + { fetchImpl: fakeFetch }, + { + server: "http://localhost:24203", + request: { message: "hi" }, + }, + ), + ).rejects.toThrow("POST /chat failed with status 404"); + }); + + it("throws when response has no body", async () => { + const fakeFetch = (async (): Promise => + new Response(null, { status: 200 })) as unknown as typeof fetch; + + await expect( + streamChat( + { fetchImpl: fakeFetch }, + { + server: "http://localhost:24203", + request: { message: "hi" }, + }, + ), + ).rejects.toThrow("no body"); + }); + + it("includes reasoningEffort in request body when set", async () => { + let capturedBody: string | undefined; + const doneEvent: AgentEvent = { + type: "done", + conversationId: "c", + turnId: "t", + reason: "completed", + }; + const fakeFetch = async ( + _url: string | URL | Request, + init?: RequestInit, + ): Promise => { + capturedBody = init?.body as string; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }; + + await streamChat( + { fetchImpl: fakeFetch as unknown as typeof fetch }, + { + server: "http://localhost:24203", + request: { message: "hi", reasoningEffort: "xhigh" }, + }, + ); + + expect(capturedBody).toBeDefined(); + const parsed = JSON.parse(capturedBody as string) as ChatRequest; + expect(parsed.reasoningEffort).toBe("xhigh"); + }); + + it("omits reasoningEffort from request body when not set", async () => { + let capturedBody: string | undefined; + const doneEvent: AgentEvent = { + type: "done", + conversationId: "c", + turnId: "t", + reason: "completed", + }; + const fakeFetch = async ( + _url: string | URL | Request, + init?: RequestInit, + ): Promise => { + capturedBody = init?.body as string; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }; + + await streamChat( + { fetchImpl: fakeFetch as unknown as typeof fetch }, + { + server: "http://localhost:24203", + request: { message: "hi" }, + }, + ); + + expect(capturedBody).toBeDefined(); + const parsed = JSON.parse(capturedBody as string) as ChatRequest; + expect(parsed).not.toHaveProperty("reasoningEffort"); + }); }); describe("fetchModels", () => { - it("returns ModelsResponse on success", async () => { - const models = { models: ["openai/gpt-4", "anthropic/claude-3"] }; - const fakeFetch = (async (): Promise => - new Response(JSON.stringify(models), { - status: 200, - headers: { "Content-Type": "application/json" }, - })) as unknown as typeof fetch; - - const result = await fetchModels( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203" }, - ); - expect(result).toEqual(models); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("server error", { status: 500 })) as unknown as typeof fetch; - - await expect( - fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }), - ).rejects.toThrow("GET /models failed with status 500"); - }); + it("returns ModelsResponse on success", async () => { + const models = { models: ["openai/gpt-4", "anthropic/claude-3"] }; + const fakeFetch = (async (): Promise => + new Response(JSON.stringify(models), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as unknown as typeof fetch; + + const result = await fetchModels( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203" }, + ); + expect(result).toEqual(models); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("server error", { status: 500 })) as unknown as typeof fetch; + + await expect( + fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }), + ).rejects.toThrow("GET /models failed with status 500"); + }); }); describe("fetchConversations", () => { - it("requests GET /conversations with no query when query omitted", async () => { - let calledUrl: string | undefined; - const list: ConversationListResponse = { - conversations: [ - { - id: "abcdef1234567890", - title: "first", - createdAt: 1, - lastActivityAt: 2, - status: "idle", - workspaceId: "default", - }, - ], - }; - const fakeFetch = (async (url: string | URL | Request): Promise => { - calledUrl = String(url); - return new Response(JSON.stringify(list), { status: 200 }); - }) as unknown as typeof fetch; - - const result = await fetchConversations( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations"); - expect(result).toEqual(list); - }); - - it("appends ?q= when a query is given", async () => { - let calledUrl: string | undefined; - const fakeFetch = (async (url: string | URL | Request): Promise => { - calledUrl = String(url); - return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await fetchConversations( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", query: "abc def" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc+def"); - }); - - it("appends ?workspaceId= when a workspaceId is given", async () => { - let calledUrl: string | undefined; - const fakeFetch = (async (url: string | URL | Request): Promise => { - calledUrl = String(url); - return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await fetchConversations( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", workspaceId: "proj" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations?workspaceId=proj"); - }); - - it("combines ?status= and ?workspaceId= when both are given", async () => { - let calledUrl: string | undefined; - const fakeFetch = (async (url: string | URL | Request): Promise => { - calledUrl = String(url); - return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await fetchConversations( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", status: "active,idle", workspaceId: "proj" }, - ); - expect(calledUrl).toBe( - "http://localhost:24203/conversations?status=active%2Cidle&workspaceId=proj", - ); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("boom", { status: 500 })) as unknown as typeof fetch; - await expect( - fetchConversations({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }), - ).rejects.toThrow("GET /conversations failed with status 500"); - }); + it("requests GET /conversations with no query when query omitted", async () => { + let calledUrl: string | undefined; + const list: ConversationListResponse = { + conversations: [ + { + id: "abcdef1234567890", + title: "first", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + workspaceId: "default", + }, + ], + }; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify(list), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations"); + expect(result).toEqual(list); + }); + + it("appends ?q= when a query is given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", query: "abc def" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc+def"); + }); + + it("appends ?workspaceId= when a workspaceId is given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", workspaceId: "proj" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations?workspaceId=proj"); + }); + + it("combines ?status= and ?workspaceId= when both are given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", status: "active,idle", workspaceId: "proj" }, + ); + expect(calledUrl).toBe( + "http://localhost:24203/conversations?status=active%2Cidle&workspaceId=proj", + ); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("boom", { status: 500 })) as unknown as typeof fetch; + await expect( + fetchConversations({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }), + ).rejects.toThrow("GET /conversations failed with status 500"); + }); }); describe("fetchLastMessage", () => { - it("requests GET /conversations/:id/last and returns the body", async () => { - let calledUrl: string | undefined; - const fakeFetch = (async (url: string | URL | Request): Promise => { - calledUrl = String(url); - return new Response( - JSON.stringify({ conversationId: "c1", content: "hello back", turnId: "t1" }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - - const result = await fetchLastMessage( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations/c1/last"); - expect(result).toEqual({ conversationId: "c1", content: "hello back", turnId: "t1" }); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("nope", { status: 404 })) as unknown as typeof fetch; - await expect( - fetchLastMessage( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1" }, - ), - ).rejects.toThrow("GET /conversations/:id/last failed with status 404"); - }); + it("requests GET /conversations/:id/last and returns the body", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response( + JSON.stringify({ conversationId: "c1", content: "hello back", turnId: "t1" }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const result = await fetchLastMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/last"); + expect(result).toEqual({ conversationId: "c1", content: "hello back", turnId: "t1" }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("nope", { status: 404 })) as unknown as typeof fetch; + await expect( + fetchLastMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ), + ).rejects.toThrow("GET /conversations/:id/last failed with status 404"); + }); }); describe("enqueueMessage", () => { - it("POSTs to /conversations/:id/queue with { text } and returns the body", async () => { - let calledUrl: string | undefined; - let calledInit: RequestInit | undefined; - const fakeFetch = (async ( - url: string | URL | Request, - init?: RequestInit, - ): Promise => { - calledUrl = String(url); - calledInit = init; - return new Response(JSON.stringify({ conversationId: "c1", startedTurn: false, queue: [] }), { - status: 200, - }); - }) as unknown as typeof fetch; - - const result = await enqueueMessage( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations/c1/queue"); - expect(calledInit?.method).toBe("POST"); - expect(JSON.parse(calledInit?.body as string)).toEqual({ text: "hi" }); - expect(result).toEqual({ conversationId: "c1", startedTurn: false, queue: [] }); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("bad", { status: 400 })) as unknown as typeof fetch; - await expect( - enqueueMessage( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, - ), - ).rejects.toThrow("POST /conversations/:id/queue failed with status 400"); - }); + it("POSTs to /conversations/:id/queue with { text } and returns the body", async () => { + let calledUrl: string | undefined; + let calledInit: RequestInit | undefined; + const fakeFetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise => { + calledUrl = String(url); + calledInit = init; + return new Response(JSON.stringify({ conversationId: "c1", startedTurn: false, queue: [] }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const result = await enqueueMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/queue"); + expect(calledInit?.method).toBe("POST"); + expect(JSON.parse(calledInit?.body as string)).toEqual({ text: "hi" }); + expect(result).toEqual({ conversationId: "c1", startedTurn: false, queue: [] }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("bad", { status: 400 })) as unknown as typeof fetch; + await expect( + enqueueMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, + ), + ).rejects.toThrow("POST /conversations/:id/queue failed with status 400"); + }); }); describe("openConversation", () => { - it("POSTs to /conversations/:id/open and returns the body", async () => { - let calledUrl: string | undefined; - let calledInit: RequestInit | undefined; - const fakeFetch = (async ( - url: string | URL | Request, - init?: RequestInit, - ): Promise => { - calledUrl = String(url); - calledInit = init; - return new Response(JSON.stringify({ conversationId: "c1" }), { status: 200 }); - }) as unknown as typeof fetch; - - const result = await openConversation( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1" }, - ); - expect(calledUrl).toBe("http://localhost:24203/conversations/c1/open"); - expect(calledInit?.method).toBe("POST"); - expect(result).toEqual({ conversationId: "c1" }); - }); - - it("throws on non-OK status", async () => { - const fakeFetch = (async (): Promise => - new Response("bad", { status: 500 })) as unknown as typeof fetch; - await expect( - openConversation( - { fetchImpl: fakeFetch }, - { server: "http://localhost:24203", conversationId: "c1" }, - ), - ).rejects.toThrow("POST /conversations/:id/open failed with status 500"); - }); + it("POSTs to /conversations/:id/open and returns the body", async () => { + let calledUrl: string | undefined; + let calledInit: RequestInit | undefined; + const fakeFetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise => { + calledUrl = String(url); + calledInit = init; + return new Response(JSON.stringify({ conversationId: "c1" }), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await openConversation( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/open"); + expect(calledInit?.method).toBe("POST"); + expect(result).toEqual({ conversationId: "c1" }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise => + new Response("bad", { status: 500 })) as unknown as typeof fetch; + await expect( + openConversation( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ), + ).rejects.toThrow("POST /conversations/:id/open failed with status 500"); + }); }); describe("resolveConversationId", () => { - function listFetch(list: ConversationListResponse): typeof fetch { - return (async (): Promise => - new Response(JSON.stringify(list), { status: 200 })) as unknown as typeof fetch; - } - - it("returns the full id on a single match", async () => { - const fetchImpl = listFetch({ - conversations: [ - { - id: "abcdef1234567890abcdef1234567890", - title: "only", - createdAt: 1, - lastActivityAt: 2, - status: "idle", - workspaceId: "default", - }, - ], - }); - const result = await resolveConversationId( - { fetchImpl }, - { server: "http://localhost:24203", shortId: "abcdef" }, - ); - expect(result).toBe("abcdef1234567890abcdef1234567890"); - }); - - it("returns an error object on no match", async () => { - const fetchImpl = listFetch({ conversations: [] }); - const result = await resolveConversationId( - { fetchImpl }, - { server: "http://localhost:24203", shortId: "abcdef" }, - ); - expect(typeof result).toBe("object"); - if (typeof result !== "string") { - expect(result.error).toContain("No conversation matching"); - expect(result.error).toContain("abcdef"); - } - }); - - it("returns an error object listing matches on multiple matches", async () => { - const fetchImpl = listFetch({ - conversations: [ - { - id: "abcdef1234567890aaaaaaaaaaaaaaaa", - title: "first", - createdAt: 1, - lastActivityAt: 2, - status: "idle", - workspaceId: "default", - }, - { - id: "abcdef1234567890bbbbbbbbbbbbbbbb", - title: "second", - createdAt: 1, - lastActivityAt: 3, - status: "idle", - workspaceId: "default", - }, - ], - }); - const result = await resolveConversationId( - { fetchImpl }, - { server: "http://localhost:24203", shortId: "abcdef" }, - ); - expect(typeof result).toBe("object"); - if (typeof result !== "string") { - expect(result.error).toContain("Multiple conversations matching"); - expect(result.error).toContain("abcdef12"); - expect(result.error).toContain("first"); - expect(result.error).toContain("second"); - } - }); - - it("passes through a full UUID (32+ chars) without calling fetch", async () => { - const fetchImpl = (async (): Promise => { - throw new Error("fetch must not be called for a full UUID"); - }) as unknown as typeof fetch; - const fullId = "abcdef1234567890abcdef1234567890"; - const result = await resolveConversationId( - { fetchImpl }, - { server: "http://localhost:24203", shortId: fullId }, - ); - expect(result).toBe(fullId); - }); + function listFetch(list: ConversationListResponse): typeof fetch { + return (async (): Promise => + new Response(JSON.stringify(list), { status: 200 })) as unknown as typeof fetch; + } + + it("returns the full id on a single match", async () => { + const fetchImpl = listFetch({ + conversations: [ + { + id: "abcdef1234567890abcdef1234567890", + title: "only", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + workspaceId: "default", + }, + ], + }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(result).toBe("abcdef1234567890abcdef1234567890"); + }); + + it("returns an error object on no match", async () => { + const fetchImpl = listFetch({ conversations: [] }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(typeof result).toBe("object"); + if (typeof result !== "string") { + expect(result.error).toContain("No conversation matching"); + expect(result.error).toContain("abcdef"); + } + }); + + it("returns an error object listing matches on multiple matches", async () => { + const fetchImpl = listFetch({ + conversations: [ + { + id: "abcdef1234567890aaaaaaaaaaaaaaaa", + title: "first", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + workspaceId: "default", + }, + { + id: "abcdef1234567890bbbbbbbbbbbbbbbb", + title: "second", + createdAt: 1, + lastActivityAt: 3, + status: "idle", + workspaceId: "default", + }, + ], + }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(typeof result).toBe("object"); + if (typeof result !== "string") { + expect(result.error).toContain("Multiple conversations matching"); + expect(result.error).toContain("abcdef12"); + expect(result.error).toContain("first"); + expect(result.error).toContain("second"); + } + }); + + it("passes through a full UUID (32+ chars) without calling fetch", async () => { + const fetchImpl = (async (): Promise => { + throw new Error("fetch must not be called for a full UUID"); + }) as unknown as typeof fetch; + const fullId = "abcdef1234567890abcdef1234567890"; + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: fullId }, + ); + expect(result).toBe(fullId); + }); }); diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index e13842a..8092dfa 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -8,222 +8,222 @@ */ import type { - AgentEvent, - ChatRequest, - CompactResponse, - ConversationListResponse, - LastMessageResponse, - ModelsResponse, - OpenConversationResponse, - QueueResponse, + AgentEvent, + ChatRequest, + CompactResponse, + ConversationListResponse, + LastMessageResponse, + ModelsResponse, + OpenConversationResponse, + QueueResponse, } from "@dispatch/transport-contract"; import { splitNdjsonLines } from "./ndjson.js"; interface FetchDeps { - readonly fetchImpl: typeof fetch; + readonly fetchImpl: typeof fetch; } interface StreamChatOpts { - readonly server: string; - readonly request: ChatRequest; + readonly server: string; + readonly request: ChatRequest; } export async function streamChat( - deps: FetchDeps, - opts: StreamChatOpts, + deps: FetchDeps, + opts: StreamChatOpts, ): Promise<{ conversationId: string | null; events: AsyncIterable }> { - const url = `${opts.server}/chat`; - const res = await deps.fetchImpl(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(opts.request), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`POST /chat failed with status ${res.status}: ${body}`); - } - - const conversationId = res.headers.get("X-Conversation-Id"); - - if (!res.body) { - throw new Error("POST /chat returned no body"); - } - - const events = readNdjsonStream(res.body); - return { conversationId, events }; + const url = `${opts.server}/chat`; + const res = await deps.fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts.request), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /chat failed with status ${res.status}: ${body}`); + } + + const conversationId = res.headers.get("X-Conversation-Id"); + + if (!res.body) { + throw new Error("POST /chat returned no body"); + } + + const events = readNdjsonStream(res.body); + return { conversationId, events }; } async function* readNdjsonStream(body: ReadableStream): AsyncIterable { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const { lines, rest } = splitNdjsonLines(buffer); - buffer = rest; - for (const line of lines) { - yield JSON.parse(line) as AgentEvent; - } - } - if (buffer.length > 0) { - yield JSON.parse(buffer) as AgentEvent; - } - } finally { - reader.releaseLock(); - } + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const { lines, rest } = splitNdjsonLines(buffer); + buffer = rest; + for (const line of lines) { + yield JSON.parse(line) as AgentEvent; + } + } + if (buffer.length > 0) { + yield JSON.parse(buffer) as AgentEvent; + } + } finally { + reader.releaseLock(); + } } interface FetchModelsOpts { - readonly server: string; + readonly server: string; } export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promise { - const url = `${opts.server}/models`; - const res = await deps.fetchImpl(url); + const url = `${opts.server}/models`; + const res = await deps.fetchImpl(url); - if (!res.ok) { - const body = await res.text(); - throw new Error(`GET /models failed with status ${res.status}: ${body}`); - } + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /models failed with status ${res.status}: ${body}`); + } - return (await res.json()) as ModelsResponse; + return (await res.json()) as ModelsResponse; } interface FetchConversationsOpts { - readonly server: string; - readonly query?: string; - readonly status?: string; - readonly workspaceId?: string; + readonly server: string; + readonly query?: string; + readonly status?: string; + readonly workspaceId?: string; } export async function fetchConversations( - deps: FetchDeps, - opts: FetchConversationsOpts, + deps: FetchDeps, + opts: FetchConversationsOpts, ): Promise { - const params = new URLSearchParams(); - if (opts.query !== undefined) params.set("q", opts.query); - if (opts.status !== undefined) params.set("status", opts.status); - if (opts.workspaceId !== undefined) params.set("workspaceId", opts.workspaceId); - const qs = params.toString(); - const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${opts.server}/conversations`; - const res = await deps.fetchImpl(url); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`GET /conversations failed with status ${res.status}: ${body}`); - } - - return (await res.json()) as ConversationListResponse; + const params = new URLSearchParams(); + if (opts.query !== undefined) params.set("q", opts.query); + if (opts.status !== undefined) params.set("status", opts.status); + if (opts.workspaceId !== undefined) params.set("workspaceId", opts.workspaceId); + const qs = params.toString(); + const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${opts.server}/conversations`; + const res = await deps.fetchImpl(url); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /conversations failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as ConversationListResponse; } interface FetchLastMessageOpts { - readonly server: string; - readonly conversationId: string; + readonly server: string; + readonly conversationId: string; } export async function fetchLastMessage( - deps: FetchDeps, - opts: FetchLastMessageOpts, + deps: FetchDeps, + opts: FetchLastMessageOpts, ): Promise { - const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`; - const res = await deps.fetchImpl(url); + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`; + const res = await deps.fetchImpl(url); - if (!res.ok) { - const body = await res.text(); - throw new Error(`GET /conversations/:id/last failed with status ${res.status}: ${body}`); - } + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /conversations/:id/last failed with status ${res.status}: ${body}`); + } - return (await res.json()) as LastMessageResponse; + return (await res.json()) as LastMessageResponse; } interface EnqueueMessageOpts { - readonly server: string; - readonly conversationId: string; - readonly text: string; + readonly server: string; + readonly conversationId: string; + readonly text: string; } export async function enqueueMessage( - deps: FetchDeps, - opts: EnqueueMessageOpts, + deps: FetchDeps, + opts: EnqueueMessageOpts, ): Promise { - const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/queue`; - const res = await deps.fetchImpl(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: opts.text }), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`POST /conversations/:id/queue failed with status ${res.status}: ${body}`); - } - - return (await res.json()) as QueueResponse; + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/queue`; + const res = await deps.fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: opts.text }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/queue failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as QueueResponse; } interface OpenConversationOpts { - readonly server: string; - readonly conversationId: string; + readonly server: string; + readonly conversationId: string; } export async function openConversation( - deps: FetchDeps, - opts: OpenConversationOpts, + deps: FetchDeps, + opts: OpenConversationOpts, ): Promise { - const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`; - const res = await deps.fetchImpl(url, { method: "POST" }); + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`; + const res = await deps.fetchImpl(url, { method: "POST" }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`POST /conversations/:id/open failed with status ${res.status}: ${body}`); - } + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/open failed with status ${res.status}: ${body}`); + } - return (await res.json()) as OpenConversationResponse; + return (await res.json()) as OpenConversationResponse; } interface CompactConversationOpts { - readonly server: string; - readonly conversationId: string; + readonly server: string; + readonly conversationId: string; } export async function compactConversation( - deps: FetchDeps, - opts: CompactConversationOpts, + deps: FetchDeps, + opts: CompactConversationOpts, ): Promise { - const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/compact`; - const res = await deps.fetchImpl(url, { method: "POST" }); + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/compact`; + const res = await deps.fetchImpl(url, { method: "POST" }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`POST /conversations/:id/compact failed with status ${res.status}: ${body}`); - } + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/compact failed with status ${res.status}: ${body}`); + } - return (await res.json()) as CompactResponse; + return (await res.json()) as CompactResponse; } interface StopTurnOpts { - readonly server: string; - readonly conversationId: string; + readonly server: string; + readonly conversationId: string; } export async function stopTurn( - deps: FetchDeps, - opts: StopTurnOpts, + deps: FetchDeps, + opts: StopTurnOpts, ): Promise<{ conversationId: string; abortedTurn: boolean }> { - const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/stop`; - const res = await deps.fetchImpl(url, { method: "POST" }); + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/stop`; + const res = await deps.fetchImpl(url, { method: "POST" }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`POST /conversations/:id/stop failed with status ${res.status}: ${body}`); - } + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/stop failed with status ${res.status}: ${body}`); + } - return (await res.json()) as { conversationId: string; abortedTurn: boolean }; + return (await res.json()) as { conversationId: string; abortedTurn: boolean }; } /** @@ -233,8 +233,8 @@ export async function stopTurn( export type ConversationIdResolution = string | { readonly error: string }; interface ResolveConversationIdOpts { - readonly server: string; - readonly shortId: string; + readonly server: string; + readonly shortId: string; } /** @@ -244,28 +244,28 @@ interface ResolveConversationIdOpts { * the candidate short ids + titles so the user can disambiguate. */ export async function resolveConversationId( - deps: FetchDeps, - opts: ResolveConversationIdOpts, + deps: FetchDeps, + opts: ResolveConversationIdOpts, ): Promise { - if (opts.shortId.length >= 32) { - return opts.shortId; - } - - const list = await fetchConversations(deps, { server: opts.server, query: opts.shortId }); - const matches = list.conversations; - - if (matches.length === 0) { - return { error: `No conversation matching "${opts.shortId}"` }; - } - - if (matches.length === 1) { - const only = matches[0]; - if (only === undefined) return { error: `No conversation matching "${opts.shortId}"` }; - return only.id; - } - - const lines = matches.map((m) => `${m.id.slice(0, 8)} ${m.title}`).join("\n"); - return { - error: `Multiple conversations matching "${opts.shortId}"\n${lines}`, - }; + if (opts.shortId.length >= 32) { + return opts.shortId; + } + + const list = await fetchConversations(deps, { server: opts.server, query: opts.shortId }); + const matches = list.conversations; + + if (matches.length === 0) { + return { error: `No conversation matching "${opts.shortId}"` }; + } + + if (matches.length === 1) { + const only = matches[0]; + if (only === undefined) return { error: `No conversation matching "${opts.shortId}"` }; + return only.id; + } + + const lines = matches.map((m) => `${m.id.slice(0, 8)} ${m.title}`).join("\n"); + return { + error: `Multiple conversations matching "${opts.shortId}"\n${lines}`, + }; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6f16547..23d9c9d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,20 +7,20 @@ export { type ParsedCommand, parseArgs } from "./args.js"; export { formatCatalog } from "./catalog.js"; export { - type ConversationIdResolution, - enqueueMessage, - fetchConversations, - fetchLastMessage, - fetchModels, - openConversation, - resolveConversationId, - streamChat, + type ConversationIdResolution, + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + streamChat, } from "./http.js"; export { buildChatRequest, composeMessage } from "./message.js"; export { type SplitResult, splitNdjsonLines } from "./ndjson.js"; export { - extractLastText, - formatConversationList, - formatRelativeTime, - renderEvent, + extractLastText, + formatConversationList, + formatRelativeTime, + renderEvent, } from "./render.js"; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index cba0de7..9fca347 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -9,15 +9,15 @@ import { readFile } from "node:fs/promises"; import { parseArgs } from "./args.js"; import { formatCatalog } from "./catalog.js"; import { - compactConversation, - enqueueMessage, - fetchConversations, - fetchLastMessage, - fetchModels, - openConversation, - resolveConversationId, - stopTurn, - streamChat, + compactConversation, + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + stopTurn, + streamChat, } from "./http.js"; import { buildChatRequest, composeMessage } from "./message.js"; import { extractLastText, formatConversationList, renderEvent } from "./render.js"; @@ -36,214 +36,214 @@ const USAGE = `Usage: Effort levels: low, medium, high (default), xhigh, max`; async function main(): Promise { - const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`; - const parsed = parseArgs(process.argv.slice(2), { defaultServer }); + const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`; + const parsed = parseArgs(process.argv.slice(2), { defaultServer }); - switch (parsed.kind) { - case "help": - process.stdout.write(`${USAGE}\n`); - process.exit(0); - break; - case "error": - process.stderr.write(`Error: ${parsed.message}\n`); - process.exit(1); - break; - case "models": { - const result = await fetchModels({ fetchImpl: globalThis.fetch }, { server: parsed.server }); - process.stdout.write(`${formatCatalog(result)}\n`); - break; - } - case "list": { - const status = parsed.all ? undefined : (parsed.status ?? "active,idle"); - const result = await fetchConversations( - { fetchImpl: globalThis.fetch }, - { - server: parsed.server, - ...(parsed.query !== undefined && { query: parsed.query }), - ...(status !== undefined && { status }), - ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), - }, - ); - const table = formatConversationList(result.conversations, Date.now()); - if (table.length > 0) process.stdout.write(`${table}\n`); - break; - } - case "read": { - const resolved = await resolveConversationId( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, shortId: parsed.conversationId }, - ); - if (typeof resolved !== "string") { - process.stderr.write(`${resolved.error}\n`); - process.exit(1); - } - const last = await fetchLastMessage( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId: resolved }, - ); - if (last.content.length > 0) process.stdout.write(`${last.content}\n`); - break; - } - case "compact": { - const resolved = await resolveConversationId( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, shortId: parsed.conversationId }, - ); - if (typeof resolved !== "string") { - process.stderr.write(`${resolved.error}\n`); - process.exit(1); - } - const result = await compactConversation( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId: resolved }, - ); - process.stdout.write( - `Compacted ${resolved}: ${result.messagesSummarized} messages summarized, ${result.messagesKept} kept.\n`, - ); - break; - } - case "stop": { - const resolved = await resolveConversationId( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, shortId: parsed.conversationId }, - ); - if (typeof resolved !== "string") { - process.stderr.write(`${resolved.error}\n`); - process.exit(1); - } - const result = await stopTurn( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId: resolved }, - ); - process.stdout.write( - result.abortedTurn - ? `Stopped generation for ${resolved}\n` - : `No active generation for ${resolved}\n`, - ); - break; - } - case "open": { - const resolved = await resolveConversationId( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, shortId: parsed.conversationId }, - ); - if (typeof resolved !== "string") { - process.stderr.write(`${resolved.error}\n`); - process.exit(1); - } - await openConversation( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId: resolved }, - ); - process.stdout.write(`Signaled frontend to open ${resolved}\n`); - break; - } - case "send": { - const resolved = await resolveConversationId( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, shortId: parsed.conversationId }, - ); - if (typeof resolved !== "string") { - process.stderr.write(`${resolved.error}\n`); - process.exit(1); - } - const conversationId = resolved; + switch (parsed.kind) { + case "help": + process.stdout.write(`${USAGE}\n`); + process.exit(0); + break; + case "error": + process.stderr.write(`Error: ${parsed.message}\n`); + process.exit(1); + break; + case "models": { + const result = await fetchModels({ fetchImpl: globalThis.fetch }, { server: parsed.server }); + process.stdout.write(`${formatCatalog(result)}\n`); + break; + } + case "list": { + const status = parsed.all ? undefined : (parsed.status ?? "active,idle"); + const result = await fetchConversations( + { fetchImpl: globalThis.fetch }, + { + server: parsed.server, + ...(parsed.query !== undefined && { query: parsed.query }), + ...(status !== undefined && { status }), + ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), + }, + ); + const table = formatConversationList(result.conversations, Date.now()); + if (table.length > 0) process.stdout.write(`${table}\n`); + break; + } + case "read": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const last = await fetchLastMessage( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId: resolved }, + ); + if (last.content.length > 0) process.stdout.write(`${last.content}\n`); + break; + } + case "compact": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const result = await compactConversation( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId: resolved }, + ); + process.stdout.write( + `Compacted ${resolved}: ${result.messagesSummarized} messages summarized, ${result.messagesKept} kept.\n`, + ); + break; + } + case "stop": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const result = await stopTurn( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId: resolved }, + ); + process.stdout.write( + result.abortedTurn + ? `Stopped generation for ${resolved}\n` + : `No active generation for ${resolved}\n`, + ); + break; + } + case "open": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + await openConversation( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId: resolved }, + ); + process.stdout.write(`Signaled frontend to open ${resolved}\n`); + break; + } + case "send": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const conversationId = resolved; - if (parsed.open) { - await openConversation( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId }, - ); - process.stdout.write(`Signaled frontend to open ${conversationId}\n`); - } + if (parsed.open) { + await openConversation( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId }, + ); + process.stdout.write(`Signaled frontend to open ${conversationId}\n`); + } - let fileContent: string | undefined; - if (parsed.file) { - fileContent = await readFile(parsed.file, "utf-8"); - } - const message = composeMessage({ - ...(parsed.text !== undefined && { text: parsed.text }), - ...(parsed.file !== undefined && { file: parsed.file }), - ...(fileContent !== undefined && { fileContent }), - }); + let fileContent: string | undefined; + if (parsed.file) { + fileContent = await readFile(parsed.file, "utf-8"); + } + const message = composeMessage({ + ...(parsed.text !== undefined && { text: parsed.text }), + ...(parsed.file !== undefined && { file: parsed.file }), + ...(fileContent !== undefined && { fileContent }), + }); - if (parsed.queue) { - const queued = await enqueueMessage( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId, text: message }, - ); - const line = queued.startedTurn - ? `Started turn for ${conversationId}` - : `Queued to ${conversationId}`; - process.stdout.write(`${line}\n`); - } else { - const request = { - conversationId, - message, - ...(parsed.cwd !== undefined && { cwd: parsed.cwd }), - ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }), - ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), - }; - const { events } = await streamChat( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, request }, - ); - const collected = []; - for await (const event of events) { - if (event.type === "error") { - process.stderr.write(`${event.message}\n`); - process.exit(1); - } - collected.push(event); - if (event.type === "done") break; - } - process.stdout.write(`${extractLastText(collected)}\n`); - process.stdout.write(`[conversation] ${conversationId}\n`); - } - break; - } - case "chat": { - let fileContent: string | undefined; - if (parsed.file) { - fileContent = await readFile(parsed.file, "utf-8"); - } + if (parsed.queue) { + const queued = await enqueueMessage( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId, text: message }, + ); + const line = queued.startedTurn + ? `Started turn for ${conversationId}` + : `Queued to ${conversationId}`; + process.stdout.write(`${line}\n`); + } else { + const request = { + conversationId, + message, + ...(parsed.cwd !== undefined && { cwd: parsed.cwd }), + ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }), + ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), + }; + const { events } = await streamChat( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, request }, + ); + const collected = []; + for await (const event of events) { + if (event.type === "error") { + process.stderr.write(`${event.message}\n`); + process.exit(1); + } + collected.push(event); + if (event.type === "done") break; + } + process.stdout.write(`${extractLastText(collected)}\n`); + process.stdout.write(`[conversation] ${conversationId}\n`); + } + break; + } + case "chat": { + let fileContent: string | undefined; + if (parsed.file) { + fileContent = await readFile(parsed.file, "utf-8"); + } - const cwd = parsed.cwd ?? process.cwd(); - const message = composeMessage({ - ...(parsed.text !== undefined && { text: parsed.text }), - ...(parsed.file !== undefined && { file: parsed.file }), - ...(fileContent !== undefined && { fileContent }), - }); - const request = buildChatRequest(parsed, { cwd, message }); + const cwd = parsed.cwd ?? process.cwd(); + const message = composeMessage({ + ...(parsed.text !== undefined && { text: parsed.text }), + ...(parsed.file !== undefined && { file: parsed.file }), + ...(fileContent !== undefined && { fileContent }), + }); + const request = buildChatRequest(parsed, { cwd, message }); - const { conversationId, events } = await streamChat( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, request }, - ); + const { conversationId, events } = await streamChat( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, request }, + ); - if (conversationId && parsed.open) { - await openConversation( - { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId }, - ); - process.stdout.write(`Signaled frontend to open ${conversationId}\n`); - } + if (conversationId && parsed.open) { + await openConversation( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId }, + ); + process.stdout.write(`Signaled frontend to open ${conversationId}\n`); + } - for await (const event of events) { - const rendered = renderEvent(event, { showReasoning: parsed.showReasoning }); - if (rendered?.stdout) process.stdout.write(rendered.stdout); - if (rendered?.stderr) process.stderr.write(rendered.stderr); - } + for await (const event of events) { + const rendered = renderEvent(event, { showReasoning: parsed.showReasoning }); + if (rendered?.stdout) process.stdout.write(rendered.stdout); + if (rendered?.stderr) process.stderr.write(rendered.stderr); + } - if (conversationId) { - process.stdout.write(`\n[conversation] ${conversationId}\n`); - } - break; - } - } + if (conversationId) { + process.stdout.write(`\n[conversation] ${conversationId}\n`); + } + break; + } + } } main().catch((err: unknown) => { - process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); }); diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts index 440ec85..536d64f 100644 --- a/packages/cli/src/message.test.ts +++ b/packages/cli/src/message.test.ts @@ -3,145 +3,145 @@ import { parseArgs } from "./args.js"; import { buildChatRequest, composeMessage } from "./message.js"; describe("composeMessage", () => { - it("returns text only", () => { - expect(composeMessage({ text: "hello" })).toBe("hello"); - }); - - it("returns file only with neutral label", () => { - expect(composeMessage({ file: "foo.txt", fileContent: "contents" })).toBe( - "Attached file (foo.txt):\ncontents", - ); - }); - - it("returns text + labeled file block", () => { - expect(composeMessage({ text: "check this", file: "a.ts", fileContent: "const x = 1;" })).toBe( - "check this\n\nAttached file (a.ts):\nconst x = 1;", - ); - }); - - it("handles missing fileContent gracefully", () => { - expect(composeMessage({ file: "f.txt" })).toBe("Attached file (f.txt):\n"); - }); - - it("uses basename for absolute paths", () => { - expect(composeMessage({ file: "/tmp/opencode/demo/note.txt", fileContent: "hello" })).toBe( - "Attached file (note.txt):\nhello", - ); - }); - - it("handles empty input", () => { - expect(composeMessage({})).toBe(""); - }); + it("returns text only", () => { + expect(composeMessage({ text: "hello" })).toBe("hello"); + }); + + it("returns file only with neutral label", () => { + expect(composeMessage({ file: "foo.txt", fileContent: "contents" })).toBe( + "Attached file (foo.txt):\ncontents", + ); + }); + + it("returns text + labeled file block", () => { + expect(composeMessage({ text: "check this", file: "a.ts", fileContent: "const x = 1;" })).toBe( + "check this\n\nAttached file (a.ts):\nconst x = 1;", + ); + }); + + it("handles missing fileContent gracefully", () => { + expect(composeMessage({ file: "f.txt" })).toBe("Attached file (f.txt):\n"); + }); + + it("uses basename for absolute paths", () => { + expect(composeMessage({ file: "/tmp/opencode/demo/note.txt", fileContent: "hello" })).toBe( + "Attached file (note.txt):\nhello", + ); + }); + + it("handles empty input", () => { + expect(composeMessage({})).toBe(""); + }); }); describe("buildChatRequest", () => { - it("maps fields correctly", () => { - const req = buildChatRequest( - { - modelName: "cred/model", - text: "hi", - showReasoning: false, - }, - { cwd: "/work", message: "hi" }, - ); - expect(req).toEqual({ - message: "hi", - model: "cred/model", - cwd: "/work", - }); - }); - - it("includes conversationId when provided", () => { - const req = buildChatRequest( - { - modelName: "m", - text: "x", - conversationId: "conv-123", - showReasoning: false, - }, - { cwd: "/work", message: "x" }, - ); - expect(req.conversationId).toBe("conv-123"); - }); - - it("omits conversationId when not provided", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", showReasoning: false }, - { cwd: "/work", message: "x" }, - ); - expect(req).not.toHaveProperty("conversationId"); - }); - - it("uses explicit cwd over context cwd", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", cwd: "/explicit", showReasoning: false }, - { cwd: "/default", message: "x" }, - ); - expect(req.cwd).toBe("/explicit"); - }); - - it("includes reasoningEffort when provided", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", reasoningEffort: "xhigh", showReasoning: false }, - { cwd: "/work", message: "x" }, - ); - expect(req.reasoningEffort).toBe("xhigh"); - }); - - it("omits reasoningEffort when not provided", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", showReasoning: false }, - { cwd: "/work", message: "x" }, - ); - expect(req).not.toHaveProperty("reasoningEffort"); - }); - - it("includes workspaceId when provided", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false }, - { cwd: "/work", message: "x" }, - ); - expect(req.workspaceId).toBe("my-work"); - }); - - it("omits workspaceId when not provided", () => { - const req = buildChatRequest( - { modelName: "m", text: "x", showReasoning: false }, - { cwd: "/work", message: "x" }, - ); - expect(req).not.toHaveProperty("workspaceId"); - }); + it("maps fields correctly", () => { + const req = buildChatRequest( + { + modelName: "cred/model", + text: "hi", + showReasoning: false, + }, + { cwd: "/work", message: "hi" }, + ); + expect(req).toEqual({ + message: "hi", + model: "cred/model", + cwd: "/work", + }); + }); + + it("includes conversationId when provided", () => { + const req = buildChatRequest( + { + modelName: "m", + text: "x", + conversationId: "conv-123", + showReasoning: false, + }, + { cwd: "/work", message: "x" }, + ); + expect(req.conversationId).toBe("conv-123"); + }); + + it("omits conversationId when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("conversationId"); + }); + + it("uses explicit cwd over context cwd", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", cwd: "/explicit", showReasoning: false }, + { cwd: "/default", message: "x" }, + ); + expect(req.cwd).toBe("/explicit"); + }); + + it("includes reasoningEffort when provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", reasoningEffort: "xhigh", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req.reasoningEffort).toBe("xhigh"); + }); + + it("omits reasoningEffort when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("reasoningEffort"); + }); + + it("includes workspaceId when provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req.workspaceId).toBe("my-work"); + }); + + it("omits workspaceId when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("workspaceId"); + }); }); describe("workspace flag → ChatRequest", () => { - const defaultServer = "http://localhost:24203"; - - it("--workspace flag sets workspaceId on request", () => { - const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], { - defaultServer, - }); - expect(parsed.kind).toBe("chat"); - if (parsed.kind !== "chat") return; - const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); - expect(req.workspaceId).toBe("my-work"); - }); - - it("--workspace flag omitted sends no workspaceId", () => { - const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer }); - expect(parsed.kind).toBe("chat"); - if (parsed.kind !== "chat") return; - const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); - expect(req.workspaceId).toBeUndefined(); - expect(req).not.toHaveProperty("workspaceId"); - }); - - it("-w shorthand sets workspaceId on request", () => { - const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], { - defaultServer, - }); - expect(parsed.kind).toBe("chat"); - if (parsed.kind !== "chat") return; - const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); - expect(req.workspaceId).toBe("shorthand"); - }); + const defaultServer = "http://localhost:24203"; + + it("--workspace flag sets workspaceId on request", () => { + const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], { + defaultServer, + }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBe("my-work"); + }); + + it("--workspace flag omitted sends no workspaceId", () => { + const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBeUndefined(); + expect(req).not.toHaveProperty("workspaceId"); + }); + + it("-w shorthand sets workspaceId on request", () => { + const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], { + defaultServer, + }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBe("shorthand"); + }); }); diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts index ec4d6d1..ddbec6b 100644 --- a/packages/cli/src/message.ts +++ b/packages/cli/src/message.ts @@ -8,52 +8,52 @@ import type { ChatRequest, ReasoningEffort } from "@dispatch/transport-contract"; interface ComposeInput { - readonly text?: string; - readonly file?: string; - readonly fileContent?: string; + readonly text?: string; + readonly file?: string; + readonly fileContent?: string; } function basename(filePath: string): string { - const segments = filePath.split("/"); - return segments[segments.length - 1] ?? filePath; + const segments = filePath.split("/"); + return segments[segments.length - 1] ?? filePath; } export function composeMessage(input: ComposeInput): string { - const { text, fileContent } = input; - const file = input.file; - - if (text && file) { - return `${text}\n\nAttached file (${basename(file)}):\n${fileContent ?? ""}`; - } - if (file) { - return `Attached file (${basename(file)}):\n${fileContent ?? ""}`; - } - return text ?? ""; + const { text, fileContent } = input; + const file = input.file; + + if (text && file) { + return `${text}\n\nAttached file (${basename(file)}):\n${fileContent ?? ""}`; + } + if (file) { + return `Attached file (${basename(file)}):\n${fileContent ?? ""}`; + } + return text ?? ""; } interface ChatCmd { - readonly modelName: string; - readonly text?: string | undefined; - readonly file?: string | undefined; - readonly cwd?: string | undefined; - readonly conversationId?: string | undefined; - readonly reasoningEffort?: ReasoningEffort | undefined; - readonly workspaceId?: string | undefined; - readonly showReasoning: boolean; + readonly modelName: string; + readonly text?: string | undefined; + readonly file?: string | undefined; + readonly cwd?: string | undefined; + readonly conversationId?: string | undefined; + readonly reasoningEffort?: ReasoningEffort | undefined; + readonly workspaceId?: string | undefined; + readonly showReasoning: boolean; } interface BuildCtx { - readonly cwd: string; - readonly message: string; + readonly cwd: string; + readonly message: string; } export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest { - return { - message: ctx.message, - model: cmd.modelName, - ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }), - ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }), - ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }), - ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }), - }; + return { + message: ctx.message, + model: cmd.modelName, + ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }), + ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }), + ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }), + ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }), + }; } diff --git a/packages/cli/src/ndjson.test.ts b/packages/cli/src/ndjson.test.ts index 8ed3bff..5c74dfb 100644 --- a/packages/cli/src/ndjson.test.ts +++ b/packages/cli/src/ndjson.test.ts @@ -2,47 +2,47 @@ import { describe, expect, it } from "vitest"; import { splitNdjsonLines } from "./ndjson.js"; describe("splitNdjsonLines", () => { - it("splits complete lines", () => { - const result = splitNdjsonLines('{"a":1}\n{"b":2}\n'); - expect(result.lines).toEqual(['{"a":1}', '{"b":2}']); - expect(result.rest).toBe(""); - }); - - it("keeps incomplete trailing line in rest", () => { - const result = splitNdjsonLines('{"a":1}\n{"b":2'); - expect(result.lines).toEqual(['{"a":1}']); - expect(result.rest).toBe('{"b":2'); - }); - - it("returns empty lines array for single incomplete line", () => { - const result = splitNdjsonLines('{"a":1'); - expect(result.lines).toEqual([]); - expect(result.rest).toBe('{"a":1'); - }); - - it("filters out empty lines", () => { - const result = splitNdjsonLines('{"a":1}\n\n{"b":2}\n'); - expect(result.lines).toEqual(['{"a":1}', '{"b":2}']); - expect(result.rest).toBe(""); - }); - - it("handles a line split across two buffers", () => { - const buf1 = '{"a":1}\n{"b":'; - const buf2 = '2}\n{"c":3}\n'; - - const r1 = splitNdjsonLines(buf1); - expect(r1.lines).toEqual(['{"a":1}']); - expect(r1.rest).toBe('{"b":'); - - const combined = r1.rest + buf2; - const r2 = splitNdjsonLines(combined); - expect(r2.lines).toEqual(['{"b":2}', '{"c":3}']); - expect(r2.rest).toBe(""); - }); - - it("handles empty buffer", () => { - const result = splitNdjsonLines(""); - expect(result.lines).toEqual([]); - expect(result.rest).toBe(""); - }); + it("splits complete lines", () => { + const result = splitNdjsonLines('{"a":1}\n{"b":2}\n'); + expect(result.lines).toEqual(['{"a":1}', '{"b":2}']); + expect(result.rest).toBe(""); + }); + + it("keeps incomplete trailing line in rest", () => { + const result = splitNdjsonLines('{"a":1}\n{"b":2'); + expect(result.lines).toEqual(['{"a":1}']); + expect(result.rest).toBe('{"b":2'); + }); + + it("returns empty lines array for single incomplete line", () => { + const result = splitNdjsonLines('{"a":1'); + expect(result.lines).toEqual([]); + expect(result.rest).toBe('{"a":1'); + }); + + it("filters out empty lines", () => { + const result = splitNdjsonLines('{"a":1}\n\n{"b":2}\n'); + expect(result.lines).toEqual(['{"a":1}', '{"b":2}']); + expect(result.rest).toBe(""); + }); + + it("handles a line split across two buffers", () => { + const buf1 = '{"a":1}\n{"b":'; + const buf2 = '2}\n{"c":3}\n'; + + const r1 = splitNdjsonLines(buf1); + expect(r1.lines).toEqual(['{"a":1}']); + expect(r1.rest).toBe('{"b":'); + + const combined = r1.rest + buf2; + const r2 = splitNdjsonLines(combined); + expect(r2.lines).toEqual(['{"b":2}', '{"c":3}']); + expect(r2.rest).toBe(""); + }); + + it("handles empty buffer", () => { + const result = splitNdjsonLines(""); + expect(result.lines).toEqual([]); + expect(result.rest).toBe(""); + }); }); diff --git a/packages/cli/src/ndjson.ts b/packages/cli/src/ndjson.ts index 57093c1..5902354 100644 --- a/packages/cli/src/ndjson.ts +++ b/packages/cli/src/ndjson.ts @@ -6,13 +6,13 @@ */ export interface SplitResult { - readonly lines: readonly string[]; - readonly rest: string; + readonly lines: readonly string[]; + readonly rest: string; } export function splitNdjsonLines(buffer: string): SplitResult { - const parts = buffer.split("\n"); - const rest = parts[parts.length - 1] ?? ""; - const lines = parts.slice(0, -1).filter((l) => l.length > 0); - return { lines, rest }; + const parts = buffer.split("\n"); + const rest = parts[parts.length - 1] ?? ""; + const lines = parts.slice(0, -1).filter((l) => l.length > 0); + return { lines, rest }; } diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts index 1c92733..5e35a50 100644 --- a/packages/cli/src/render.test.ts +++ b/packages/cli/src/render.test.ts @@ -2,253 +2,253 @@ import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract" import type { StepId } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - extractLastText, - formatConversationList, - formatRelativeTime, - renderEvent, + extractLastText, + formatConversationList, + formatRelativeTime, + renderEvent, } from "./render.js"; describe("renderEvent", () => { - const opts = { showReasoning: false }; - const optsReasoning = { showReasoning: true }; - - it("renders text-delta as stdout", () => { - const e: AgentEvent = { - type: "text-delta", - conversationId: "c", - turnId: "t", - delta: "hello", - }; - expect(renderEvent(e, opts)).toEqual({ stdout: "hello" }); - }); - - it("hides reasoning-delta by default", () => { - const e: AgentEvent = { - type: "reasoning-delta", - conversationId: "c", - turnId: "t", - delta: "thinking...", - }; - expect(renderEvent(e, opts)).toBeUndefined(); - }); - - it("shows reasoning-delta when showReasoning is true", () => { - const e: AgentEvent = { - type: "reasoning-delta", - conversationId: "c", - turnId: "t", - delta: "thinking...", - }; - expect(renderEvent(e, optsReasoning)).toEqual({ stdout: "thinking..." }); - }); - - it("renders tool-call with name and JSON input", () => { - const e: AgentEvent = { - type: "tool-call", - conversationId: "c", - turnId: "t", - stepId: "t1#0" as StepId, - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/foo" }, - }; - const result = renderEvent(e, opts); - expect(result?.stdout).toContain("[tool] read_file"); - expect(result?.stdout).toContain('"/foo"'); - }); - - it("renders tool-output data as stdout", () => { - const e: AgentEvent = { - type: "tool-output", - conversationId: "c", - turnId: "t", - toolCallId: "tc1", - data: "some output", - stream: "stdout", - }; - expect(renderEvent(e, opts)).toEqual({ stdout: "some output" }); - }); - - it("renders tool-result without error", () => { - const e: AgentEvent = { - type: "tool-result", - conversationId: "c", - turnId: "t", - stepId: "t1#0" as StepId, - toolCallId: "tc1", - toolName: "read_file", - content: "file contents", - isError: false, - }; - expect(renderEvent(e, opts)).toEqual({ - stdout: "[tool:read_file] file contents\n", - }); - }); - - it("renders tool-result with error flag", () => { - const e: AgentEvent = { - type: "tool-result", - conversationId: "c", - turnId: "t", - stepId: "t1#0" as StepId, - toolCallId: "tc1", - toolName: "read_file", - content: "not found", - isError: true, - }; - expect(renderEvent(e, opts)).toEqual({ - stdout: "[tool:read_file] ERROR not found\n", - }); - }); - - it("renders usage event", () => { - const e: AgentEvent = { - type: "usage", - conversationId: "c", - turnId: "t", - usage: { inputTokens: 100, outputTokens: 50 }, - }; - expect(renderEvent(e, opts)).toEqual({ - stdout: "\n[usage] in=100 out=50\n", - }); - }); - - it("renders error event to stderr", () => { - const e: AgentEvent = { - type: "error", - conversationId: "c", - turnId: "t", - message: "something went wrong", - }; - expect(renderEvent(e, opts)).toEqual({ - stderr: "[error] something went wrong\n", - }); - }); - - it("returns undefined for status", () => { - const e: AgentEvent = { - type: "status", - conversationId: "c", - status: "running", - }; - expect(renderEvent(e, opts)).toBeUndefined(); - }); - - it("returns undefined for turn-start", () => { - const e: AgentEvent = { type: "turn-start", conversationId: "c", turnId: "t" }; - expect(renderEvent(e, opts)).toBeUndefined(); - }); - - it("returns undefined for turn-sealed", () => { - const e: AgentEvent = { type: "turn-sealed", conversationId: "c", turnId: "t" }; - expect(renderEvent(e, opts)).toBeUndefined(); - }); - - it("returns undefined for done", () => { - const e: AgentEvent = { - type: "done", - conversationId: "c", - turnId: "t", - reason: "completed", - }; - expect(renderEvent(e, opts)).toBeUndefined(); - }); + const opts = { showReasoning: false }; + const optsReasoning = { showReasoning: true }; + + it("renders text-delta as stdout", () => { + const e: AgentEvent = { + type: "text-delta", + conversationId: "c", + turnId: "t", + delta: "hello", + }; + expect(renderEvent(e, opts)).toEqual({ stdout: "hello" }); + }); + + it("hides reasoning-delta by default", () => { + const e: AgentEvent = { + type: "reasoning-delta", + conversationId: "c", + turnId: "t", + delta: "thinking...", + }; + expect(renderEvent(e, opts)).toBeUndefined(); + }); + + it("shows reasoning-delta when showReasoning is true", () => { + const e: AgentEvent = { + type: "reasoning-delta", + conversationId: "c", + turnId: "t", + delta: "thinking...", + }; + expect(renderEvent(e, optsReasoning)).toEqual({ stdout: "thinking..." }); + }); + + it("renders tool-call with name and JSON input", () => { + const e: AgentEvent = { + type: "tool-call", + conversationId: "c", + turnId: "t", + stepId: "t1#0" as StepId, + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/foo" }, + }; + const result = renderEvent(e, opts); + expect(result?.stdout).toContain("[tool] read_file"); + expect(result?.stdout).toContain('"/foo"'); + }); + + it("renders tool-output data as stdout", () => { + const e: AgentEvent = { + type: "tool-output", + conversationId: "c", + turnId: "t", + toolCallId: "tc1", + data: "some output", + stream: "stdout", + }; + expect(renderEvent(e, opts)).toEqual({ stdout: "some output" }); + }); + + it("renders tool-result without error", () => { + const e: AgentEvent = { + type: "tool-result", + conversationId: "c", + turnId: "t", + stepId: "t1#0" as StepId, + toolCallId: "tc1", + toolName: "read_file", + content: "file contents", + isError: false, + }; + expect(renderEvent(e, opts)).toEqual({ + stdout: "[tool:read_file] file contents\n", + }); + }); + + it("renders tool-result with error flag", () => { + const e: AgentEvent = { + type: "tool-result", + conversationId: "c", + turnId: "t", + stepId: "t1#0" as StepId, + toolCallId: "tc1", + toolName: "read_file", + content: "not found", + isError: true, + }; + expect(renderEvent(e, opts)).toEqual({ + stdout: "[tool:read_file] ERROR not found\n", + }); + }); + + it("renders usage event", () => { + const e: AgentEvent = { + type: "usage", + conversationId: "c", + turnId: "t", + usage: { inputTokens: 100, outputTokens: 50 }, + }; + expect(renderEvent(e, opts)).toEqual({ + stdout: "\n[usage] in=100 out=50\n", + }); + }); + + it("renders error event to stderr", () => { + const e: AgentEvent = { + type: "error", + conversationId: "c", + turnId: "t", + message: "something went wrong", + }; + expect(renderEvent(e, opts)).toEqual({ + stderr: "[error] something went wrong\n", + }); + }); + + it("returns undefined for status", () => { + const e: AgentEvent = { + type: "status", + conversationId: "c", + status: "running", + }; + expect(renderEvent(e, opts)).toBeUndefined(); + }); + + it("returns undefined for turn-start", () => { + const e: AgentEvent = { type: "turn-start", conversationId: "c", turnId: "t" }; + expect(renderEvent(e, opts)).toBeUndefined(); + }); + + it("returns undefined for turn-sealed", () => { + const e: AgentEvent = { type: "turn-sealed", conversationId: "c", turnId: "t" }; + expect(renderEvent(e, opts)).toBeUndefined(); + }); + + it("returns undefined for done", () => { + const e: AgentEvent = { + type: "done", + conversationId: "c", + turnId: "t", + reason: "completed", + }; + expect(renderEvent(e, opts)).toBeUndefined(); + }); }); describe("extractLastText", () => { - it("accumulates text deltas", () => { - const events: AgentEvent[] = [ - { type: "text-delta", conversationId: "c", turnId: "t", delta: "Hello" }, - { type: "text-delta", conversationId: "c", turnId: "t", delta: ", " }, - { type: "text-delta", conversationId: "c", turnId: "t", delta: "world" }, - { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, - ]; - expect(extractLastText(events)).toBe("Hello, world"); - }); - - it("returns empty string when no text-delta events", () => { - const events: AgentEvent[] = [ - { type: "turn-start", conversationId: "c", turnId: "t" }, - { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, - ]; - expect(extractLastText(events)).toBe(""); - }); - - it("ignores non-text-delta events but keeps deltas interleaved among them", () => { - const events: AgentEvent[] = [ - { type: "text-delta", conversationId: "c", turnId: "t", delta: "a" }, - { - type: "tool-call", - conversationId: "c", - turnId: "t", - stepId: "t1#0" as StepId, - toolCallId: "tc1", - toolName: "read_file", - input: {}, - }, - { type: "text-delta", conversationId: "c", turnId: "t", delta: "b" }, - ]; - expect(extractLastText(events)).toBe("ab"); - }); - - it("returns empty string for an empty event list", () => { - expect(extractLastText([])).toBe(""); - }); + it("accumulates text deltas", () => { + const events: AgentEvent[] = [ + { type: "text-delta", conversationId: "c", turnId: "t", delta: "Hello" }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: ", " }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: "world" }, + { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, + ]; + expect(extractLastText(events)).toBe("Hello, world"); + }); + + it("returns empty string when no text-delta events", () => { + const events: AgentEvent[] = [ + { type: "turn-start", conversationId: "c", turnId: "t" }, + { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, + ]; + expect(extractLastText(events)).toBe(""); + }); + + it("ignores non-text-delta events but keeps deltas interleaved among them", () => { + const events: AgentEvent[] = [ + { type: "text-delta", conversationId: "c", turnId: "t", delta: "a" }, + { + type: "tool-call", + conversationId: "c", + turnId: "t", + stepId: "t1#0" as StepId, + toolCallId: "tc1", + toolName: "read_file", + input: {}, + }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: "b" }, + ]; + expect(extractLastText(events)).toBe("ab"); + }); + + it("returns empty string for an empty event list", () => { + expect(extractLastText([])).toBe(""); + }); }); describe("formatRelativeTime", () => { - const now = 1_000_000_000_000; // fixed clock + const now = 1_000_000_000_000; // fixed clock - it("returns 'just now' for under a minute", () => { - expect(formatRelativeTime(now - 30_000, now)).toBe("just now"); - }); + it("returns 'just now' for under a minute", () => { + expect(formatRelativeTime(now - 30_000, now)).toBe("just now"); + }); - it("returns minutes ago", () => { - expect(formatRelativeTime(now - 5 * 60_000, now)).toBe("5m ago"); - }); + it("returns minutes ago", () => { + expect(formatRelativeTime(now - 5 * 60_000, now)).toBe("5m ago"); + }); - it("returns hours ago", () => { - expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago"); - }); + it("returns hours ago", () => { + expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago"); + }); - it("returns days ago", () => { - expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago"); - }); + it("returns days ago", () => { + expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago"); + }); - it("returns a date past a week", () => { - // 2001-09-09T01:46:40.000Z - expect(formatRelativeTime(now - 8 * 86_400_000, now)).toBe("2001-09-01"); - }); + it("returns a date past a week", () => { + // 2001-09-09T01:46:40.000Z + expect(formatRelativeTime(now - 8 * 86_400_000, now)).toBe("2001-09-01"); + }); }); describe("formatConversationList", () => { - const now = 1_000_000_000_000; - - const conv = (id: string, title: string, ageMs: number): ConversationMeta => ({ - id, - title, - createdAt: now - ageMs - 1000, - lastActivityAt: now - ageMs, - status: "idle", - workspaceId: "default", - }); - - it("returns empty string for an empty list", () => { - expect(formatConversationList([], now)).toBe(""); - }); - - it("formats one row with short id, title, relative time", () => { - const list = [conv("abcdef1234567890", "hello world", 5 * 60_000)]; - expect(formatConversationList(list, now)).toBe("abcdef12 | hello world | 5m ago"); - }); - - it("formats multiple rows one per line", () => { - const list = [ - conv("abcdef1234567890", "first", 5 * 60_000), - conv("0123456789abcdef", "second", 3 * 3_600_000), - ]; - expect(formatConversationList(list, now)).toBe( - "abcdef12 | first | 5m ago\n01234567 | second | 3h ago", - ); - }); + const now = 1_000_000_000_000; + + const conv = (id: string, title: string, ageMs: number): ConversationMeta => ({ + id, + title, + createdAt: now - ageMs - 1000, + lastActivityAt: now - ageMs, + status: "idle", + workspaceId: "default", + }); + + it("returns empty string for an empty list", () => { + expect(formatConversationList([], now)).toBe(""); + }); + + it("formats one row with short id, title, relative time", () => { + const list = [conv("abcdef1234567890", "hello world", 5 * 60_000)]; + expect(formatConversationList(list, now)).toBe("abcdef12 | hello world | 5m ago"); + }); + + it("formats multiple rows one per line", () => { + const list = [ + conv("abcdef1234567890", "first", 5 * 60_000), + conv("0123456789abcdef", "second", 3 * 3_600_000), + ]; + expect(formatConversationList(list, now)).toBe( + "abcdef12 | first | 5m ago\n01234567 | second | 3h ago", + ); + }); }); diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts index 9f25dd3..f512b7a 100644 --- a/packages/cli/src/render.ts +++ b/packages/cli/src/render.ts @@ -8,40 +8,40 @@ import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract"; interface RenderOpts { - readonly showReasoning: boolean; + readonly showReasoning: boolean; } interface RenderOutput { - readonly stdout?: string; - readonly stderr?: string; + readonly stdout?: string; + readonly stderr?: string; } export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | undefined { - switch (e.type) { - case "text-delta": - return { stdout: e.delta }; - case "reasoning-delta": - return opts.showReasoning ? { stdout: e.delta } : undefined; - case "tool-call": - return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` }; - case "tool-output": - return { stdout: e.data }; - case "tool-result": - return { - stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`, - }; - case "usage": - return { - stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`, - }; - case "error": - return { stderr: `[error] ${e.message}\n` }; - case "status": - case "turn-start": - case "turn-sealed": - case "done": - return undefined; - } + switch (e.type) { + case "text-delta": + return { stdout: e.delta }; + case "reasoning-delta": + return opts.showReasoning ? { stdout: e.delta } : undefined; + case "tool-call": + return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` }; + case "tool-output": + return { stdout: e.data }; + case "tool-result": + return { + stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`, + }; + case "usage": + return { + stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`, + }; + case "error": + return { stderr: `[error] ${e.message}\n` }; + case "status": + case "turn-start": + case "turn-sealed": + case "done": + return undefined; + } } /** @@ -50,13 +50,13 @@ export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | und * no I/O. Returns the empty string when the stream had no text deltas. */ export function extractLastText(events: readonly AgentEvent[]): string { - let text = ""; - for (const e of events) { - if (e.type === "text-delta") { - text += e.delta; - } - } - return text; + let text = ""; + for (const e of events) { + if (e.type === "text-delta") { + text += e.delta; + } + } + return text; } /** @@ -65,15 +65,15 @@ export function extractLastText(events: readonly AgentEvent[]): string { * (clock is an outermost edge) so the function is pure and testable. */ export function formatRelativeTime(epochMs: number, now: number): string { - const delta = now - epochMs; - if (delta < 60_000) return "just now"; - const minutes = Math.floor(delta / 60_000); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - return new Date(epochMs).toISOString().slice(0, 10); + const delta = now - epochMs; + if (delta < 60_000) return "just now"; + const minutes = Math.floor(delta / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(epochMs).toISOString().slice(0, 10); } /** @@ -82,11 +82,11 @@ export function formatRelativeTime(epochMs: number, now: number): string { * string. `now` is injected for `formatRelativeTime` (pure + testable). */ export function formatConversationList( - conversations: readonly ConversationMeta[], - now: number, + conversations: readonly ConversationMeta[], + now: number, ): string { - if (conversations.length === 0) return ""; - return conversations - .map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`) - .join("\n"); + if (conversations.length === 0) return ""; + return conversations + .map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`) + .join("\n"); } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e011575..f8864ec 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../transport-contract" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../transport-contract" }] } diff --git a/packages/conversation-store/package.json b/packages/conversation-store/package.json index ff6cd22..3b1f6a2 100644 --- a/packages/conversation-store/package.json +++ b/packages/conversation-store/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/conversation-store", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/conversation-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/conversation-store/src/extension.ts b/packages/conversation-store/src/extension.ts index cd03077..b86ffa7 100644 --- a/packages/conversation-store/src/extension.ts +++ b/packages/conversation-store/src/extension.ts @@ -2,30 +2,30 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { conversationStoreHandle, createConversationStore } from "./store.js"; export const manifest: Manifest = { - id: "conversation-store", - name: "Conversation Store", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - capabilities: { db: true }, - contributes: { services: ["conversation-store/store"] }, - activation: "eager", + id: "conversation-store", + name: "Conversation Store", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + capabilities: { db: true }, + contributes: { services: ["conversation-store/store"] }, + activation: "eager", }; export const extension: Extension = { - manifest, - activate: async (host: HostAPI) => { - const storage = host.storage("conversation-store"); - const store = createConversationStore(storage, host.logger, undefined, process.cwd()); + manifest, + activate: async (host: HostAPI) => { + const storage = host.storage("conversation-store"); + const store = createConversationStore(storage, host.logger, undefined, process.cwd()); - const stale = await store.listConversations({ status: ["active"] }); - for (const m of stale) { - await store.setConversationStatus(m.id, "idle"); - } - if (stale.length > 0) { - host.logger.info("conversation-store: boot-sweep", { resetCount: stale.length }); - } + const stale = await store.listConversations({ status: ["active"] }); + for (const m of stale) { + await store.setConversationStatus(m.id, "idle"); + } + if (stale.length > 0) { + host.logger.info("conversation-store: boot-sweep", { resetCount: stale.length }); + } - host.provideService(conversationStoreHandle, store); - }, + host.provideService(conversationStoreHandle, store); + }, }; diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts index 9e78b94..1fc2c33 100644 --- a/packages/conversation-store/src/index.ts +++ b/packages/conversation-store/src/index.ts @@ -4,8 +4,8 @@ export type { ReconcileReport, ReconcileResult } from "./reconcile.js"; export { reconcile, reconcileWithReport } from "./reconcile.js"; export type { ConversationStore } from "./store.js"; export { - conversationStoreHandle, - createConversationStore, - extractTitle, - isValidWorkspaceSlug, + conversationStoreHandle, + createConversationStore, + extractTitle, + isValidWorkspaceSlug, } from "./store.js"; diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 061871e..b2c635d 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -1,77 +1,77 @@ const SEQ_PAD = 10; export function seqKey(conversationId: string): string { - return `conv:${conversationId}:seq`; + return `conv:${conversationId}:seq`; } export function chunkKey(conversationId: string, seq: number): string { - return `conv:${conversationId}:chunk:${String(seq).padStart(SEQ_PAD, "0")}`; + return `conv:${conversationId}:chunk:${String(seq).padStart(SEQ_PAD, "0")}`; } export function chunkPrefix(conversationId: string): string { - return `conv:${conversationId}:chunk:`; + return `conv:${conversationId}:chunk:`; } export function parseSeq(raw: string | null): number { - if (raw === null) return 0; - const n = Number.parseInt(raw, 10); - return Number.isNaN(n) ? 0 : n; + if (raw === null) return 0; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? 0 : n; } export function parseChunkSeq(key: string): number { - const parts = key.split(":"); - const last = parts[parts.length - 1]; - if (last === undefined) return -1; - const n = Number.parseInt(last, 10); - return Number.isNaN(n) ? -1 : n; + const parts = key.split(":"); + const last = parts[parts.length - 1]; + if (last === undefined) return -1; + const n = Number.parseInt(last, 10); + return Number.isNaN(n) ? -1 : n; } export function metricsSeqKey(conversationId: string): string { - return `conv:${conversationId}:metrics-seq`; + return `conv:${conversationId}:metrics-seq`; } export function metricsKey(conversationId: string, ordinal: number): string { - return `conv:${conversationId}:metrics:${String(ordinal).padStart(SEQ_PAD, "0")}`; + return `conv:${conversationId}:metrics:${String(ordinal).padStart(SEQ_PAD, "0")}`; } export function metricsPrefix(conversationId: string): string { - return `conv:${conversationId}:metrics:`; + return `conv:${conversationId}:metrics:`; } export function parseMetricsOrdinal(key: string): number { - const parts = key.split(":"); - const last = parts[parts.length - 1]; - if (last === undefined) return -1; - const n = Number.parseInt(last, 10); - return Number.isNaN(n) ? -1 : n; + const parts = key.split(":"); + const last = parts[parts.length - 1]; + if (last === undefined) return -1; + const n = Number.parseInt(last, 10); + return Number.isNaN(n) ? -1 : n; } export function cwdKey(conversationId: string): string { - return `conv:${conversationId}:cwd`; + return `conv:${conversationId}:cwd`; } export function computerKey(conversationId: string): string { - return `conv:${conversationId}:computer`; + return `conv:${conversationId}:computer`; } export function reasoningEffortKey(conversationId: string): string { - return `conv:${conversationId}:reasoning-effort`; + return `conv:${conversationId}:reasoning-effort`; } export function modelKey(conversationId: string): string { - return `conv:${conversationId}:model`; + return `conv:${conversationId}:model`; } export function compactThresholdKey(conversationId: string): string { - return `conv:${conversationId}:compact-percent`; + return `conv:${conversationId}:compact-percent`; } export function metaKey(conversationId: string): string { - return `conv:${conversationId}:meta`; + return `conv:${conversationId}:meta`; } export function workspaceKey(workspaceId: string): string { - return `workspace:${workspaceId}`; + return `workspace:${workspaceId}`; } export const CONVERSATION_INDEX_KEY = "conv-index"; diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts index 78b808e..b1926e9 100644 --- a/packages/conversation-store/src/reconcile.test.ts +++ b/packages/conversation-store/src/reconcile.test.ts @@ -3,418 +3,418 @@ import { describe, expect, it } from "vitest"; import { reconcile, reconcileWithReport } from "./reconcile.js"; describe("reconcile", () => { - it("returns empty array for empty input", () => { - expect(reconcile([])).toEqual([]); - }); + it("returns empty array for empty input", () => { + expect(reconcile([])).toEqual([]); + }); - it("passes through a complete conversation unchanged", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - ]; - const result = reconcile(messages); - expect(result).toEqual(messages); - }); + it("passes through a complete conversation unchanged", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + ]; + const result = reconcile(messages); + expect(result).toEqual(messages); + }); - it("passes through a complete tool-call/tool-result pair unchanged", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "read file" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_1", - toolName: "readFile", - input: { path: "/tmp/foo" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "readFile", - content: "file contents", - isError: false, - }, - ], - }, - { role: "assistant", chunks: [{ type: "text", text: "done" }] }, - ]; - const result = reconcile(messages); - expect(result).toEqual(messages); - }); + it("passes through a complete tool-call/tool-result pair unchanged", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "read file" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "readFile", + input: { path: "/tmp/foo" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "readFile", + content: "file contents", + isError: false, + }, + ], + }, + { role: "assistant", chunks: [{ type: "text", text: "done" }] }, + ]; + const result = reconcile(messages); + expect(result).toEqual(messages); + }); - it("synthesizes error result for orphaned tool-call", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do something" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[2]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "someTool", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("synthesizes error result for orphaned tool-call", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do something" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[2]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "someTool", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("synthesizes results for multiple orphaned tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_a", - toolName: "toolA", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_b", - toolName: "toolB", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[1]?.role).toBe("tool"); - expect(result[2]?.role).toBe("tool"); - const ids = result.slice(1).map((m) => { - const chunk = m.chunks[0]; - return chunk?.type === "tool-result" ? chunk.toolCallId : null; - }); - expect(ids).toEqual(["call_a", "call_b"]); - }); + it("synthesizes results for multiple orphaned tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_a", + toolName: "toolA", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_b", + toolName: "toolB", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[1]?.role).toBe("tool"); + expect(result[2]?.role).toBe("tool"); + const ids = result.slice(1).map((m) => { + const chunk = m.chunks[0]; + return chunk?.type === "tool-result" ? chunk.toolCallId : null; + }); + expect(ids).toEqual(["call_a", "call_b"]); + }); - it("handles mixed resolved and orphaned tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_resolved", - toolName: "toolResolved", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "toolOrphan", - input: {}, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_resolved", - toolName: "toolResolved", - content: "ok", - isError: false, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[2]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "toolOrphan", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("handles mixed resolved and orphaned tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_resolved", + toolName: "toolResolved", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "toolOrphan", + input: {}, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_resolved", + toolName: "toolResolved", + content: "ok", + isError: false, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[2]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "toolOrphan", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("handles multiple turns with orphaned tool-calls in different turns", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_t1", - toolName: "tool1", - input: {}, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_t1", - toolName: "tool1", - content: "result", - isError: false, - }, - ], - }, - { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_t2", - toolName: "tool2", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(6); - expect(result[5]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_t2", - toolName: "tool2", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("handles multiple turns with orphaned tool-calls in different turns", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_t1", + toolName: "tool1", + input: {}, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_t1", + toolName: "tool1", + content: "result", + isError: false, + }, + ], + }, + { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_t2", + toolName: "tool2", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(6); + expect(result[5]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_t2", + toolName: "tool2", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("preserves thinking and text chunks alongside tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "thinking", text: "let me think" }, - { type: "text", text: "I will call a tool" }, - { - type: "tool-call", - toolCallId: "call_x", - toolName: "toolX", - input: { a: 1 }, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - expect(result[0]?.chunks).toHaveLength(3); - expect(result[1]?.role).toBe("tool"); - }); + it("preserves thinking and text chunks alongside tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "I will call a tool" }, + { + type: "tool-call", + toolCallId: "call_x", + toolName: "toolX", + input: { a: 1 }, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + expect(result[0]?.chunks).toHaveLength(3); + expect(result[1]?.role).toBe("tool"); + }); - it("copies the originating tool-call's stepId onto a synthesized result", () => { - const stepId = "step_orphan" as StepId; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_sid", - toolName: "someTool", - input: {}, - stepId, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - expect(result[1]?.role).toBe("tool"); - const chunk = result[1]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.stepId).toBe(stepId); - } - }); + it("copies the originating tool-call's stepId onto a synthesized result", () => { + const stepId = "step_orphan" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_sid", + toolName: "someTool", + input: {}, + stepId, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + expect(result[1]?.role).toBe("tool"); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.stepId).toBe(stepId); + } + }); - it("omits stepId when the dangling call has none", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_nosid", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - const chunk = result[1]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk).not.toHaveProperty("stepId"); - } - }); + it("omits stepId when the dangling call has none", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_nosid", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk).not.toHaveProperty("stepId"); + } + }); - // --- Layer 1: read-time self-repair of broken chats (error chunks) --- + // --- Layer 1: read-time self-repair of broken chats (error chunks) --- - it("reconcile strips error-only trailing assistant message", () => { - // The 77574596/102587c0 shape: [user, assistant{error}] -> [user]. - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(1); - expect(report.repairedCount).toBe(0); - }); + it("reconcile strips error-only trailing assistant message", () => { + // The 77574596/102587c0 shape: [user, assistant{error}] -> [user]. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(1); + expect(report.repairedCount).toBe(0); + }); - it("reconcile strips error chunk but keeps sibling text", () => { - // assistant{text,error} -> assistant{text}. - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "hello" }, - { type: "error", message: "boom" }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(0); - }); + it("reconcile strips error chunk but keeps sibling text", () => { + // assistant{text,error} -> assistant{text}. + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "hello" }, + { type: "error", message: "boom" }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(0); + }); - it("reconcile drops assistant message left empty after stripping error", () => { - // assistant{error} only -> dropped entirely. - const messages: ChatMessage[] = [ - { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(1); - expect(report.repairedCount).toBe(0); - }); + it("reconcile drops assistant message left empty after stripping error", () => { + // assistant{error} only -> dropped entirely. + const messages: ChatMessage[] = [ + { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(1); + expect(report.repairedCount).toBe(0); + }); - it("reconcile keeps tool-call + strips error", () => { - // assistant{tool-call,error} with a matching result -> assistant{tool-call}. - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }, - { type: "error", message: "boom" }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "t", - content: "ok", - isError: false, - }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([ - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "t", - content: "ok", - isError: false, - }, - ], - }, - ]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(0); // the tool-call has a matching result - }); + it("reconcile keeps tool-call + strips error", () => { + // assistant{tool-call,error} with a matching result -> assistant{tool-call}. + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }, + { type: "error", message: "boom" }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "t", + content: "ok", + isError: false, + }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([ + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "t", + content: "ok", + isError: false, + }, + ], + }, + ]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(0); // the tool-call has a matching result + }); - it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => { - // Ordering guard: strip error chunks first, then run orphaned-tool-call - // synthesis on what remains. assistant{tool-call,error} with NO result -> - // the error is stripped, the tool-call survives, and a result is synthesized. - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "go" }] }, - { - role: "assistant", - chunks: [ - { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }, - { type: "error", message: "boom" }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([ - { role: "user", chunks: [{ type: "text", text: "go" }] }, - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orph", - toolName: "t", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }, - ]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(1); - expect(report.repairedToolCallIds).toEqual(["call_orph"]); - }); + it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => { + // Ordering guard: strip error chunks first, then run orphaned-tool-call + // synthesis on what remains. assistant{tool-call,error} with NO result -> + // the error is stripped, the tool-call survives, and a result is synthesized. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "go" }] }, + { + role: "assistant", + chunks: [ + { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }, + { type: "error", message: "boom" }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([ + { role: "user", chunks: [{ type: "text", text: "go" }] }, + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orph", + toolName: "t", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }, + ]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(1); + expect(report.repairedToolCallIds).toEqual(["call_orph"]); + }); }); diff --git a/packages/conversation-store/src/reconcile.ts b/packages/conversation-store/src/reconcile.ts index ea33904..9023415 100644 --- a/packages/conversation-store/src/reconcile.ts +++ b/packages/conversation-store/src/reconcile.ts @@ -1,107 +1,107 @@ import type { ChatMessage, ToolCallChunk, ToolResultChunk } from "@dispatch/kernel"; export interface ReconcileReport { - readonly repairedCount: number; - readonly repairedToolCallIds: readonly string[]; - /** Number of `error` chunks stripped from assistant messages. */ - readonly strippedErrorChunks: number; - /** Number of assistant messages dropped after stripping left them empty. */ - readonly droppedEmptyMessages: number; + readonly repairedCount: number; + readonly repairedToolCallIds: readonly string[]; + /** Number of `error` chunks stripped from assistant messages. */ + readonly strippedErrorChunks: number; + /** Number of assistant messages dropped after stripping left them empty. */ + readonly droppedEmptyMessages: number; } export interface ReconcileResult { - readonly messages: ChatMessage[]; - readonly report: ReconcileReport; + readonly messages: ChatMessage[]; + readonly report: ReconcileReport; } export function reconcileWithReport(messages: readonly ChatMessage[]): ReconcileResult { - // Phase 1: strip `error` chunks from assistant messages. An error chunk is a - // failed-generation marker, never valid provider content — removing it here - // (on load, before any provider sees the messages) auto-repairs broken chats - // with no DB surgery (append-only storage untouched). - let strippedErrorChunks = 0; - const stripped: ChatMessage[] = []; - for (const msg of messages) { - if (msg.role === "assistant" && msg.chunks.some((c) => c.type === "error")) { - const filtered = msg.chunks.filter((chunk) => { - if (chunk.type === "error") { - strippedErrorChunks++; - return false; - } - return true; - }); - stripped.push({ role: msg.role, chunks: filtered }); - } else { - stripped.push(msg); - } - } + // Phase 1: strip `error` chunks from assistant messages. An error chunk is a + // failed-generation marker, never valid provider content — removing it here + // (on load, before any provider sees the messages) auto-repairs broken chats + // with no DB surgery (append-only storage untouched). + let strippedErrorChunks = 0; + const stripped: ChatMessage[] = []; + for (const msg of messages) { + if (msg.role === "assistant" && msg.chunks.some((c) => c.type === "error")) { + const filtered = msg.chunks.filter((chunk) => { + if (chunk.type === "error") { + strippedErrorChunks++; + return false; + } + return true; + }); + stripped.push({ role: msg.role, chunks: filtered }); + } else { + stripped.push(msg); + } + } - // Phase 2: drop assistant messages left with neither `text` nor `tool-call` - // chunks after stripping (the now-empty error-only message). This is what - // unblocks continuation: such a message serializes to nothing a provider - // understands. Safe: it ends with no tool-call, so it is NEVER followed by a - // `tool` message — no "tool-without-preceding-assistant-tool_calls" 400. - let droppedEmptyMessages = 0; - const pruned: ChatMessage[] = []; - for (const msg of stripped) { - if (msg.role === "assistant") { - const hasContent = msg.chunks.some( - (chunk) => chunk.type === "text" || chunk.type === "tool-call", - ); - if (!hasContent) { - droppedEmptyMessages++; - continue; - } - } - pruned.push(msg); - } + // Phase 2: drop assistant messages left with neither `text` nor `tool-call` + // chunks after stripping (the now-empty error-only message). This is what + // unblocks continuation: such a message serializes to nothing a provider + // understands. Safe: it ends with no tool-call, so it is NEVER followed by a + // `tool` message — no "tool-without-preceding-assistant-tool_calls" 400. + let droppedEmptyMessages = 0; + const pruned: ChatMessage[] = []; + for (const msg of stripped) { + if (msg.role === "assistant") { + const hasContent = msg.chunks.some( + (chunk) => chunk.type === "text" || chunk.type === "tool-call", + ); + if (!hasContent) { + droppedEmptyMessages++; + continue; + } + } + pruned.push(msg); + } - // Phase 3: orphaned-tool-call synthesis (unchanged) on what remains. - const resolvedIds = new Set(); - for (const msg of pruned) { - for (const chunk of msg.chunks) { - if (chunk.type === "tool-result") { - resolvedIds.add(chunk.toolCallId); - } - } - } + // Phase 3: orphaned-tool-call synthesis (unchanged) on what remains. + const resolvedIds = new Set(); + for (const msg of pruned) { + for (const chunk of msg.chunks) { + if (chunk.type === "tool-result") { + resolvedIds.add(chunk.toolCallId); + } + } + } - const orphaned: ToolCallChunk[] = []; - for (const msg of pruned) { - if (msg.role !== "assistant") continue; - for (const chunk of msg.chunks) { - if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) { - orphaned.push(chunk); - } - } - } + const orphaned: ToolCallChunk[] = []; + for (const msg of pruned) { + if (msg.role !== "assistant") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) { + orphaned.push(chunk); + } + } + } - const result: ChatMessage[] = [...pruned]; + const result: ChatMessage[] = [...pruned]; - for (const call of orphaned) { - const base = { - type: "tool-result" as const, - toolCallId: call.toolCallId, - toolName: call.toolName, - content: "interrupted: tool execution did not complete", - isError: true, - }; - const synthesized: ToolResultChunk = - call.stepId !== undefined ? { ...base, stepId: call.stepId } : base; - result.push({ role: "tool", chunks: [synthesized] }); - } + for (const call of orphaned) { + const base = { + type: "tool-result" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + content: "interrupted: tool execution did not complete", + isError: true, + }; + const synthesized: ToolResultChunk = + call.stepId !== undefined ? { ...base, stepId: call.stepId } : base; + result.push({ role: "tool", chunks: [synthesized] }); + } - return { - messages: result, - report: { - repairedCount: orphaned.length, - repairedToolCallIds: orphaned.map((c) => c.toolCallId), - strippedErrorChunks, - droppedEmptyMessages, - }, - }; + return { + messages: result, + report: { + repairedCount: orphaned.length, + repairedToolCallIds: orphaned.map((c) => c.toolCallId), + strippedErrorChunks, + droppedEmptyMessages, + }, + }; } export function reconcile(messages: readonly ChatMessage[]): ChatMessage[] { - return reconcileWithReport(messages).messages; + return reconcileWithReport(messages).messages; } diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts index 3926c94..788a526 100644 --- a/packages/conversation-store/src/store-workspace.test.ts +++ b/packages/conversation-store/src/store-workspace.test.ts @@ -3,674 +3,674 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createConversationStore, isValidWorkspaceSlug } from "./store.js"; function createMemoryStorage(): StorageNamespace { - const data = new Map(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (!prefix) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + const data = new Map(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (!prefix) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; } describe("WorkspaceStore", () => { - let storage: StorageNamespace; - let clock: number; - - beforeEach(() => { - storage = createMemoryStorage(); - clock = 1000; - }); - - function makeStore(serverDefaultCwd?: string) { - return createConversationStore(storage, undefined, () => clock, serverDefaultCwd); - } - - function userMessage(text: string): ChatMessage { - return { role: "user", chunks: [{ type: "text", text }] }; - } - - it("ensureWorkspace creates with defaults", async () => { - const store = makeStore(); - clock = 1000; - const ws = await store.ensureWorkspace("my-work"); - expect(ws).toEqual({ - id: "my-work", - title: "my-work", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("ensureWorkspace returns existing as-is", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const ws = await store.ensureWorkspace("my-work", { - title: "New Title", - defaultCwd: "/ignored", - }); - expect(ws).toEqual({ - id: "my-work", - title: "my-work", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("ensureWorkspace with custom title/defaultCwd", async () => { - const store = makeStore(); - clock = 3000; - const ws = await store.ensureWorkspace("my-work", { - title: "Custom", - defaultCwd: "/projects/dispatch", - }); - expect(ws).toEqual({ - id: "my-work", - title: "Custom", - defaultCwd: "/projects/dispatch", - defaultComputerId: null, - createdAt: 3000, - lastActivityAt: 3000, - }); - }); - - it("getWorkspace synthesizes default", async () => { - const store = makeStore(); - const ws = await store.getWorkspace("default"); - expect(ws).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - }); - - it("getWorkspace returns null for unknown", async () => { - const store = makeStore(); - expect(await store.getWorkspace("unknown")).toBeNull(); - }); - - it("setWorkspaceTitle renames", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const ws = await store.setWorkspaceTitle("my-work", "Renamed"); - expect(ws).toEqual({ - id: "my-work", - title: "Renamed", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("setWorkspaceDefaultCwd sets and clears", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const setWs = await store.setWorkspaceDefaultCwd("my-work", "/some/path"); - expect(setWs.defaultCwd).toBe("/some/path"); - expect(setWs.lastActivityAt).toBe(1000); // does not bump on defaultCwd change - const cleared = await store.setWorkspaceDefaultCwd("my-work", null); - expect(cleared.defaultCwd).toBeNull(); - }); - - it("deleteWorkspace closes conversations", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("work-a"); - await store.setWorkspaceId("conv1", "work-a"); - await store.setWorkspaceId("conv2", "work-a"); - await store.setWorkspaceId("conv3", "default"); - - clock = 2000; - await store.append("conv1", [userMessage("hi 1")]); - await store.append("conv2", [userMessage("hi 2")]); - await store.append("conv3", [userMessage("hi 3")]); - - const result = await store.deleteWorkspace("work-a"); - expect(result.closedCount).toBe(2); - - const meta1 = await store.getConversationMeta("conv1"); - expect(meta1?.status).toBe("closed"); - expect(meta1?.workspaceId).toBe("default"); - - const meta2 = await store.getConversationMeta("conv2"); - expect(meta2?.status).toBe("closed"); - expect(meta2?.workspaceId).toBe("default"); - - const meta3 = await store.getConversationMeta("conv3"); - expect(meta3?.status).toBe("idle"); - expect(meta3?.workspaceId).toBe("default"); - - expect(await store.getWorkspace("work-a")).toBeNull(); - }); - - it("deleteWorkspace throws for default", async () => { - const store = makeStore(); - await expect(store.deleteWorkspace("default")).rejects.toThrow(); - }); - - it("listWorkspaces sorted by lastActivityAt desc", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("alpha"); - clock = 2000; - await store.ensureWorkspace("beta"); - clock = 3000; - await store.ensureWorkspace("gamma"); - - const list = await store.listWorkspaces(); - expect(list.map((w) => w.id)).toEqual(["gamma", "beta", "alpha", "default"]); - }); - - it("listWorkspaces includes conversationCount", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("work-a"); - await store.ensureWorkspace("work-b"); - await store.setWorkspaceId("a1", "work-a"); - await store.setWorkspaceId("a2", "work-a"); - await store.setWorkspaceId("b1", "work-b"); - await store.append("lonely", [userMessage("hi")]); // defaults to "default" - - const list = await store.listWorkspaces(); - const counts = Object.fromEntries(list.map((w) => [w.id, w.conversationCount])); - expect(counts["work-a"]).toBe(2); - expect(counts["work-b"]).toBe(1); - expect(counts.default).toBe(1); - }); - - it("listWorkspaces always includes default", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("only"); - // No append or explicit default creation — default is synthesized. - const list = await store.listWorkspaces(); - const ids = list.map((w) => w.id); - expect(ids).toContain("default"); - const defaultWs = list.find((w) => w.id === "default"); - expect(defaultWs).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - conversationCount: 0, - }); - }); - - it("getWorkspaceId returns default for legacy", async () => { - const store = makeStore(); - await store.append("conv1", [userMessage("hi")]); - expect(await store.getWorkspaceId("conv1")).toBe("default"); - expect(await store.getWorkspaceId("never-seen")).toBe("default"); - }); - - it("setWorkspaceId persists and reads back", async () => { - const store = makeStore(); - clock = 1000; - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getWorkspaceId("conv1")).toBe("my-work"); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.workspaceId).toBe("my-work"); - expect(meta?.status).toBe("idle"); - }); - - it("getEffectiveCwd: absolute conversation cwd overrides workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "/explicit/path"); - expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); - }); - - it("getEffectiveCwd: workspace defaultCwd used when conversation cwd is unset (bug fix)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("getEffectiveCwd: serverDefaultCwd fallback when both conversation and workspace cwd are null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); - }); - - it("getEffectiveCwd: relative conversation cwd resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "subdir"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/subdir"); - }); - - it("getEffectiveCwd: relative conversation cwd resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "subdir"); - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default/subdir"); - }); - - it("getEffectiveCwd: relative cwd with nested segments resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "a/b/c"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/a/b/c"); - }); - - it("getEffectiveCwd: relative cwd with .. segments normalizes via path.resolve", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root/sub" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "../sibling"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/sibling"); - }); - - it("getEffectiveCwd: default workspace (no defaultCwd) falls through to serverDefaultCwd", async () => { - const store = makeStore("/server/default"); - // No explicit workspace assignment — defaults to "default" workspace - // which has defaultCwd null. - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); - }); - - // --- overrideCwd (per-turn cwd override) --- - - it("getEffectiveCwd: overrideCwd absolute (starts with /) returned as-is, overriding workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - // An absolute override wins outright, even over a workspace defaultCwd. - expect(await store.getEffectiveCwd("conv1", "/override/abs")).toBe("/override/abs"); - }); - - it("getEffectiveCwd: overrideCwd relative resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/workspace/root/subdir"); - }); - - it("getEffectiveCwd: overrideCwd relative resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/server/default/subdir"); - }); - - it("getEffectiveCwd: overrideCwd does NOT read the persisted getCwd (override wins over persisted)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - // Persist a cwd that differs from the override — the override must win. - await store.setCwd("conv1", "/persisted/path"); - expect(await store.getEffectiveCwd("conv1", "override-rel")).toBe( - "/workspace/root/override-rel", - ); - }); - - it("getEffectiveCwd: overrideCwd omitted behaves as today (uses persisted cwd)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "persisted-rel"); - // No second arg — persisted cwd is used. - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/persisted-rel"); - }); - - it("clearCwd → getEffectiveCwd falls through to workspace defaultCwd (un-shadows it)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "/explicit/path"); - // Before clear: the conversation cwd shadows the workspace defaultCwd. - expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); - // After clear: the workspace defaultCwd is used (fall-through). - await store.clearCwd("conv1"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("getEffectiveCwd: an empty-string cwd does NOT fall through (proving clear ≠ setCwd(''))", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - // An empty string is a non-null explicit cwd — it is resolved (not - // treated as absent), so it does NOT fall through to the workspace - // defaultCwd. This is the gap clearCwd fixes. - await store.setCwd("conv1", ""); - expect(await store.getCwd("conv1")).toBe(""); - // path.resolve("/workspace/default", "") === "/workspace/default" — - // but this is a RELATIVE cwd resolution, not a fall-through. The point - // is that getCwd returns "" (not null), so the relative branch runs. - // With a clearCwd, getCwd returns null and the fall-through branch runs. - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("listConversations filtered by workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("work-a"); - await store.ensureWorkspace("work-b"); - await store.append("a1", [userMessage("a1")]); - await store.append("a2", [userMessage("a2")]); - await store.append("b1", [userMessage("b1")]); - await store.setWorkspaceId("a1", "work-a"); - await store.setWorkspaceId("a2", "work-a"); - await store.setWorkspaceId("b1", "work-b"); - - const aConvs = await store.listConversations({ workspaceId: "work-a" }); - expect(aConvs.map((c) => c.id).sort()).toEqual(["a1", "a2"]); - - const bConvs = await store.listConversations({ workspaceId: "work-b" }); - expect(bConvs.map((c) => c.id)).toEqual(["b1"]); - }); - - it("append updates workspace lastActivityAt", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - await store.setWorkspaceId("conv1", "my-work"); - clock = 3000; - await store.append("conv1", [userMessage("hi")]); - const ws = await store.getWorkspace("my-work"); - expect(ws?.lastActivityAt).toBe(3000); - }); - - it("forkHistory copies workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("source", "my-work"); - await store.append("source", [userMessage("hello")]); - await store.forkHistory("source", "target"); - const targetMeta = await store.getConversationMeta("target"); - expect(targetMeta?.workspaceId).toBe("my-work"); - }); - - it("replaceHistory preserves workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - await store.append("conv1", [userMessage("original")]); - await store.replaceHistory("conv1", [userMessage("replaced")]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.workspaceId).toBe("my-work"); - }); + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore(serverDefaultCwd?: string) { + return createConversationStore(storage, undefined, () => clock, serverDefaultCwd); + } + + function userMessage(text: string): ChatMessage { + return { role: "user", chunks: [{ type: "text", text }] }; + } + + it("ensureWorkspace creates with defaults", async () => { + const store = makeStore(); + clock = 1000; + const ws = await store.ensureWorkspace("my-work"); + expect(ws).toEqual({ + id: "my-work", + title: "my-work", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("ensureWorkspace returns existing as-is", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const ws = await store.ensureWorkspace("my-work", { + title: "New Title", + defaultCwd: "/ignored", + }); + expect(ws).toEqual({ + id: "my-work", + title: "my-work", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("ensureWorkspace with custom title/defaultCwd", async () => { + const store = makeStore(); + clock = 3000; + const ws = await store.ensureWorkspace("my-work", { + title: "Custom", + defaultCwd: "/projects/dispatch", + }); + expect(ws).toEqual({ + id: "my-work", + title: "Custom", + defaultCwd: "/projects/dispatch", + defaultComputerId: null, + createdAt: 3000, + lastActivityAt: 3000, + }); + }); + + it("getWorkspace synthesizes default", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }); + }); + + it("getWorkspace returns null for unknown", async () => { + const store = makeStore(); + expect(await store.getWorkspace("unknown")).toBeNull(); + }); + + it("setWorkspaceTitle renames", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const ws = await store.setWorkspaceTitle("my-work", "Renamed"); + expect(ws).toEqual({ + id: "my-work", + title: "Renamed", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("setWorkspaceDefaultCwd sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultCwd("my-work", "/some/path"); + expect(setWs.defaultCwd).toBe("/some/path"); + expect(setWs.lastActivityAt).toBe(1000); // does not bump on defaultCwd change + const cleared = await store.setWorkspaceDefaultCwd("my-work", null); + expect(cleared.defaultCwd).toBeNull(); + }); + + it("deleteWorkspace closes conversations", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("work-a"); + await store.setWorkspaceId("conv1", "work-a"); + await store.setWorkspaceId("conv2", "work-a"); + await store.setWorkspaceId("conv3", "default"); + + clock = 2000; + await store.append("conv1", [userMessage("hi 1")]); + await store.append("conv2", [userMessage("hi 2")]); + await store.append("conv3", [userMessage("hi 3")]); + + const result = await store.deleteWorkspace("work-a"); + expect(result.closedCount).toBe(2); + + const meta1 = await store.getConversationMeta("conv1"); + expect(meta1?.status).toBe("closed"); + expect(meta1?.workspaceId).toBe("default"); + + const meta2 = await store.getConversationMeta("conv2"); + expect(meta2?.status).toBe("closed"); + expect(meta2?.workspaceId).toBe("default"); + + const meta3 = await store.getConversationMeta("conv3"); + expect(meta3?.status).toBe("idle"); + expect(meta3?.workspaceId).toBe("default"); + + expect(await store.getWorkspace("work-a")).toBeNull(); + }); + + it("deleteWorkspace throws for default", async () => { + const store = makeStore(); + await expect(store.deleteWorkspace("default")).rejects.toThrow(); + }); + + it("listWorkspaces sorted by lastActivityAt desc", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("alpha"); + clock = 2000; + await store.ensureWorkspace("beta"); + clock = 3000; + await store.ensureWorkspace("gamma"); + + const list = await store.listWorkspaces(); + expect(list.map((w) => w.id)).toEqual(["gamma", "beta", "alpha", "default"]); + }); + + it("listWorkspaces includes conversationCount", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("work-a"); + await store.ensureWorkspace("work-b"); + await store.setWorkspaceId("a1", "work-a"); + await store.setWorkspaceId("a2", "work-a"); + await store.setWorkspaceId("b1", "work-b"); + await store.append("lonely", [userMessage("hi")]); // defaults to "default" + + const list = await store.listWorkspaces(); + const counts = Object.fromEntries(list.map((w) => [w.id, w.conversationCount])); + expect(counts["work-a"]).toBe(2); + expect(counts["work-b"]).toBe(1); + expect(counts.default).toBe(1); + }); + + it("listWorkspaces always includes default", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("only"); + // No append or explicit default creation — default is synthesized. + const list = await store.listWorkspaces(); + const ids = list.map((w) => w.id); + expect(ids).toContain("default"); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + }); + + it("getWorkspaceId returns default for legacy", async () => { + const store = makeStore(); + await store.append("conv1", [userMessage("hi")]); + expect(await store.getWorkspaceId("conv1")).toBe("default"); + expect(await store.getWorkspaceId("never-seen")).toBe("default"); + }); + + it("setWorkspaceId persists and reads back", async () => { + const store = makeStore(); + clock = 1000; + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getWorkspaceId("conv1")).toBe("my-work"); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.workspaceId).toBe("my-work"); + expect(meta?.status).toBe("idle"); + }); + + it("getEffectiveCwd: absolute conversation cwd overrides workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "/explicit/path"); + expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); + }); + + it("getEffectiveCwd: workspace defaultCwd used when conversation cwd is unset (bug fix)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("getEffectiveCwd: serverDefaultCwd fallback when both conversation and workspace cwd are null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); + }); + + it("getEffectiveCwd: relative conversation cwd resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "subdir"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/subdir"); + }); + + it("getEffectiveCwd: relative conversation cwd resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "subdir"); + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default/subdir"); + }); + + it("getEffectiveCwd: relative cwd with nested segments resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "a/b/c"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/a/b/c"); + }); + + it("getEffectiveCwd: relative cwd with .. segments normalizes via path.resolve", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root/sub" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "../sibling"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/sibling"); + }); + + it("getEffectiveCwd: default workspace (no defaultCwd) falls through to serverDefaultCwd", async () => { + const store = makeStore("/server/default"); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultCwd null. + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); + }); + + // --- overrideCwd (per-turn cwd override) --- + + it("getEffectiveCwd: overrideCwd absolute (starts with /) returned as-is, overriding workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + // An absolute override wins outright, even over a workspace defaultCwd. + expect(await store.getEffectiveCwd("conv1", "/override/abs")).toBe("/override/abs"); + }); + + it("getEffectiveCwd: overrideCwd relative resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/workspace/root/subdir"); + }); + + it("getEffectiveCwd: overrideCwd relative resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/server/default/subdir"); + }); + + it("getEffectiveCwd: overrideCwd does NOT read the persisted getCwd (override wins over persisted)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + // Persist a cwd that differs from the override — the override must win. + await store.setCwd("conv1", "/persisted/path"); + expect(await store.getEffectiveCwd("conv1", "override-rel")).toBe( + "/workspace/root/override-rel", + ); + }); + + it("getEffectiveCwd: overrideCwd omitted behaves as today (uses persisted cwd)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "persisted-rel"); + // No second arg — persisted cwd is used. + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/persisted-rel"); + }); + + it("clearCwd → getEffectiveCwd falls through to workspace defaultCwd (un-shadows it)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "/explicit/path"); + // Before clear: the conversation cwd shadows the workspace defaultCwd. + expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); + // After clear: the workspace defaultCwd is used (fall-through). + await store.clearCwd("conv1"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("getEffectiveCwd: an empty-string cwd does NOT fall through (proving clear ≠ setCwd(''))", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + // An empty string is a non-null explicit cwd — it is resolved (not + // treated as absent), so it does NOT fall through to the workspace + // defaultCwd. This is the gap clearCwd fixes. + await store.setCwd("conv1", ""); + expect(await store.getCwd("conv1")).toBe(""); + // path.resolve("/workspace/default", "") === "/workspace/default" — + // but this is a RELATIVE cwd resolution, not a fall-through. The point + // is that getCwd returns "" (not null), so the relative branch runs. + // With a clearCwd, getCwd returns null and the fall-through branch runs. + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("listConversations filtered by workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("work-a"); + await store.ensureWorkspace("work-b"); + await store.append("a1", [userMessage("a1")]); + await store.append("a2", [userMessage("a2")]); + await store.append("b1", [userMessage("b1")]); + await store.setWorkspaceId("a1", "work-a"); + await store.setWorkspaceId("a2", "work-a"); + await store.setWorkspaceId("b1", "work-b"); + + const aConvs = await store.listConversations({ workspaceId: "work-a" }); + expect(aConvs.map((c) => c.id).sort()).toEqual(["a1", "a2"]); + + const bConvs = await store.listConversations({ workspaceId: "work-b" }); + expect(bConvs.map((c) => c.id)).toEqual(["b1"]); + }); + + it("append updates workspace lastActivityAt", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + await store.setWorkspaceId("conv1", "my-work"); + clock = 3000; + await store.append("conv1", [userMessage("hi")]); + const ws = await store.getWorkspace("my-work"); + expect(ws?.lastActivityAt).toBe(3000); + }); + + it("forkHistory copies workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("source", "my-work"); + await store.append("source", [userMessage("hello")]); + await store.forkHistory("source", "target"); + const targetMeta = await store.getConversationMeta("target"); + expect(targetMeta?.workspaceId).toBe("my-work"); + }); + + it("replaceHistory preserves workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + await store.append("conv1", [userMessage("original")]); + await store.replaceHistory("conv1", [userMessage("replaced")]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.workspaceId).toBe("my-work"); + }); }); describe("ComputerStore", () => { - let storage: StorageNamespace; - let clock: number; - - beforeEach(() => { - storage = createMemoryStorage(); - clock = 1000; - }); - - function makeStore() { - return createConversationStore(storage, undefined, () => clock); - } - - // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- - - it("setComputerId/getComputerId round-trips an alias", async () => { - const store = makeStore(); - expect(await store.getComputerId("conv1")).toBeNull(); - await store.setComputerId("conv1", "myserver"); - expect(await store.getComputerId("conv1")).toBe("myserver"); - }); - - it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { - const store = makeStore(); - await store.setComputerId("conv1", "myserver"); - expect(await store.getComputerId("conv1")).toBe("myserver"); - // null is the "local" sentinel: it clears the persisted key so it does - // NOT linger to shadow the workspace defaultComputerId. - await store.setComputerId("conv1", null); - expect(await store.getComputerId("conv1")).toBeNull(); - // idempotent — clearing an already-absent key is a no-op. - await store.setComputerId("conv1", null); - expect(await store.getComputerId("conv1")).toBeNull(); - }); - - it("clearComputerId is idempotent and un-shadows the workspace default", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - // After clear: the workspace defaultComputerId is used (fall-through). - await store.clearComputerId("conv1"); - expect(await store.getComputerId("conv1")).toBeNull(); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - // idempotent — deleting an already-absent key is a no-op. - await store.clearComputerId("conv1"); - expect(await store.getComputerId("conv1")).toBeNull(); - }); - - // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- - - it("setWorkspaceDefaultComputerId sets and clears", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); - expect(setWs.defaultComputerId).toBe("remote-host"); - // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). - expect(setWs.lastActivityAt).toBe(1000); - const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); - expect(cleared.defaultComputerId).toBeNull(); - }); - - it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { - const store = makeStore(); - clock = 5000; - const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); - expect(ws).toEqual({ - id: "brand-new", - title: "brand-new", - defaultCwd: null, - defaultComputerId: "remote-host", - createdAt: 5000, - lastActivityAt: 5000, - }); - }); - - it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - clock = 2000; - const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); - expect(ws.defaultCwd).toBe("/workspace/root"); - expect(ws.defaultComputerId).toBe("remote-host"); - }); - - it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { - const store = makeStore(); - const ws = await store.getWorkspace("default"); - expect(ws).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - // And it surfaces null in listWorkspaces too. - const list = await store.listWorkspaces(); - const defaultWs = list.find((w) => w.id === "default"); - expect(defaultWs?.defaultComputerId).toBeNull(); - }); - - // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- - - it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - }); - - it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - }); - - it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveComputer("conv1")).toBeNull(); - }); - - it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { - const store = makeStore(); - // No explicit workspace assignment — defaults to "default" workspace - // which has defaultComputerId null. - expect(await store.getEffectiveComputer("conv1")).toBeNull(); - }); - - it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - // Before clear: the conversation computerId shadows the workspace default. - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - // After clear: the workspace defaultComputerId is used (fall-through). - await store.clearComputerId("conv1"); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - }); - - // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- - - it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - // A string override wins outright, even over a workspace defaultComputerId. - expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); - }); - - it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // The override must win over the persisted computerId. - expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); - }); - - it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // An explicit null override = "local for this turn": it wins outright and - // does NOT fall through to the persisted value or the workspace default. - expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); - }); - - it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // No second arg — persisted computerId is used. - expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); - }); - - // --- round-trip through persistence (parse/toWorkspace) --- - - it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { - const store = makeStore(); - clock = 1000; - // Create with a defaultComputerId via ensureWorkspace, then read it back - // (exercises parseWorkspaceRow -> toWorkspace round-trip). - const created = await store.ensureWorkspace("remote-work", { - title: "Remote", - defaultComputerId: "prod-server", - }); - expect(created.defaultComputerId).toBe("prod-server"); - const roundTripped = await store.getWorkspace("remote-work"); - expect(roundTripped).toEqual({ - id: "remote-work", - title: "Remote", - defaultCwd: null, - defaultComputerId: "prod-server", - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { - const store = makeStore(); - // Simulate a legacy row persisted before defaultComputerId existed: - // write a raw WorkspaceRow JSON lacking the field, then read it back. - await storage.set( - "workspace:legacy", - JSON.stringify({ - title: "legacy", - defaultCwd: "/legacy/cwd", - createdAt: 100, - lastActivityAt: 200, - }), - ); - const ws = await store.getWorkspace("legacy"); - expect(ws).toEqual({ - id: "legacy", - title: "legacy", - defaultCwd: "/legacy/cwd", - defaultComputerId: null, - createdAt: 100, - lastActivityAt: 200, - }); - }); + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore() { + return createConversationStore(storage, undefined, () => clock); + } + + // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- + + it("setComputerId/getComputerId round-trips an alias", async () => { + const store = makeStore(); + expect(await store.getComputerId("conv1")).toBeNull(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + }); + + it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { + const store = makeStore(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + // null is the "local" sentinel: it clears the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + // idempotent — clearing an already-absent key is a no-op. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + it("clearComputerId is idempotent and un-shadows the workspace default", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + // idempotent — deleting an already-absent key is a no-op. + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- + + it("setWorkspaceDefaultComputerId sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(setWs.defaultComputerId).toBe("remote-host"); + // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). + expect(setWs.lastActivityAt).toBe(1000); + const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); + expect(cleared.defaultComputerId).toBeNull(); + }); + + it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { + const store = makeStore(); + clock = 5000; + const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); + expect(ws).toEqual({ + id: "brand-new", + title: "brand-new", + defaultCwd: null, + defaultComputerId: "remote-host", + createdAt: 5000, + lastActivityAt: 5000, + }); + }); + + it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + clock = 2000; + const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(ws.defaultCwd).toBe("/workspace/root"); + expect(ws.defaultComputerId).toBe("remote-host"); + }); + + it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }); + // And it surfaces null in listWorkspaces too. + const list = await store.listWorkspaces(); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs?.defaultComputerId).toBeNull(); + }); + + // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- + + it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + }); + + it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { + const store = makeStore(); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultComputerId null. + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + // Before clear: the conversation computerId shadows the workspace default. + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- + + it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + // A string override wins outright, even over a workspace defaultComputerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // The override must win over the persisted computerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // An explicit null override = "local for this turn": it wins outright and + // does NOT fall through to the persisted value or the workspace default. + expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); + }); + + it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // No second arg — persisted computerId is used. + expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); + }); + + // --- round-trip through persistence (parse/toWorkspace) --- + + it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { + const store = makeStore(); + clock = 1000; + // Create with a defaultComputerId via ensureWorkspace, then read it back + // (exercises parseWorkspaceRow -> toWorkspace round-trip). + const created = await store.ensureWorkspace("remote-work", { + title: "Remote", + defaultComputerId: "prod-server", + }); + expect(created.defaultComputerId).toBe("prod-server"); + const roundTripped = await store.getWorkspace("remote-work"); + expect(roundTripped).toEqual({ + id: "remote-work", + title: "Remote", + defaultCwd: null, + defaultComputerId: "prod-server", + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { + const store = makeStore(); + // Simulate a legacy row persisted before defaultComputerId existed: + // write a raw WorkspaceRow JSON lacking the field, then read it back. + await storage.set( + "workspace:legacy", + JSON.stringify({ + title: "legacy", + defaultCwd: "/legacy/cwd", + createdAt: 100, + lastActivityAt: 200, + }), + ); + const ws = await store.getWorkspace("legacy"); + expect(ws).toEqual({ + id: "legacy", + title: "legacy", + defaultCwd: "/legacy/cwd", + defaultComputerId: null, + createdAt: 100, + lastActivityAt: 200, + }); + }); }); describe("isValidWorkspaceSlug", () => { - it("accepts valid slugs", () => { - expect(isValidWorkspaceSlug("my-work")).toBe(true); - expect(isValidWorkspaceSlug("default")).toBe(true); - expect(isValidWorkspaceSlug("a1b2")).toBe(true); - }); - - it("rejects invalid slugs", () => { - expect(isValidWorkspaceSlug("My-Work")).toBe(false); - expect(isValidWorkspaceSlug("-leading")).toBe(false); - expect(isValidWorkspaceSlug("trailing-")).toBe(false); - expect(isValidWorkspaceSlug("")).toBe(false); - expect(isValidWorkspaceSlug("a".repeat(41))).toBe(false); - expect(isValidWorkspaceSlug("has space")).toBe(false); - }); + it("accepts valid slugs", () => { + expect(isValidWorkspaceSlug("my-work")).toBe(true); + expect(isValidWorkspaceSlug("default")).toBe(true); + expect(isValidWorkspaceSlug("a1b2")).toBe(true); + }); + + it("rejects invalid slugs", () => { + expect(isValidWorkspaceSlug("My-Work")).toBe(false); + expect(isValidWorkspaceSlug("-leading")).toBe(false); + expect(isValidWorkspaceSlug("trailing-")).toBe(false); + expect(isValidWorkspaceSlug("")).toBe(false); + expect(isValidWorkspaceSlug("a".repeat(41))).toBe(false); + expect(isValidWorkspaceSlug("has space")).toBe(false); + }); }); diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 65b7dc3..6b12d0a 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -1,1603 +1,1603 @@ import type { - ChatMessage, - Logger, - Span, - StepId, - StorageNamespace, - TurnMetrics, + ChatMessage, + Logger, + Span, + StepId, + StorageNamespace, + TurnMetrics, } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; import { CONVERSATION_INDEX_KEY, chunkKey, metaKey } from "./keys.js"; import { createConversationStore, extractTitle } from "./store.js"; interface SpanEvent { - readonly kind: "span-open" | "span-close"; - readonly name: string; - readonly attrs?: Record | undefined; - readonly conversationId?: string | undefined; + readonly kind: "span-open" | "span-close"; + readonly name: string; + readonly attrs?: Record | undefined; + readonly conversationId?: string | undefined; } function createCapturingLogger(): { logger: Logger; events: SpanEvent[] } { - const events: SpanEvent[] = []; - - function createSpan(name: string, conversationId?: string | undefined): Span { - events.push({ kind: "span-open", name, conversationId }); - const span: Span = { - id: `span_${events.length}`, - log: createFakeLogger(conversationId), - setAttributes: () => {}, - addLink: () => {}, - child: (childName, attrs) => { - const child = createSpan(childName, conversationId); - if (attrs !== undefined) { - const prev = events[events.length - 1]; - if (prev !== undefined) { - events[events.length - 1] = { - ...prev, - attrs: attrs as Record, - }; - } - } - return child; - }, - end: (outcome) => { - const attrs = outcome?.attrs as - | Record - | undefined; - events.push({ kind: "span-close", name, attrs, conversationId }); - }, - }; - return span; - } - - function createFakeLogger(conversationId?: string | undefined): Logger { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: (ctx) => createFakeLogger(ctx.conversationId ?? conversationId), - span: (name, attrs) => { - const span = createSpan(name, conversationId); - if (attrs !== undefined) { - const prev = events[events.length - 1]; - if (prev !== undefined) { - events[events.length - 1] = { - ...prev, - attrs: attrs as Record, - }; - } - } - return span; - }, - }; - } - - return { logger: createFakeLogger(), events }; + const events: SpanEvent[] = []; + + function createSpan(name: string, conversationId?: string | undefined): Span { + events.push({ kind: "span-open", name, conversationId }); + const span: Span = { + id: `span_${events.length}`, + log: createFakeLogger(conversationId), + setAttributes: () => {}, + addLink: () => {}, + child: (childName, attrs) => { + const child = createSpan(childName, conversationId); + if (attrs !== undefined) { + const prev = events[events.length - 1]; + if (prev !== undefined) { + events[events.length - 1] = { + ...prev, + attrs: attrs as Record, + }; + } + } + return child; + }, + end: (outcome) => { + const attrs = outcome?.attrs as + | Record + | undefined; + events.push({ kind: "span-close", name, attrs, conversationId }); + }, + }; + return span; + } + + function createFakeLogger(conversationId?: string | undefined): Logger { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: (ctx) => createFakeLogger(ctx.conversationId ?? conversationId), + span: (name, attrs) => { + const span = createSpan(name, conversationId); + if (attrs !== undefined) { + const prev = events[events.length - 1]; + if (prev !== undefined) { + events[events.length - 1] = { + ...prev, + attrs: attrs as Record, + }; + } + } + return span; + }, + }; + } + + return { logger: createFakeLogger(), events }; } function createMemoryStorage(): StorageNamespace { - const data = new Map(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (!prefix) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + const data = new Map(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (!prefix) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; } describe("ConversationStore", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("returns empty array for unknown conversation", async () => { - const store = createConversationStore(storage); - const result = await store.load("nonexistent"); - expect(result).toEqual([]); - }); - - it("round-trips a single message", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - const result = await store.load("conv1"); - expect(result).toEqual([msg]); - }); - - it("round-trips multiple messages in one append", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hello" }] }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toEqual(messages); - }); - - it("accumulates messages across multiple appends", async () => { - const store = createConversationStore(storage); - const turn1: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply 1" }] }, - ]; - const turn2: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply 2" }] }, - ]; - await store.append("conv1", turn1); - await store.append("conv1", turn2); - const result = await store.load("conv1"); - expect(result).toEqual([...turn1, ...turn2]); - }); - - it("preserves message ordering", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = []; - for (let i = 0; i < 10; i++) { - messages.push({ role: "user", chunks: [{ type: "text", text: `msg ${i}` }] }); - } - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toEqual(messages); - for (let i = 0; i < 10; i++) { - const chunk = result[i]?.chunks[0]; - expect(chunk?.type === "text" ? chunk.text : null).toBe(`msg ${i}`); - } - }); - - it("isolates conversations by id", async () => { - const store = createConversationStore(storage); - const msgA: ChatMessage = { role: "user", chunks: [{ type: "text", text: "A" }] }; - const msgB: ChatMessage = { role: "user", chunks: [{ type: "text", text: "B" }] }; - await store.append("convA", [msgA]); - await store.append("convB", [msgB]); - expect(await store.load("convA")).toEqual([msgA]); - expect(await store.load("convB")).toEqual([msgB]); - }); - - it("reconciles orphaned tool-calls on load", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_1", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toHaveLength(3); - expect(result[2]?.role).toBe("tool"); - const chunk = result[2]?.chunks[0]; - expect(chunk?.type === "tool-result" ? chunk.isError : null).toBe(true); - }); - - it("handles tool-call/tool-result round-trip", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_1", - toolName: "readFile", - input: { path: "/tmp/x" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "readFile", - content: "contents", - isError: false, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toEqual(messages); - }); - - it("append assigns gap-free 1-based per-chunk seq", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { - role: "assistant", - chunks: [ - { type: "text", text: "first" }, - { type: "thinking", text: "hmm" }, - { type: "text", text: "second" }, - ], - }; - await store.append("conv1", [msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(3); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[2]?.seq).toBe(3); - }); - - it("seq continues monotonically across separate append calls", async () => { - const store = createConversationStore(storage); - const msg1: ChatMessage = { - role: "user", - chunks: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }; - const msg2: ChatMessage = { - role: "assistant", - chunks: [ - { type: "text", text: "c" }, - { type: "text", text: "d" }, - { type: "text", text: "e" }, - ], - }; - await store.append("conv1", [msg1]); - await store.append("conv1", [msg2]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(5); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[2]?.seq).toBe(3); - expect(chunks[3]?.seq).toBe(4); - expect(chunks[4]?.seq).toBe(5); - }); - - it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "world" }] }, - ]; - await store.append("conv1", messages); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[1]?.role).toBe("assistant"); - expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" }); - }); - - it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "a" }] }, - { role: "assistant", chunks: [{ type: "text", text: "b" }] }, - { role: "user", chunks: [{ type: "text", text: "c" }] }, - ]; - await store.append("conv1", messages); - const chunks = await store.loadSince("conv1", 2); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBe(3); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" }); - }); - - it("loadSince treats a non-positive / non-integer sinceSeq as 0 (from the start), honoring the contract", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "a" }] }, - { role: "assistant", chunks: [{ type: "text", text: "b" }] }, - { role: "user", chunks: [{ type: "text", text: "c" }] }, - ]; - await store.append("conv1", messages); - const all = [1, 2, 3]; - // Non-positive integers → from the start (already worked; now codified). - expect((await store.loadSince("conv1", 0)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", -2)).map((c) => c.seq)).toEqual(all); - // Non-integer values → from the start (the contract lie this fixes: - // a positive non-integer like 2.5 used to filter like sinceSeq=2). - expect((await store.loadSince("conv1", 2.5)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 2.7)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", -2.5)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", Number.POSITIVE_INFINITY)).map((c) => c.seq)).toEqual( - all, - ); - expect((await store.loadSince("conv1", Number.NaN)).map((c) => c.seq)).toEqual(all); - }); - - it("load() round-trips the exact ChatMessage[] that was appended", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "read file" }] }, - { - role: "assistant", - chunks: [ - { type: "thinking", text: "let me think" }, - { type: "text", text: "I will read it" }, - { - type: "tool-call", - toolCallId: "call_rt", - toolName: "readFile", - input: { path: "/tmp/x" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_rt", - toolName: "readFile", - content: "file contents here", - isError: false, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toEqual(messages); - }); - - it("load() does not merge consecutive same-role messages", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "first user msg" }] }, - { role: "user", chunks: [{ type: "text", text: "second user msg" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toHaveLength(3); - expect(result).toEqual(messages); - expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe( - "first user msg", - ); - expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe( - "second user msg", - ); - }); - - it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "calling tool" }, - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "someTool", - input: { x: 1 }, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toHaveLength(3); - expect(result[2]?.role).toBe("tool"); - const chunk = result[2]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.toolCallId).toBe("call_orphan"); - expect(chunk.isError).toBe(true); - expect(chunk.content).toBe("interrupted: tool execution did not complete"); - } - }); - - it("load() skips a corrupt-JSON chunk row and reconciles the rest (no throw)", async () => { - // "Never leave the system broken": a single bad row must not brick the - // conversation. The corrupt chunk is skipped; the rest loads and reconcile - // still runs normally. Fake only the OUTERMOST edge (the injected storage) - // — no @dispatch/* mocks. - const { logger } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "calling" }, - { type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }, - ], - }, - ]; - await store.append("conv_corrupt", messages); - // Corrupt the assistant text chunk (seq 2) directly in storage. - await storage.set(chunkKey("conv_corrupt", 2), "{this is not valid json"); - - const result = await store.load("conv_corrupt"); - // No throw. The user message survives; the assistant message keeps its - // tool-call (its text chunk was the corrupt row, skipped); reconcile - // synthesizes the missing tool-result for the now-orphaned tool-call. - expect(result).toEqual([ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_x", - toolName: "t", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }, - ]); - }); - - it("loadSince returns empty array for unknown conversation", async () => { - const store = createConversationStore(storage); - const result = await store.loadSince("nonexistent"); - expect(result).toEqual([]); - }); - - it("loadSince(0) returns all chunks", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { - role: "user", - chunks: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }; - await store.append("conv1", [msg]); - const all = await store.loadSince("conv1", 0); - expect(all).toHaveLength(2); - expect(all[0]?.seq).toBe(1); - expect(all[1]?.seq).toBe(2); - }); - - it("append → loadSince preserves a tool chunk's stepId", async () => { - const store = createConversationStore(storage); - const stepId = "step_abc" as StepId; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_sid", - toolName: "myTool", - input: {}, - stepId, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_sid", - toolName: "myTool", - content: "ok", - isError: false, - stepId, - }, - ], - }, - ]; - await store.append("conv1", messages); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(2); - const callChunk = chunks[0]?.chunk; - expect(callChunk?.type).toBe("tool-call"); - if (callChunk?.type === "tool-call") { - expect(callChunk.stepId).toBe(stepId); - } - const resultChunk = chunks[1]?.chunk; - expect(resultChunk?.type).toBe("tool-result"); - if (resultChunk?.type === "tool-result") { - expect(resultChunk.stepId).toBe(stepId); - } - }); - - it("load preserves a tool chunk's stepId", async () => { - const store = createConversationStore(storage); - const stepId = "step_xyz" as StepId; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_lid", - toolName: "myTool", - input: { a: 1 }, - stepId, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_lid", - toolName: "myTool", - content: "done", - isError: false, - stepId, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toHaveLength(2); - const callChunk = result[0]?.chunks[0]; - expect(callChunk?.type).toBe("tool-call"); - if (callChunk?.type === "tool-call") { - expect(callChunk.stepId).toBe(stepId); - } - const resultChunk = result[1]?.chunks[0]; - expect(resultChunk?.type).toBe("tool-result"); - if (resultChunk?.type === "tool-result") { - expect(resultChunk.stepId).toBe(stepId); - } - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("returns empty array for unknown conversation", async () => { + const store = createConversationStore(storage); + const result = await store.load("nonexistent"); + expect(result).toEqual([]); + }); + + it("round-trips a single message", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + const result = await store.load("conv1"); + expect(result).toEqual([msg]); + }); + + it("round-trips multiple messages in one append", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hello" }] }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + }); + + it("accumulates messages across multiple appends", async () => { + const store = createConversationStore(storage); + const turn1: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply 1" }] }, + ]; + const turn2: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply 2" }] }, + ]; + await store.append("conv1", turn1); + await store.append("conv1", turn2); + const result = await store.load("conv1"); + expect(result).toEqual([...turn1, ...turn2]); + }); + + it("preserves message ordering", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = []; + for (let i = 0; i < 10; i++) { + messages.push({ role: "user", chunks: [{ type: "text", text: `msg ${i}` }] }); + } + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + for (let i = 0; i < 10; i++) { + const chunk = result[i]?.chunks[0]; + expect(chunk?.type === "text" ? chunk.text : null).toBe(`msg ${i}`); + } + }); + + it("isolates conversations by id", async () => { + const store = createConversationStore(storage); + const msgA: ChatMessage = { role: "user", chunks: [{ type: "text", text: "A" }] }; + const msgB: ChatMessage = { role: "user", chunks: [{ type: "text", text: "B" }] }; + await store.append("convA", [msgA]); + await store.append("convB", [msgB]); + expect(await store.load("convA")).toEqual([msgA]); + expect(await store.load("convB")).toEqual([msgB]); + }); + + it("reconciles orphaned tool-calls on load", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result[2]?.role).toBe("tool"); + const chunk = result[2]?.chunks[0]; + expect(chunk?.type === "tool-result" ? chunk.isError : null).toBe(true); + }); + + it("handles tool-call/tool-result round-trip", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "readFile", + input: { path: "/tmp/x" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "readFile", + content: "contents", + isError: false, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + }); + + it("append assigns gap-free 1-based per-chunk seq", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "first" }, + { type: "thinking", text: "hmm" }, + { type: "text", text: "second" }, + ], + }; + await store.append("conv1", [msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(3); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + }); + + it("seq continues monotonically across separate append calls", async () => { + const store = createConversationStore(storage); + const msg1: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + const msg2: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "c" }, + { type: "text", text: "d" }, + { type: "text", text: "e" }, + ], + }; + await store.append("conv1", [msg1]); + await store.append("conv1", [msg2]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(5); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + expect(chunks[3]?.seq).toBe(4); + expect(chunks[4]?.seq).toBe(5); + }); + + it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "world" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" }); + }); + + it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "b" }] }, + { role: "user", chunks: [{ type: "text", text: "c" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1", 2); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBe(3); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" }); + }); + + it("loadSince treats a non-positive / non-integer sinceSeq as 0 (from the start), honoring the contract", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "b" }] }, + { role: "user", chunks: [{ type: "text", text: "c" }] }, + ]; + await store.append("conv1", messages); + const all = [1, 2, 3]; + // Non-positive integers → from the start (already worked; now codified). + expect((await store.loadSince("conv1", 0)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", -2)).map((c) => c.seq)).toEqual(all); + // Non-integer values → from the start (the contract lie this fixes: + // a positive non-integer like 2.5 used to filter like sinceSeq=2). + expect((await store.loadSince("conv1", 2.5)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 2.7)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", -2.5)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", Number.POSITIVE_INFINITY)).map((c) => c.seq)).toEqual( + all, + ); + expect((await store.loadSince("conv1", Number.NaN)).map((c) => c.seq)).toEqual(all); + }); + + it("load() round-trips the exact ChatMessage[] that was appended", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "read file" }] }, + { + role: "assistant", + chunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "I will read it" }, + { + type: "tool-call", + toolCallId: "call_rt", + toolName: "readFile", + input: { path: "/tmp/x" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_rt", + toolName: "readFile", + content: "file contents here", + isError: false, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + }); + + it("load() does not merge consecutive same-role messages", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "first user msg" }] }, + { role: "user", chunks: [{ type: "text", text: "second user msg" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result).toEqual(messages); + expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe( + "first user msg", + ); + expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe( + "second user msg", + ); + }); + + it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "calling tool" }, + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "someTool", + input: { x: 1 }, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result[2]?.role).toBe("tool"); + const chunk = result[2]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.toolCallId).toBe("call_orphan"); + expect(chunk.isError).toBe(true); + expect(chunk.content).toBe("interrupted: tool execution did not complete"); + } + }); + + it("load() skips a corrupt-JSON chunk row and reconciles the rest (no throw)", async () => { + // "Never leave the system broken": a single bad row must not brick the + // conversation. The corrupt chunk is skipped; the rest loads and reconcile + // still runs normally. Fake only the OUTERMOST edge (the injected storage) + // — no @dispatch/* mocks. + const { logger } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "calling" }, + { type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }, + ], + }, + ]; + await store.append("conv_corrupt", messages); + // Corrupt the assistant text chunk (seq 2) directly in storage. + await storage.set(chunkKey("conv_corrupt", 2), "{this is not valid json"); + + const result = await store.load("conv_corrupt"); + // No throw. The user message survives; the assistant message keeps its + // tool-call (its text chunk was the corrupt row, skipped); reconcile + // synthesizes the missing tool-result for the now-orphaned tool-call. + expect(result).toEqual([ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_x", + toolName: "t", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }, + ]); + }); + + it("loadSince returns empty array for unknown conversation", async () => { + const store = createConversationStore(storage); + const result = await store.loadSince("nonexistent"); + expect(result).toEqual([]); + }); + + it("loadSince(0) returns all chunks", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + await store.append("conv1", [msg]); + const all = await store.loadSince("conv1", 0); + expect(all).toHaveLength(2); + expect(all[0]?.seq).toBe(1); + expect(all[1]?.seq).toBe(2); + }); + + it("append → loadSince preserves a tool chunk's stepId", async () => { + const store = createConversationStore(storage); + const stepId = "step_abc" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_sid", + toolName: "myTool", + input: {}, + stepId, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_sid", + toolName: "myTool", + content: "ok", + isError: false, + stepId, + }, + ], + }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(2); + const callChunk = chunks[0]?.chunk; + expect(callChunk?.type).toBe("tool-call"); + if (callChunk?.type === "tool-call") { + expect(callChunk.stepId).toBe(stepId); + } + const resultChunk = chunks[1]?.chunk; + expect(resultChunk?.type).toBe("tool-result"); + if (resultChunk?.type === "tool-result") { + expect(resultChunk.stepId).toBe(stepId); + } + }); + + it("load preserves a tool chunk's stepId", async () => { + const store = createConversationStore(storage); + const stepId = "step_xyz" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_lid", + toolName: "myTool", + input: { a: 1 }, + stepId, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_lid", + toolName: "myTool", + content: "done", + isError: false, + stepId, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(2); + const callChunk = result[0]?.chunks[0]; + expect(callChunk?.type).toBe("tool-call"); + if (callChunk?.type === "tool-call") { + expect(callChunk.stepId).toBe(stepId); + } + const resultChunk = result[1]?.chunks[0]; + expect(resultChunk?.type).toBe("tool-result"); + if (resultChunk?.type === "tool-result") { + expect(resultChunk.stepId).toBe(stepId); + } + }); }); describe("ConversationStore loadSince windowing", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - // Append `count` single-chunk user messages so seq runs 1..count, gap-free. - async function seed(store: ReturnType, count: number) { - const messages: ChatMessage[] = []; - for (let i = 1; i <= count; i++) { - messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] }); - } - await store.append("conv1", messages); - } - - it("limit returns the newest N of the selection, ascending by seq", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const chunks = await store.loadSince("conv1", 0, { limit: 2 }); - expect(chunks.map((c) => c.seq)).toEqual([4, 5]); - }); - - it("limit >= selection size returns the whole selection (exact, not truncated)", async () => { - const store = createConversationStore(storage); - await seed(store, 3); - const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 }); - expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]); - const overAll = await store.loadSince("conv1", 0, { limit: 99 }); - expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]); - }); - - it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 }); - expect(chunks.map((c) => c.seq)).toEqual([1, 2]); - }); - - it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => { - const store = createConversationStore(storage); - await seed(store, 6); - const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 }); - expect(chunks.map((c) => c.seq)).toEqual([3, 4]); - }); - - it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => { - const store = createConversationStore(storage); - await seed(store, 8); - const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 }); - expect(chunks.map((c) => c.seq)).toEqual([4, 5]); - }); - - it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => { - const store = createConversationStore(storage); - await seed(store, 4); - expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]); - expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]); - }); - - it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => { - const store = createConversationStore(storage); - await seed(store, 4); - const all = [1, 2, 3, 4]; - expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all); - }); - - it("window omitted is identical to today's behavior (regression guard)", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const base = await store.loadSince("conv1", 1); - const withEmptyWindow = await store.loadSince("conv1", 1, {}); - // A caller whose window fields happen to be undefined (e.g. unset query - // params) — modelled as an optional-field record, not explicit `undefined` - // literals (which exactOptionalPropertyTypes rejects on the contract). - const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {}; - const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow); - expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]); - expect(withEmptyWindow).toEqual(base); - expect(withUndefinedFields).toEqual(base); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + // Append `count` single-chunk user messages so seq runs 1..count, gap-free. + async function seed(store: ReturnType, count: number) { + const messages: ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] }); + } + await store.append("conv1", messages); + } + + it("limit returns the newest N of the selection, ascending by seq", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("limit >= selection size returns the whole selection (exact, not truncated)", async () => { + const store = createConversationStore(storage); + await seed(store, 3); + const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 }); + expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]); + const overAll = await store.loadSince("conv1", 0, { limit: 99 }); + expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]); + }); + + it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 }); + expect(chunks.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => { + const store = createConversationStore(storage); + await seed(store, 6); + const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 }); + expect(chunks.map((c) => c.seq)).toEqual([3, 4]); + }); + + it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => { + const store = createConversationStore(storage); + await seed(store, 8); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]); + expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]); + }); + + it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + const all = [1, 2, 3, 4]; + expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all); + }); + + it("window omitted is identical to today's behavior (regression guard)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const base = await store.loadSince("conv1", 1); + const withEmptyWindow = await store.loadSince("conv1", 1, {}); + // A caller whose window fields happen to be undefined (e.g. unset query + // params) — modelled as an optional-field record, not explicit `undefined` + // literals (which exactOptionalPropertyTypes rejects on the contract). + const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {}; + const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow); + expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]); + expect(withEmptyWindow).toEqual(base); + expect(withUndefinedFields).toEqual(base); + }); }); describe("ConversationStore metrics", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => { - const store = createConversationStore(storage); - const stepId = "step_1" as StepId; - const metrics: TurnMetrics = { - turnId: "turn_abc", - usage: { inputTokens: 100, outputTokens: 50 }, - durationMs: 1234, - steps: [ - { - stepId, - usage: { inputTokens: 100, outputTokens: 50 }, - ttftMs: 200, - decodeMs: 800, - genTotalMs: 1000, - }, - ], - }; - await store.appendMetrics("conv1", metrics); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(metrics); - }); - - it("loadMetrics returns turns in append order", async () => { - const store = createConversationStore(storage); - const metrics1: TurnMetrics = { - turnId: "turn_first", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [], - }; - const metrics2: TurnMetrics = { - turnId: "turn_second", - usage: { inputTokens: 20, outputTokens: 10 }, - steps: [], - }; - const metrics3: TurnMetrics = { - turnId: "turn_third", - usage: { inputTokens: 30, outputTokens: 15 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics1); - await store.appendMetrics("conv1", metrics2); - await store.appendMetrics("conv1", metrics3); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(3); - expect(result[0]?.turnId).toBe("turn_first"); - expect(result[1]?.turnId).toBe("turn_second"); - expect(result[2]?.turnId).toBe("turn_third"); - }); - - it("loadMetrics returns [] for a conversation with no persisted metrics", async () => { - const store = createConversationStore(storage); - const result = await store.loadMetrics("nonexistent"); - expect(result).toEqual([]); - }); - - it("appendMetrics does not affect chunk load / loadSince", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - const messages = await store.load("conv1"); - expect(messages).toEqual([msg]); - - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - }); - - it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => { - const store = createConversationStore(storage); - const stepId1 = "step_a" as StepId; - const stepId2 = "step_b" as StepId; - const metrics: TurnMetrics = { - turnId: "turn_cache", - usage: { - inputTokens: 500, - outputTokens: 200, - cacheReadTokens: 300, - cacheWriteTokens: 100, - }, - durationMs: 5000, - steps: [ - { - stepId: stepId1, - usage: { - inputTokens: 300, - outputTokens: 100, - cacheReadTokens: 200, - cacheWriteTokens: 50, - }, - ttftMs: 150, - decodeMs: 600, - genTotalMs: 750, - }, - { - stepId: stepId2, - usage: { - inputTokens: 200, - outputTokens: 100, - cacheReadTokens: 100, - cacheWriteTokens: 50, - }, - ttftMs: 100, - decodeMs: 400, - genTotalMs: 500, - }, - ], - }; - await store.appendMetrics("conv1", metrics); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(metrics); - expect(result[0]?.usage.cacheReadTokens).toBe(300); - expect(result[0]?.usage.cacheWriteTokens).toBe(100); - expect(result[0]?.steps[0]?.ttftMs).toBe(150); - expect(result[0]?.steps[0]?.decodeMs).toBe(600); - expect(result[0]?.steps[0]?.genTotalMs).toBe(750); - expect(result[0]?.steps[1]?.ttftMs).toBe(100); - expect(result[0]?.steps[1]?.decodeMs).toBe(400); - expect(result[0]?.steps[1]?.genTotalMs).toBe(500); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => { + const store = createConversationStore(storage); + const stepId = "step_1" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_abc", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1234, + steps: [ + { + stepId, + usage: { inputTokens: 100, outputTokens: 50 }, + ttftMs: 200, + decodeMs: 800, + genTotalMs: 1000, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + }); + + it("loadMetrics returns turns in append order", async () => { + const store = createConversationStore(storage); + const metrics1: TurnMetrics = { + turnId: "turn_first", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [], + }; + const metrics2: TurnMetrics = { + turnId: "turn_second", + usage: { inputTokens: 20, outputTokens: 10 }, + steps: [], + }; + const metrics3: TurnMetrics = { + turnId: "turn_third", + usage: { inputTokens: 30, outputTokens: 15 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics1); + await store.appendMetrics("conv1", metrics2); + await store.appendMetrics("conv1", metrics3); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(3); + expect(result[0]?.turnId).toBe("turn_first"); + expect(result[1]?.turnId).toBe("turn_second"); + expect(result[2]?.turnId).toBe("turn_third"); + }); + + it("loadMetrics returns [] for a conversation with no persisted metrics", async () => { + const store = createConversationStore(storage); + const result = await store.loadMetrics("nonexistent"); + expect(result).toEqual([]); + }); + + it("appendMetrics does not affect chunk load / loadSince", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); + + it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => { + const store = createConversationStore(storage); + const stepId1 = "step_a" as StepId; + const stepId2 = "step_b" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_cache", + usage: { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 100, + }, + durationMs: 5000, + steps: [ + { + stepId: stepId1, + usage: { + inputTokens: 300, + outputTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 50, + }, + ttftMs: 150, + decodeMs: 600, + genTotalMs: 750, + }, + { + stepId: stepId2, + usage: { + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 100, + cacheWriteTokens: 50, + }, + ttftMs: 100, + decodeMs: 400, + genTotalMs: 500, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + expect(result[0]?.usage.cacheReadTokens).toBe(300); + expect(result[0]?.usage.cacheWriteTokens).toBe(100); + expect(result[0]?.steps[0]?.ttftMs).toBe(150); + expect(result[0]?.steps[0]?.decodeMs).toBe(600); + expect(result[0]?.steps[0]?.genTotalMs).toBe(750); + expect(result[0]?.steps[1]?.ttftMs).toBe(100); + expect(result[0]?.steps[1]?.decodeMs).toBe(400); + expect(result[0]?.steps[1]?.genTotalMs).toBe(500); + }); }); describe("ConversationStore reconcile.repair span", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("load() emits a reconcile.repair span when a dangling tool-call is repaired", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_dangle", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - await store.append("conv_span", messages); - await store.load("conv_span"); - - const spanOpens = events.filter((e) => e.kind === "span-open" && e.name === "reconcile.repair"); - const spanCloses = events.filter( - (e) => e.kind === "span-close" && e.name === "reconcile.repair", - ); - expect(spanOpens).toHaveLength(1); - expect(spanCloses).toHaveLength(1); - }); - - it("load() emits NO reconcile.repair span when the history is already valid", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]; - await store.append("conv_valid", messages); - await store.load("conv_valid"); - - const repairSpans = events.filter((e) => e.name === "reconcile.repair"); - expect(repairSpans).toHaveLength(0); - }); - - it("the reconcile.repair span carries conversationId + a repair count attribute", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_a", - toolName: "toolA", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_b", - toolName: "toolB", - input: {}, - }, - ], - }, - ]; - await store.append("conv_multi", messages); - await store.load("conv_multi"); - - const spanOpen = events.find((e) => e.kind === "span-open" && e.name === "reconcile.repair"); - expect(spanOpen).toBeDefined(); - if (spanOpen === undefined) throw new Error("expected spanOpen"); - expect(spanOpen.conversationId).toBe("conv_multi"); - expect(spanOpen.attrs).toBeDefined(); - if (spanOpen.attrs === undefined) throw new Error("expected attrs"); - expect(spanOpen.attrs.repairedCount).toBe(2); - expect(spanOpen.attrs.firstRepairedToolCallId).toBe("call_a"); - }); - - it("createConversationStore works with the logger omitted (optional)", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_nolog", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - await store.append("conv_nolog", messages); - const result = await store.load("conv_nolog"); - expect(result).toHaveLength(3); - expect(result[2]?.role).toBe("tool"); - const chunk = result[2]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.toolCallId).toBe("call_nolog"); - expect(chunk.isError).toBe(true); - } - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("load() emits a reconcile.repair span when a dangling tool-call is repaired", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_dangle", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + await store.append("conv_span", messages); + await store.load("conv_span"); + + const spanOpens = events.filter((e) => e.kind === "span-open" && e.name === "reconcile.repair"); + const spanCloses = events.filter( + (e) => e.kind === "span-close" && e.name === "reconcile.repair", + ); + expect(spanOpens).toHaveLength(1); + expect(spanCloses).toHaveLength(1); + }); + + it("load() emits NO reconcile.repair span when the history is already valid", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]; + await store.append("conv_valid", messages); + await store.load("conv_valid"); + + const repairSpans = events.filter((e) => e.name === "reconcile.repair"); + expect(repairSpans).toHaveLength(0); + }); + + it("the reconcile.repair span carries conversationId + a repair count attribute", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_a", + toolName: "toolA", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_b", + toolName: "toolB", + input: {}, + }, + ], + }, + ]; + await store.append("conv_multi", messages); + await store.load("conv_multi"); + + const spanOpen = events.find((e) => e.kind === "span-open" && e.name === "reconcile.repair"); + expect(spanOpen).toBeDefined(); + if (spanOpen === undefined) throw new Error("expected spanOpen"); + expect(spanOpen.conversationId).toBe("conv_multi"); + expect(spanOpen.attrs).toBeDefined(); + if (spanOpen.attrs === undefined) throw new Error("expected attrs"); + expect(spanOpen.attrs.repairedCount).toBe(2); + expect(spanOpen.attrs.firstRepairedToolCallId).toBe("call_a"); + }); + + it("createConversationStore works with the logger omitted (optional)", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_nolog", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + await store.append("conv_nolog", messages); + const result = await store.load("conv_nolog"); + expect(result).toHaveLength(3); + expect(result[2]?.role).toBe("tool"); + const chunk = result[2]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.toolCallId).toBe("call_nolog"); + expect(chunk.isError).toBe(true); + } + }); }); describe("ConversationStore cwd", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("setCwd then getCwd returns the value", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/home/user/project"); - const result = await store.getCwd("conv1"); - expect(result).toBe("/home/user/project"); - }); - - it("getCwd returns null when never set", async () => { - const store = createConversationStore(storage); - const result = await store.getCwd("conv_unknown"); - expect(result).toBeNull(); - }); - - it("setCwd is an upsert (second set overwrites)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/first/path"); - await store.setCwd("conv1", "/second/path"); - const result = await store.getCwd("conv1"); - expect(result).toBe("/second/path"); - }); - - it("cwd persists across a fresh store instance on the same db file", async () => { - const store1 = createConversationStore(storage); - await store1.setCwd("conv1", "/persisted/path"); - - const store2 = createConversationStore(storage); - const result = await store2.getCwd("conv1"); - expect(result).toBe("/persisted/path"); - }); - - it("cwd of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setCwd("convA", "/path/a"); - await store.setCwd("convB", "/path/b"); - expect(await store.getCwd("convA")).toBe("/path/a"); - expect(await store.getCwd("convB")).toBe("/path/b"); - }); - - it("setCwd then clearCwd → getCwd returns null", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/some/path"); - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - }); - - it("clearCwd on a conversation that never had a cwd set → no error, getCwd null", async () => { - const store = createConversationStore(storage); - await expect(store.clearCwd("never-seen")).resolves.toBeUndefined(); - expect(await store.getCwd("never-seen")).toBeNull(); - }); - - it("clearCwd does not affect other conversations' cwds or other key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/path/one"); - await store.setCwd("conv2", "/path/two"); - await store.setReasoningEffort("conv1", "high"); - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - // Clear conv1's cwd only. - await store.clearCwd("conv1"); - - // conv1 cwd is gone, but conv2 cwd survives. - expect(await store.getCwd("conv1")).toBeNull(); - expect(await store.getCwd("conv2")).toBe("/path/two"); - - // Other key spaces on conv1 are untouched. - expect(await store.getReasoningEffort("conv1")).toBe("high"); - expect(await store.load("conv1")).toEqual([msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); - - it("clearCwd is idempotent (clearing twice is a no-op)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/some/path"); - await store.clearCwd("conv1"); - // Second clear on an already-absent key — no error. - await expect(store.clearCwd("conv1")).resolves.toBeUndefined(); - expect(await store.getCwd("conv1")).toBeNull(); - }); - - it("setCwd after clearCwd re-persists the cwd (clear is a true delete, not a tombstone)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/first"); - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - await store.setCwd("conv1", "/second"); - expect(await store.getCwd("conv1")).toBe("/second"); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setCwd then getCwd returns the value", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/home/user/project"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/home/user/project"); + }); + + it("getCwd returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getCwd("conv_unknown"); + expect(result).toBeNull(); + }); + + it("setCwd is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/first/path"); + await store.setCwd("conv1", "/second/path"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/second/path"); + }); + + it("cwd persists across a fresh store instance on the same db file", async () => { + const store1 = createConversationStore(storage); + await store1.setCwd("conv1", "/persisted/path"); + + const store2 = createConversationStore(storage); + const result = await store2.getCwd("conv1"); + expect(result).toBe("/persisted/path"); + }); + + it("cwd of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setCwd("convA", "/path/a"); + await store.setCwd("convB", "/path/b"); + expect(await store.getCwd("convA")).toBe("/path/a"); + expect(await store.getCwd("convB")).toBe("/path/b"); + }); + + it("setCwd then clearCwd → getCwd returns null", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/some/path"); + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + }); + + it("clearCwd on a conversation that never had a cwd set → no error, getCwd null", async () => { + const store = createConversationStore(storage); + await expect(store.clearCwd("never-seen")).resolves.toBeUndefined(); + expect(await store.getCwd("never-seen")).toBeNull(); + }); + + it("clearCwd does not affect other conversations' cwds or other key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/path/one"); + await store.setCwd("conv2", "/path/two"); + await store.setReasoningEffort("conv1", "high"); + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + // Clear conv1's cwd only. + await store.clearCwd("conv1"); + + // conv1 cwd is gone, but conv2 cwd survives. + expect(await store.getCwd("conv1")).toBeNull(); + expect(await store.getCwd("conv2")).toBe("/path/two"); + + // Other key spaces on conv1 are untouched. + expect(await store.getReasoningEffort("conv1")).toBe("high"); + expect(await store.load("conv1")).toEqual([msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); + + it("clearCwd is idempotent (clearing twice is a no-op)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/some/path"); + await store.clearCwd("conv1"); + // Second clear on an already-absent key — no error. + await expect(store.clearCwd("conv1")).resolves.toBeUndefined(); + expect(await store.getCwd("conv1")).toBeNull(); + }); + + it("setCwd after clearCwd re-persists the cwd (clear is a true delete, not a tombstone)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/first"); + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + await store.setCwd("conv1", "/second"); + expect(await store.getCwd("conv1")).toBe("/second"); + }); }); describe("ConversationStore reasoning effort", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("setReasoningEffort then getReasoningEffort returns the level", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("conv1", "high"); - const result = await store.getReasoningEffort("conv1"); - expect(result).toBe("high"); - }); - - it("getReasoningEffort returns null when never set", async () => { - const store = createConversationStore(storage); - const result = await store.getReasoningEffort("conv_unknown"); - expect(result).toBeNull(); - }); - - it("reasoning effort of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("convA", "low"); - await store.setReasoningEffort("convB", "max"); - expect(await store.getReasoningEffort("convA")).toBe("low"); - expect(await store.getReasoningEffort("convB")).toBe("max"); - }); - - it("setReasoningEffort is an upsert (second set overwrites)", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("conv1", "medium"); - await store.setReasoningEffort("conv1", "xhigh"); - const result = await store.getReasoningEffort("conv1"); - expect(result).toBe("xhigh"); - }); - - it("reasoning effort persists across a fresh store instance on the same storage", async () => { - const store1 = createConversationStore(storage); - await store1.setReasoningEffort("conv1", "max"); - - const store2 = createConversationStore(storage); - const result = await store2.getReasoningEffort("conv1"); - expect(result).toBe("max"); - }); - - it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/some/path"); - await store.setReasoningEffort("conv1", "low"); - - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - const messages = await store.load("conv1"); - expect(messages).toEqual([msg]); - - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - - expect(await store.getCwd("conv1")).toBe("/some/path"); - expect(await store.getReasoningEffort("conv1")).toBe("low"); - - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setReasoningEffort then getReasoningEffort returns the level", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "high"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("high"); + }); + + it("getReasoningEffort returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getReasoningEffort("conv_unknown"); + expect(result).toBeNull(); + }); + + it("reasoning effort of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("convA", "low"); + await store.setReasoningEffort("convB", "max"); + expect(await store.getReasoningEffort("convA")).toBe("low"); + expect(await store.getReasoningEffort("convB")).toBe("max"); + }); + + it("setReasoningEffort is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "medium"); + await store.setReasoningEffort("conv1", "xhigh"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("xhigh"); + }); + + it("reasoning effort persists across a fresh store instance on the same storage", async () => { + const store1 = createConversationStore(storage); + await store1.setReasoningEffort("conv1", "max"); + + const store2 = createConversationStore(storage); + const result = await store2.getReasoningEffort("conv1"); + expect(result).toBe("max"); + }); + + it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/some/path"); + await store.setReasoningEffort("conv1", "low"); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + + expect(await store.getCwd("conv1")).toBe("/some/path"); + expect(await store.getReasoningEffort("conv1")).toBe("low"); + + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); }); describe("ConversationStore model", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("getModel returns null when never set", async () => { - const store = createConversationStore(storage); - expect(await store.getModel("conv_unknown")).toBeNull(); - }); - - it("setModel then getModel returns the model name", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - }); - - it("setModel is an upsert (second set overwrites with the latest)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.setModel("conv1", "openai/gpt-4o"); - expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); - }); - - it("setModel with an empty string clears the key (getModel returns null)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - // Clear via the empty-string sentinel. - await store.setModel("conv1", ""); - expect(await store.getModel("conv1")).toBeNull(); - }); - - it("setModel('') on a never-set conversation is a no-op (idempotent clear)", async () => { - const store = createConversationStore(storage); - await expect(store.setModel("never-seen", "")).resolves.toBeUndefined(); - expect(await store.getModel("never-seen")).toBeNull(); - }); - - it("setModel after a clear re-persists the model (clear is a true delete, not a tombstone)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.setModel("conv1", ""); - expect(await store.getModel("conv1")).toBeNull(); - await store.setModel("conv1", "openai/gpt-4o"); - expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); - }); - - it("model of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setModel("convA", "umans/umans-glm-5.2"); - await store.setModel("convB", "openai/gpt-4o"); - expect(await store.getModel("convA")).toBe("umans/umans-glm-5.2"); - expect(await store.getModel("convB")).toBe("openai/gpt-4o"); - }); - - it("model persists across a fresh store instance on the same storage", async () => { - const store1 = createConversationStore(storage); - await store1.setModel("conv1", "umans/umans-glm-5.2"); - - const store2 = createConversationStore(storage); - expect(await store2.getModel("conv1")).toBe("umans/umans-glm-5.2"); - }); - - it("model keys do not collide with chunk/cwd/metrics/reasoning-effort key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/some/path"); - await store.setReasoningEffort("conv1", "low"); - await store.setModel("conv1", "umans/umans-glm-5.2"); - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - expect(await store.load("conv1")).toEqual([msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - expect(await store.getCwd("conv1")).toBe("/some/path"); - expect(await store.getReasoningEffort("conv1")).toBe("low"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); - - it("forkHistory copies the model to the target", async () => { - const store = createConversationStore(storage); - await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - await store.setModel("source", "umans/umans-glm-5.2"); - await store.forkHistory("source", "target"); - expect(await store.getModel("target")).toBe("umans/umans-glm-5.2"); - }); - - it("forkHistory copies a cleared (unset) model as absent (target reads null)", async () => { - const store = createConversationStore(storage); - await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - // No model set on source. - await store.forkHistory("source", "target"); - expect(await store.getModel("target")).toBeNull(); - }); - - it("replaceHistory preserves the model", async () => { - const store = createConversationStore(storage); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.replaceHistory("conv1", [ - { role: "user", chunks: [{ type: "text", text: "replaced" }] }, - ]); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - // History was replaced; the model survived the chunk-only sweep. - expect(await store.load("conv1")).toEqual([ - { role: "user", chunks: [{ type: "text", text: "replaced" }] }, - ]); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("getModel returns null when never set", async () => { + const store = createConversationStore(storage); + expect(await store.getModel("conv_unknown")).toBeNull(); + }); + + it("setModel then getModel returns the model name", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + }); + + it("setModel is an upsert (second set overwrites with the latest)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.setModel("conv1", "openai/gpt-4o"); + expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); + }); + + it("setModel with an empty string clears the key (getModel returns null)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + // Clear via the empty-string sentinel. + await store.setModel("conv1", ""); + expect(await store.getModel("conv1")).toBeNull(); + }); + + it("setModel('') on a never-set conversation is a no-op (idempotent clear)", async () => { + const store = createConversationStore(storage); + await expect(store.setModel("never-seen", "")).resolves.toBeUndefined(); + expect(await store.getModel("never-seen")).toBeNull(); + }); + + it("setModel after a clear re-persists the model (clear is a true delete, not a tombstone)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.setModel("conv1", ""); + expect(await store.getModel("conv1")).toBeNull(); + await store.setModel("conv1", "openai/gpt-4o"); + expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); + }); + + it("model of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setModel("convA", "umans/umans-glm-5.2"); + await store.setModel("convB", "openai/gpt-4o"); + expect(await store.getModel("convA")).toBe("umans/umans-glm-5.2"); + expect(await store.getModel("convB")).toBe("openai/gpt-4o"); + }); + + it("model persists across a fresh store instance on the same storage", async () => { + const store1 = createConversationStore(storage); + await store1.setModel("conv1", "umans/umans-glm-5.2"); + + const store2 = createConversationStore(storage); + expect(await store2.getModel("conv1")).toBe("umans/umans-glm-5.2"); + }); + + it("model keys do not collide with chunk/cwd/metrics/reasoning-effort key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/some/path"); + await store.setReasoningEffort("conv1", "low"); + await store.setModel("conv1", "umans/umans-glm-5.2"); + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + expect(await store.load("conv1")).toEqual([msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + expect(await store.getCwd("conv1")).toBe("/some/path"); + expect(await store.getReasoningEffort("conv1")).toBe("low"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); + + it("forkHistory copies the model to the target", async () => { + const store = createConversationStore(storage); + await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setModel("source", "umans/umans-glm-5.2"); + await store.forkHistory("source", "target"); + expect(await store.getModel("target")).toBe("umans/umans-glm-5.2"); + }); + + it("forkHistory copies a cleared (unset) model as absent (target reads null)", async () => { + const store = createConversationStore(storage); + await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + // No model set on source. + await store.forkHistory("source", "target"); + expect(await store.getModel("target")).toBeNull(); + }); + + it("replaceHistory preserves the model", async () => { + const store = createConversationStore(storage); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.replaceHistory("conv1", [ + { role: "user", chunks: [{ type: "text", text: "replaced" }] }, + ]); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + // History was replaced; the model survived the chunk-only sweep. + expect(await store.load("conv1")).toEqual([ + { role: "user", chunks: [{ type: "text", text: "replaced" }] }, + ]); + }); }); describe("ConversationStore conversation metadata + list + title", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("listConversations: returns empty array when no conversations exist", async () => { - const store = createConversationStore(storage); - expect(await store.listConversations()).toEqual([]); - }); - - it("listConversations: returns conversations sorted by lastActivityAt desc", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); - clock = 2000; - await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]); - clock = 3000; - await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]); - // Bump conv1 to the most recent activity. - clock = 4000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); - - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]); - }); - - it("listConversations: includes id + createdAt + lastActivityAt + title", async () => { - const store = createConversationStore(storage, undefined, () => 12345); - await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]); - const list = await store.listConversations(); - expect(list).toHaveLength(1); - const first = list[0]; - if (first === undefined) throw new Error("expected list entry"); - expect(first).toEqual({ - id: "convX", - createdAt: 12345, - lastActivityAt: 12345, - title: "my title", - status: "idle", - workspaceId: "default", - }); - }); - - it("getConversationMeta: returns null for unknown conversation", async () => { - const store = createConversationStore(storage); - expect(await store.getConversationMeta("unknown")).toBeNull(); - }); - - it("getConversationMeta: returns metadata for known conversation", async () => { - const store = createConversationStore(storage, undefined, () => 7777); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - expect(await store.getConversationMeta("conv1")).toEqual({ - id: "conv1", - createdAt: 7777, - lastActivityAt: 7777, - title: "hello", - status: "idle", - workspaceId: "default", - }); - }); - - it("getConversationMeta: returns null on a corrupt meta row", async () => { - const store = createConversationStore(storage); - // Write a meta row with the wrong shape directly to storage. - await storage.set("conv:conv1:meta", "{not json"); - expect(await store.getConversationMeta("conv1")).toBeNull(); - }); - - it("setConversationTitle: updates the title", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); - await store.setConversationTitle("conv1", "custom title"); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe("custom title"); - // createdAt + lastActivityAt are preserved (setTitle does not bump them). - expect(meta?.createdAt).toBe(1000); - expect(meta?.lastActivityAt).toBe(1000); - }); - - it("setConversationTitle: creates meta if conversation is new", async () => { - const store = createConversationStore(storage, undefined, () => 5000); - await store.setConversationTitle("convNew", "preset title"); - expect(await store.getConversationMeta("convNew")).toEqual({ - id: "convNew", - createdAt: 5000, - lastActivityAt: 5000, - title: "preset title", - status: "idle", - workspaceId: "default", - }); - // And the new conversation is discoverable in the index. - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["convNew"]); - }); - - it("append: auto-sets title from first user message", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [ - { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, - { role: "user", chunks: [{ type: "text", text: "hello world" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world"); - }); - - it("append: truncates long titles to 80 chars", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - const longText = "x".repeat(100); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe(`${longText.slice(0, 80)}…`); - expect(meta?.title.length).toBe(81); - }); - - it("append: sets createdAt on first write, preserves on subsequent", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); - clock = 5000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.createdAt).toBe(1000); - expect(meta?.lastActivityAt).toBe(5000); - }); - - it("append: updates lastActivityAt on every write", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000); - clock = 2000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000); - clock = 3000; - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000); - }); - - it('append: title "Untitled" updated when first user message arrives in later append', async () => { - const store = createConversationStore(storage, undefined, () => 1000); - // First append — assistant only, no user message yet → "Untitled". - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled"); - // Second append — the first user message arrives → title is re-derived. - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("what now"); - }); - - it("append: a non-Untitled title is NOT overwritten by a later user message", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [ - { role: "user", chunks: [{ type: "text", text: "first question" }] }, - ]); - await store.append("conv1", [ - { role: "user", chunks: [{ type: "text", text: "second question" }] }, - ]); - // The title stays as the first user message; later user messages do not clobber. - expect((await store.getConversationMeta("conv1"))?.title).toBe("first question"); - }); - - it("append: does not add the same conversation to the index twice", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - const list = await store.listConversations(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("conv1"); - }); - - it("listConversations: skips index entries whose meta row is missing", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - // Manually corrupt the index by adding an id with no meta row. - await storage.set("conv-index", JSON.stringify(["conv1", "ghost"])); - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["conv1"]); - }); - - it("metadata persists across a fresh store instance on the same storage", async () => { - const clock = 1000; - const store1 = createConversationStore(storage, undefined, () => clock); - await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]); - - const store2 = createConversationStore(storage); - const meta = await store2.getConversationMeta("conv1"); - expect(meta).toEqual({ - id: "conv1", - createdAt: 1000, - lastActivityAt: 1000, - title: "persisted", - status: "idle", - workspaceId: "default", - }); - const list = await store2.listConversations(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("conv1"); - }); - - describe("ConversationStore conversation status", () => { - it("new conversation defaults to idle", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - expect(await store.getConversationStatus("conv1")).toBe("idle"); - expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); - }); - - it("setConversationStatus updates status on existing conversation", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - - await store.setConversationStatus("conv1", "active"); - expect(await store.getConversationStatus("conv1")).toBe("active"); - - await store.setConversationStatus("conv1", "idle"); - expect(await store.getConversationStatus("conv1")).toBe("idle"); - - await store.setConversationStatus("conv1", "closed"); - expect(await store.getConversationStatus("conv1")).toBe("closed"); - }); - - it("setConversationStatus creates minimal row for unknown conversation", async () => { - const store = createConversationStore(storage, undefined, () => 2000); - await store.setConversationStatus("convNew", "closed"); - expect(await store.getConversationStatus("convNew")).toBe("closed"); - const meta = await store.getConversationMeta("convNew"); - expect(meta?.status).toBe("closed"); - expect(meta?.title).toBe("Untitled"); - }); - - it("setConversationStatus preserves other metadata", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - await store.setConversationTitle("conv1", "custom title"); - - await store.setConversationStatus("conv1", "active"); - - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe("custom title"); - expect(meta?.createdAt).toBe(1000); - expect(meta?.status).toBe("active"); - }); - - it("listConversations filters by status", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); - await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - - await store.setConversationStatus("conv1", "active"); - await store.setConversationStatus("conv2", "closed"); - - const activeOnly = await store.listConversations({ status: ["active"] }); - expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); - - const idleOnly = await store.listConversations({ status: ["idle"] }); - expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); - - const activeIdle = await store.listConversations({ status: ["active", "idle"] }); - expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); - - const all = await store.listConversations(); - expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); - }); - - it("status persists across a fresh store instance", async () => { - const store1 = createConversationStore(storage, undefined, () => 1000); - await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - await store1.setConversationStatus("conv1", "active"); - - const store2 = createConversationStore(storage); - expect(await store2.getConversationStatus("conv1")).toBe("active"); - }); - - it("old meta rows without status default to idle on read", async () => { - // Simulate a pre-status meta row written by an older version. - await storage.set( - metaKey("conv1"), - JSON.stringify({ - createdAt: 1000, - lastActivityAt: 1000, - title: "old", - }), - ); - await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); - - const store = createConversationStore(storage); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.status).toBe("idle"); - }); - }); - it("extractTitle: returns first user text", () => { - const messages: ChatMessage[] = [ - { role: "system", chunks: [{ type: "text", text: "sys" }] }, - { role: "assistant", chunks: [{ type: "text", text: "greeting" }] }, - { role: "user", chunks: [{ type: "text", text: "my question" }] }, - { role: "assistant", chunks: [{ type: "text", text: "answer" }] }, - ]; - expect(extractTitle(messages)).toBe("my question"); - }); - - it('extractTitle: returns "Untitled" when no user message', () => { - expect(extractTitle([])).toBe("Untitled"); - expect( - extractTitle([ - { role: "system", chunks: [{ type: "text", text: "sys" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]), - ).toBe("Untitled"); - // A user message with no text chunk also yields "Untitled". - expect( - extractTitle([ - { - role: "user", - chunks: [ - { - type: "tool-result", - toolCallId: "c", - toolName: "t", - content: "x", - isError: false, - }, - ], - }, - ]), - ).toBe("Untitled"); - }); - - it("extractTitle: truncates to 80 chars", () => { - const exactly80 = "a".repeat(80); - const over80 = "a".repeat(81); - const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe( - exactly80, - ); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe( - `${over80.slice(0, 80)}…`, - ); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe( - `${wayOver.slice(0, 80)}…`, - ); - }); - - it("extractTitle: uses the first text chunk of the first user message", () => { - expect( - extractTitle([ - { - role: "user", - chunks: [ - { type: "text", text: "first chunk" }, - { type: "text", text: "second chunk" }, - ], - }, - ]), - ).toBe("first chunk"); - }); - - it("extractTitle: skips a user message with no text chunk, finds the next", () => { - expect( - extractTitle([ - { - role: "user", - chunks: [ - { - type: "tool-result", - toolCallId: "c", - toolName: "t", - content: "x", - isError: false, - }, - ], - }, - { role: "user", chunks: [{ type: "text", text: "real question" }] }, - ]), - ).toBe("real question"); - }); - - it("extractTitle: does not mutate the input", () => { - const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]; - const snapshot = JSON.stringify(messages); - extractTitle(messages); - expect(JSON.stringify(messages)).toBe(snapshot); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("listConversations: returns empty array when no conversations exist", async () => { + const store = createConversationStore(storage); + expect(await store.listConversations()).toEqual([]); + }); + + it("listConversations: returns conversations sorted by lastActivityAt desc", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 2000; + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]); + clock = 3000; + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]); + // Bump conv1 to the most recent activity. + clock = 4000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]); + }); + + it("listConversations: includes id + createdAt + lastActivityAt + title", async () => { + const store = createConversationStore(storage, undefined, () => 12345); + await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + const first = list[0]; + if (first === undefined) throw new Error("expected list entry"); + expect(first).toEqual({ + id: "convX", + createdAt: 12345, + lastActivityAt: 12345, + title: "my title", + status: "idle", + workspaceId: "default", + }); + }); + + it("getConversationMeta: returns null for unknown conversation", async () => { + const store = createConversationStore(storage); + expect(await store.getConversationMeta("unknown")).toBeNull(); + }); + + it("getConversationMeta: returns metadata for known conversation", async () => { + const store = createConversationStore(storage, undefined, () => 7777); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + expect(await store.getConversationMeta("conv1")).toEqual({ + id: "conv1", + createdAt: 7777, + lastActivityAt: 7777, + title: "hello", + status: "idle", + workspaceId: "default", + }); + }); + + it("getConversationMeta: returns null on a corrupt meta row", async () => { + const store = createConversationStore(storage); + // Write a meta row with the wrong shape directly to storage. + await storage.set("conv:conv1:meta", "{not json"); + expect(await store.getConversationMeta("conv1")).toBeNull(); + }); + + it("setConversationTitle: updates the title", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); + await store.setConversationTitle("conv1", "custom title"); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + // createdAt + lastActivityAt are preserved (setTitle does not bump them). + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(1000); + }); + + it("setConversationTitle: creates meta if conversation is new", async () => { + const store = createConversationStore(storage, undefined, () => 5000); + await store.setConversationTitle("convNew", "preset title"); + expect(await store.getConversationMeta("convNew")).toEqual({ + id: "convNew", + createdAt: 5000, + lastActivityAt: 5000, + title: "preset title", + status: "idle", + workspaceId: "default", + }); + // And the new conversation is discoverable in the index. + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["convNew"]); + }); + + it("append: auto-sets title from first user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, + { role: "user", chunks: [{ type: "text", text: "hello world" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world"); + }); + + it("append: truncates long titles to 80 chars", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + const longText = "x".repeat(100); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe(`${longText.slice(0, 80)}…`); + expect(meta?.title.length).toBe(81); + }); + + it("append: sets createdAt on first write, preserves on subsequent", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 5000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(5000); + }); + + it("append: updates lastActivityAt on every write", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000); + clock = 2000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000); + clock = 3000; + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000); + }); + + it('append: title "Untitled" updated when first user message arrives in later append', async () => { + const store = createConversationStore(storage, undefined, () => 1000); + // First append — assistant only, no user message yet → "Untitled". + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled"); + // Second append — the first user message arrives → title is re-derived. + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("what now"); + }); + + it("append: a non-Untitled title is NOT overwritten by a later user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "first question" }] }, + ]); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "second question" }] }, + ]); + // The title stays as the first user message; later user messages do not clobber. + expect((await store.getConversationMeta("conv1"))?.title).toBe("first question"); + }); + + it("append: does not add the same conversation to the index twice", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); + + it("listConversations: skips index entries whose meta row is missing", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + // Manually corrupt the index by adding an id with no meta row. + await storage.set("conv-index", JSON.stringify(["conv1", "ghost"])); + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1"]); + }); + + it("metadata persists across a fresh store instance on the same storage", async () => { + const clock = 1000; + const store1 = createConversationStore(storage, undefined, () => clock); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]); + + const store2 = createConversationStore(storage); + const meta = await store2.getConversationMeta("conv1"); + expect(meta).toEqual({ + id: "conv1", + createdAt: 1000, + lastActivityAt: 1000, + title: "persisted", + status: "idle", + workspaceId: "default", + }); + const list = await store2.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); + + describe("ConversationStore conversation status", () => { + it("new conversation defaults to idle", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); + }); + + it("setConversationStatus updates status on existing conversation", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + await store.setConversationStatus("conv1", "active"); + expect(await store.getConversationStatus("conv1")).toBe("active"); + + await store.setConversationStatus("conv1", "idle"); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + + await store.setConversationStatus("conv1", "closed"); + expect(await store.getConversationStatus("conv1")).toBe("closed"); + }); + + it("setConversationStatus creates minimal row for unknown conversation", async () => { + const store = createConversationStore(storage, undefined, () => 2000); + await store.setConversationStatus("convNew", "closed"); + expect(await store.getConversationStatus("convNew")).toBe("closed"); + const meta = await store.getConversationMeta("convNew"); + expect(meta?.status).toBe("closed"); + expect(meta?.title).toBe("Untitled"); + }); + + it("setConversationStatus preserves other metadata", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setConversationTitle("conv1", "custom title"); + + await store.setConversationStatus("conv1", "active"); + + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.status).toBe("active"); + }); + + it("listConversations filters by status", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + + await store.setConversationStatus("conv1", "active"); + await store.setConversationStatus("conv2", "closed"); + + const activeOnly = await store.listConversations({ status: ["active"] }); + expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); + + const idleOnly = await store.listConversations({ status: ["idle"] }); + expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); + + const activeIdle = await store.listConversations({ status: ["active", "idle"] }); + expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); + + const all = await store.listConversations(); + expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); + }); + + it("status persists across a fresh store instance", async () => { + const store1 = createConversationStore(storage, undefined, () => 1000); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store1.setConversationStatus("conv1", "active"); + + const store2 = createConversationStore(storage); + expect(await store2.getConversationStatus("conv1")).toBe("active"); + }); + + it("old meta rows without status default to idle on read", async () => { + // Simulate a pre-status meta row written by an older version. + await storage.set( + metaKey("conv1"), + JSON.stringify({ + createdAt: 1000, + lastActivityAt: 1000, + title: "old", + }), + ); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); + + const store = createConversationStore(storage); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.status).toBe("idle"); + }); + }); + it("extractTitle: returns first user text", () => { + const messages: ChatMessage[] = [ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "greeting" }] }, + { role: "user", chunks: [{ type: "text", text: "my question" }] }, + { role: "assistant", chunks: [{ type: "text", text: "answer" }] }, + ]; + expect(extractTitle(messages)).toBe("my question"); + }); + + it('extractTitle: returns "Untitled" when no user message', () => { + expect(extractTitle([])).toBe("Untitled"); + expect( + extractTitle([ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]), + ).toBe("Untitled"); + // A user message with no text chunk also yields "Untitled". + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + ]), + ).toBe("Untitled"); + }); + + it("extractTitle: truncates to 80 chars", () => { + const exactly80 = "a".repeat(80); + const over80 = "a".repeat(81); + const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe( + exactly80, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe( + `${over80.slice(0, 80)}…`, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe( + `${wayOver.slice(0, 80)}…`, + ); + }); + + it("extractTitle: uses the first text chunk of the first user message", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { type: "text", text: "first chunk" }, + { type: "text", text: "second chunk" }, + ], + }, + ]), + ).toBe("first chunk"); + }); + + it("extractTitle: skips a user message with no text chunk, finds the next", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + { role: "user", chunks: [{ type: "text", text: "real question" }] }, + ]), + ).toBe("real question"); + }); + + it("extractTitle: does not mutate the input", () => { + const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]; + const snapshot = JSON.stringify(messages); + extractTitle(messages); + expect(JSON.stringify(messages)).toBe(snapshot); + }); }); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 2fd0a0c..f90e809 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,266 +1,266 @@ import { resolve as pathResolve } from "node:path"; import type { - ChatMessage, - Chunk, - ConversationMeta, - ConversationStatus, - Logger, - ReasoningEffort, - Role, - StorageNamespace, - StoredChunk, - TurnMetrics, + ChatMessage, + Chunk, + ConversationMeta, + ConversationStatus, + Logger, + ReasoningEffort, + Role, + StorageNamespace, + StoredChunk, + TurnMetrics, } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { - CONVERSATION_INDEX_KEY, - chunkKey, - chunkPrefix, - compactThresholdKey, - computerKey, - cwdKey, - metaKey, - metricsKey, - metricsPrefix, - metricsSeqKey, - modelKey, - parseSeq, - reasoningEffortKey, - seqKey, - workspaceKey, + CONVERSATION_INDEX_KEY, + chunkKey, + chunkPrefix, + compactThresholdKey, + computerKey, + cwdKey, + metaKey, + metricsKey, + metricsPrefix, + metricsSeqKey, + modelKey, + parseSeq, + reasoningEffortKey, + seqKey, + workspaceKey, } from "./keys.js"; import { reconcileWithReport } from "./reconcile.js"; export interface ConversationStore { - readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise; - readonly load: (conversationId: string) => Promise; - /** - * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, - * ascending by seq. The raw append-order log; NOT reconciled (a dangling - * tool-call is returned as-is — repair is a turn-path concern). - * - * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; - * omitted/`0`/non-positive/non-integer = from the start). When - * `window.beforeSeq` is given it is an exclusive upper bound - * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. - * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the - * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` - * chunks is returned whole (exact, not truncated). - * - **Omitted = unchanged** — `window` absent (or both its fields undefined) - * is byte-identical to the pre-windowing behavior, so existing callers that - * pass no third argument are unaffected. - * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or - * `beforeSeq`) is treated as ABSENT (full selection); this method never - * throws on bad window input. The transport validates and 400s upstream. - * - * Seq numbering is 1-based and gap-free, so a client derives "older chunks - * exist" purely from the oldest returned `seq > 1`; there is deliberately no - * `earliestSeq`/high-water-mark API. - */ - readonly loadSince: ( - conversationId: string, - sinceSeq?: number, - window?: { readonly beforeSeq?: number; readonly limit?: number }, - ) => Promise; - readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise; - readonly loadMetrics: (conversationId: string) => Promise; - /** The persisted working directory for a conversation, or null if never set. */ - readonly getCwd: (conversationId: string) => Promise; - /** Persist (upsert) the working directory for a conversation. */ - readonly setCwd: (conversationId: string, cwd: string) => Promise; - /** Clear (delete) the persisted working directory for a conversation. */ - readonly clearCwd: (conversationId: string) => Promise; - /** - * The persisted computer (SSH config `Host` alias) for a conversation, or - * `null` if never set (local). The computer analog of `getCwd`. - */ - readonly getComputerId: (conversationId: string) => Promise; - /** - * Persist (upsert) the computer for a conversation. Passing `null` clears - * the persisted selection (idempotent) — `null` is the "local" sentinel - * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors - * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). - */ - readonly setComputerId: (conversationId: string, alias: string | null) => Promise; - /** Clear (delete) the persisted computer for a conversation. */ - readonly clearComputerId: (conversationId: string) => Promise; - /** The persisted reasoning-effort level for a conversation, or null if never set. */ - readonly getReasoningEffort: (conversationId: string) => Promise; - /** Persist (upsert) the reasoning-effort level for a conversation. */ - readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise; - /** The persisted model name for a conversation, or null if never set. */ - readonly getModel: (conversationId: string) => Promise; - /** - * Persist (upsert) the model name for a conversation (a model name in - * `/` form). Passing an empty string clears the - * persisted selection (idempotent) — this is how transport-http clears via - * `PUT /conversations/:id/model` with a `null` body. - */ - readonly setModel: (conversationId: string, model: string) => Promise; - /** - * List all known conversations, sorted by `lastActivityAt` descending (most - * recent first). Metadata (createdAt, lastActivityAt, title) is tracked - * automatically on append; title defaults to the first user message. - */ - readonly listConversations: (filter?: { - readonly status?: readonly ConversationStatus[]; - readonly workspaceId?: string; - }) => Promise; - /** Single conversation metadata, or null if unknown. */ - readonly getConversationMeta: (conversationId: string) => Promise; - /** Set/update the human-readable title for a conversation. */ - readonly setConversationTitle: (conversationId: string, title: string) => Promise; - /** Get the lifecycle status of a conversation, or null if unknown. */ - readonly getConversationStatus: (conversationId: string) => Promise; - /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ - readonly setConversationStatus: ( - conversationId: string, - status: ConversationStatus, - ) => Promise; - /** - * Replace the entire conversation history with the given messages. Deletes - * all existing chunks, resets the seq counter, and appends the new messages. - * Used by compaction to replace old history with a summary + recent messages. - * Metadata (createdAt, title, status) is preserved. - */ - readonly replaceHistory: ( - conversationId: string, - messages: readonly ChatMessage[], - ) => Promise; - /** - * Fork (copy) the full conversation history from `sourceId` to `targetId`. - * Copies all chunks, metadata, cwd, reasoning-effort, and model. The - * target's status is set to "closed" (it's an archive) and `compactedFrom` - * is set to `sourceId`. Used by compaction to preserve the pre-compaction - * history non-destructively before replacing it with a summary. - */ - readonly forkHistory: (sourceId: string, targetId: string) => Promise; - /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ - readonly getCompactPercent: (conversationId: string) => Promise; - /** Set the compact percent (0-100, 0 = manual only). */ - readonly setCompactPercent: (conversationId: string, percent: number) => Promise; - /** - * Set the `compactedFrom` field on a conversation's metadata, pointing to - * the archive conversation that holds the pre-compaction history. - */ - readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise; - /** - * Returns the workspace, or synthesizes `"default"` if `id === "default"` - * and it was never persisted (title `"default"`, defaultCwd `null`, - * timestamps `0`). Returns `null` for any other non-existent id. - */ - readonly getWorkspace: (id: string) => Promise; - /** - * Create-on-miss: if absent, create with `title = opts.title ?? id`, - * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. - * If present, return as-is (ignore `opts`). The `"default"` workspace is - * always returned as-is (never re-created). This is the `PUT - * /workspaces/:id` handler. - */ - readonly ensureWorkspace: ( - id: string, - opts?: { - readonly title?: string; - readonly defaultCwd?: string | null; - readonly defaultComputerId?: string | null; - }, - ) => Promise; - /** Rename a workspace. Creates the workspace if missing. */ - readonly setWorkspaceTitle: (id: string, title: string) => Promise; - /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ - readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise; - /** - * Set/clear a workspace's default computer (SSH alias). Creates the - * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. - * `null` = local (no SSH). - */ - readonly setWorkspaceDefaultComputerId: ( - id: string, - defaultComputerId: string | null, - ) => Promise; - /** - * Delete a workspace: (1) find all conversations with `workspaceId === id`, - * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, - * (3) delete the workspace entity. Returns `closedCount`. Throws if `id - * === "default"`. - */ - readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; - /** - * All workspaces sorted by `lastActivityAt` descending. Each entry includes - * `conversationCount`. Always includes `"default"` (synthesized if not - * persisted, with the count of legacy/unassigned conversations). - */ - readonly listWorkspaces: () => Promise; - /** - * Returns the conversation's workspaceId, or `"default"` if the - * conversation has no workspaceId persisted (or doesn't exist). - */ - readonly getWorkspaceId: (conversationId: string) => Promise; - /** - * Persist the conversation's workspace assignment. If the conversation - * doesn't exist yet, create a minimal metadata row (like - * `setConversationStatus` does). - */ - readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise; - /** - * Resolve the effective working directory for a conversation: - * - * 1. **Absolute conversation cwd** — an explicit per-conversation cwd - * (`getCwd`, or `overrideCwd` when provided) that starts with `/` - * overrides outright. - * 2. **Relative conversation cwd** — an explicit cwd that does NOT start - * with `/` is resolved against the workspace `defaultCwd` (or - * `serverDefaultCwd` when the workspace has no `defaultCwd`) via - * `path.resolve`. - * 3. **No conversation cwd** — the workspace `defaultCwd` is used. - * 4. **Neither set** — the `serverDefaultCwd` (defaulting to - * `process.cwd()` at construction time) is used. - * - * The workspace is resolved via `getWorkspaceId` (falling back to - * `"default"`) + `getWorkspace`. - * - * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted - * `getCwd` value. When provided (not `undefined`), it is fed through the - * same algorithm above (absolute → returned as-is; relative → resolved - * against the workspace `defaultCwd`). Used by the session-orchestrator - * for a per-turn cwd override (sent by the client on `chat.send`) so a - * transient relative cwd is resolved the same way a persisted one is, - * instead of being resolved against `process.cwd()`. When omitted, the - * persisted `getCwd` is read as today. - */ - readonly getEffectiveCwd: ( - conversationId: string, - overrideCwd?: string, - ) => Promise; - /** - * Resolve the effective computer (SSH alias) for a conversation — the - * computer analog of `getEffectiveCwd`. Resolution ladder: - * - * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) - * wins outright, EVEN when `null` (explicitly local for this turn — it - * does NOT fall through). - * 2. **Persisted per-conversation `computerId`** — `getComputerId`. - * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` - * (falling back to `"default"`) + `getWorkspace`. - * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). - * - * Returns the alias STRING (or `null`); it does NOT validate the alias - * exists in `~/.ssh/config` (validation happens at connect time — a stale - * alias yields a clear connect error rather than silently falling back to - * local). - * - * @param overrideAlias — an explicit alias to resolve INSTEAD of the - * persisted `getComputerId` value. When provided (not `undefined`), it - * is returned as-is (string or `null`), short-circuiting the rest of the - * ladder. Used by the session-orchestrator for a per-turn computer - * override (sent by the client on `chat.send`). When omitted, the - * persisted `getComputerId` is read as today. - */ - readonly getEffectiveComputer: ( - conversationId: string, - overrideAlias?: string | null, - ) => Promise; + readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise; + readonly load: (conversationId: string) => Promise; + /** + * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, + * ascending by seq. The raw append-order log; NOT reconciled (a dangling + * tool-call is returned as-is — repair is a turn-path concern). + * + * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; + * omitted/`0`/non-positive/non-integer = from the start). When + * `window.beforeSeq` is given it is an exclusive upper bound + * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. + * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the + * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` + * chunks is returned whole (exact, not truncated). + * - **Omitted = unchanged** — `window` absent (or both its fields undefined) + * is byte-identical to the pre-windowing behavior, so existing callers that + * pass no third argument are unaffected. + * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or + * `beforeSeq`) is treated as ABSENT (full selection); this method never + * throws on bad window input. The transport validates and 400s upstream. + * + * Seq numbering is 1-based and gap-free, so a client derives "older chunks + * exist" purely from the oldest returned `seq > 1`; there is deliberately no + * `earliestSeq`/high-water-mark API. + */ + readonly loadSince: ( + conversationId: string, + sinceSeq?: number, + window?: { readonly beforeSeq?: number; readonly limit?: number }, + ) => Promise; + readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise; + readonly loadMetrics: (conversationId: string) => Promise; + /** The persisted working directory for a conversation, or null if never set. */ + readonly getCwd: (conversationId: string) => Promise; + /** Persist (upsert) the working directory for a conversation. */ + readonly setCwd: (conversationId: string, cwd: string) => Promise; + /** Clear (delete) the persisted working directory for a conversation. */ + readonly clearCwd: (conversationId: string) => Promise; + /** + * The persisted computer (SSH config `Host` alias) for a conversation, or + * `null` if never set (local). The computer analog of `getCwd`. + */ + readonly getComputerId: (conversationId: string) => Promise; + /** + * Persist (upsert) the computer for a conversation. Passing `null` clears + * the persisted selection (idempotent) — `null` is the "local" sentinel + * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors + * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). + */ + readonly setComputerId: (conversationId: string, alias: string | null) => Promise; + /** Clear (delete) the persisted computer for a conversation. */ + readonly clearComputerId: (conversationId: string) => Promise; + /** The persisted reasoning-effort level for a conversation, or null if never set. */ + readonly getReasoningEffort: (conversationId: string) => Promise; + /** Persist (upsert) the reasoning-effort level for a conversation. */ + readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise; + /** The persisted model name for a conversation, or null if never set. */ + readonly getModel: (conversationId: string) => Promise; + /** + * Persist (upsert) the model name for a conversation (a model name in + * `/` form). Passing an empty string clears the + * persisted selection (idempotent) — this is how transport-http clears via + * `PUT /conversations/:id/model` with a `null` body. + */ + readonly setModel: (conversationId: string, model: string) => Promise; + /** + * List all known conversations, sorted by `lastActivityAt` descending (most + * recent first). Metadata (createdAt, lastActivityAt, title) is tracked + * automatically on append; title defaults to the first user message. + */ + readonly listConversations: (filter?: { + readonly status?: readonly ConversationStatus[]; + readonly workspaceId?: string; + }) => Promise; + /** Single conversation metadata, or null if unknown. */ + readonly getConversationMeta: (conversationId: string) => Promise; + /** Set/update the human-readable title for a conversation. */ + readonly setConversationTitle: (conversationId: string, title: string) => Promise; + /** Get the lifecycle status of a conversation, or null if unknown. */ + readonly getConversationStatus: (conversationId: string) => Promise; + /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ + readonly setConversationStatus: ( + conversationId: string, + status: ConversationStatus, + ) => Promise; + /** + * Replace the entire conversation history with the given messages. Deletes + * all existing chunks, resets the seq counter, and appends the new messages. + * Used by compaction to replace old history with a summary + recent messages. + * Metadata (createdAt, title, status) is preserved. + */ + readonly replaceHistory: ( + conversationId: string, + messages: readonly ChatMessage[], + ) => Promise; + /** + * Fork (copy) the full conversation history from `sourceId` to `targetId`. + * Copies all chunks, metadata, cwd, reasoning-effort, and model. The + * target's status is set to "closed" (it's an archive) and `compactedFrom` + * is set to `sourceId`. Used by compaction to preserve the pre-compaction + * history non-destructively before replacing it with a summary. + */ + readonly forkHistory: (sourceId: string, targetId: string) => Promise; + /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ + readonly getCompactPercent: (conversationId: string) => Promise; + /** Set the compact percent (0-100, 0 = manual only). */ + readonly setCompactPercent: (conversationId: string, percent: number) => Promise; + /** + * Set the `compactedFrom` field on a conversation's metadata, pointing to + * the archive conversation that holds the pre-compaction history. + */ + readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise; + /** + * Returns the workspace, or synthesizes `"default"` if `id === "default"` + * and it was never persisted (title `"default"`, defaultCwd `null`, + * timestamps `0`). Returns `null` for any other non-existent id. + */ + readonly getWorkspace: (id: string) => Promise; + /** + * Create-on-miss: if absent, create with `title = opts.title ?? id`, + * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. + * If present, return as-is (ignore `opts`). The `"default"` workspace is + * always returned as-is (never re-created). This is the `PUT + * /workspaces/:id` handler. + */ + readonly ensureWorkspace: ( + id: string, + opts?: { + readonly title?: string; + readonly defaultCwd?: string | null; + readonly defaultComputerId?: string | null; + }, + ) => Promise; + /** Rename a workspace. Creates the workspace if missing. */ + readonly setWorkspaceTitle: (id: string, title: string) => Promise; + /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ + readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise; + /** + * Set/clear a workspace's default computer (SSH alias). Creates the + * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. + * `null` = local (no SSH). + */ + readonly setWorkspaceDefaultComputerId: ( + id: string, + defaultComputerId: string | null, + ) => Promise; + /** + * Delete a workspace: (1) find all conversations with `workspaceId === id`, + * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, + * (3) delete the workspace entity. Returns `closedCount`. Throws if `id + * === "default"`. + */ + readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; + /** + * All workspaces sorted by `lastActivityAt` descending. Each entry includes + * `conversationCount`. Always includes `"default"` (synthesized if not + * persisted, with the count of legacy/unassigned conversations). + */ + readonly listWorkspaces: () => Promise; + /** + * Returns the conversation's workspaceId, or `"default"` if the + * conversation has no workspaceId persisted (or doesn't exist). + */ + readonly getWorkspaceId: (conversationId: string) => Promise; + /** + * Persist the conversation's workspace assignment. If the conversation + * doesn't exist yet, create a minimal metadata row (like + * `setConversationStatus` does). + */ + readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise; + /** + * Resolve the effective working directory for a conversation: + * + * 1. **Absolute conversation cwd** — an explicit per-conversation cwd + * (`getCwd`, or `overrideCwd` when provided) that starts with `/` + * overrides outright. + * 2. **Relative conversation cwd** — an explicit cwd that does NOT start + * with `/` is resolved against the workspace `defaultCwd` (or + * `serverDefaultCwd` when the workspace has no `defaultCwd`) via + * `path.resolve`. + * 3. **No conversation cwd** — the workspace `defaultCwd` is used. + * 4. **Neither set** — the `serverDefaultCwd` (defaulting to + * `process.cwd()` at construction time) is used. + * + * The workspace is resolved via `getWorkspaceId` (falling back to + * `"default"`) + `getWorkspace`. + * + * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted + * `getCwd` value. When provided (not `undefined`), it is fed through the + * same algorithm above (absolute → returned as-is; relative → resolved + * against the workspace `defaultCwd`). Used by the session-orchestrator + * for a per-turn cwd override (sent by the client on `chat.send`) so a + * transient relative cwd is resolved the same way a persisted one is, + * instead of being resolved against `process.cwd()`. When omitted, the + * persisted `getCwd` is read as today. + */ + readonly getEffectiveCwd: ( + conversationId: string, + overrideCwd?: string, + ) => Promise; + /** + * Resolve the effective computer (SSH alias) for a conversation — the + * computer analog of `getEffectiveCwd`. Resolution ladder: + * + * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) + * wins outright, EVEN when `null` (explicitly local for this turn — it + * does NOT fall through). + * 2. **Persisted per-conversation `computerId`** — `getComputerId`. + * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` + * (falling back to `"default"`) + `getWorkspace`. + * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). + * + * Returns the alias STRING (or `null`); it does NOT validate the alias + * exists in `~/.ssh/config` (validation happens at connect time — a stale + * alias yields a clear connect error rather than silently falling back to + * local). + * + * @param overrideAlias — an explicit alias to resolve INSTEAD of the + * persisted `getComputerId` value. When provided (not `undefined`), it + * is returned as-is (string or `null`), short-circuiting the rest of the + * ladder. Used by the session-orchestrator for a per-turn computer + * override (sent by the client on `chat.send`). When omitted, the + * persisted `getComputerId` is read as today. + */ + readonly getEffectiveComputer: ( + conversationId: string, + overrideAlias?: string | null, + ) => Promise; } export const conversationStoreHandle = defineService("conversation-store/store"); @@ -270,9 +270,9 @@ export const conversationStoreHandle = defineService("convers * non-positive / non-integer / undefined input. Keeps `loadSince` total. */ function positiveInt(value: number | undefined): number | undefined { - if (value === undefined) return undefined; - if (!Number.isInteger(value) || value <= 0) return undefined; - return value; + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value <= 0) return undefined; + return value; } /** @@ -285,16 +285,16 @@ function positiveInt(value: number | undefined): number | undefined { * ever passed (omitted / `0` / non-negative integers). */ function sinceSeqBase(value: number | undefined): number { - if (value === undefined) return 0; - if (!Number.isInteger(value) || value < 0) return 0; - return value; + if (value === undefined) return 0; + if (!Number.isInteger(value) || value < 0) return 0; + return value; } interface PersistedChunkEntry { - readonly chunk: Chunk; - readonly role: Role; - readonly msgIdx: number; - readonly chunkIdx: number; + readonly chunk: Chunk; + readonly role: Role; + readonly msgIdx: number; + readonly chunkIdx: number; } /** @@ -302,16 +302,16 @@ interface PersistedChunkEntry { * Maps to `ConversationMeta` (from `@dispatch/wire`) by adding the `id`. */ interface ConversationMetaRow { - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - readonly compactedFrom?: string; - /** - * The workspace this conversation belongs to. Absent on legacy rows - * (read as `"default"`). Persisted only when explicitly assigned. - */ - readonly workspaceId?: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + readonly compactedFrom?: string; + /** + * The workspace this conversation belongs to. Absent on legacy rows + * (read as `"default"`). Persisted only when explicitly assigned. + */ + readonly workspaceId?: string; } /** @@ -319,16 +319,16 @@ interface ConversationMetaRow { * is the key, so it is not duplicated in the row. */ interface WorkspaceRow { - readonly title: string; - readonly defaultCwd: string | null; - /** - * The workspace's default computer (SSH config `Host` alias) — the computer - * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this - * workspace inherit it when they set no `computerId` of their own. - */ - readonly defaultComputerId: string | null; - readonly createdAt: number; - readonly lastActivityAt: number; + readonly title: string; + readonly defaultCwd: string | null; + /** + * The workspace's default computer (SSH config `Host` alias) — the computer + * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this + * workspace inherit it when they set no `computerId` of their own. + */ + readonly defaultComputerId: string | null; + readonly createdAt: number; + readonly lastActivityAt: number; } /** Maximum title length (in characters) before truncation with an ellipsis. */ @@ -344,15 +344,15 @@ const TITLE_MAX = 80; * persisting. */ export function extractTitle(messages: readonly ChatMessage[]): string { - for (const msg of messages) { - if (msg.role !== "user") continue; - for (const chunk of msg.chunks) { - if (chunk.type === "text") { - return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; - } - } - } - return "Untitled"; + for (const msg of messages) { + if (msg.role !== "user") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "text") { + return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; + } + } + } + return "Untitled"; } /** @@ -360,44 +360,44 @@ export function extractTitle(messages: readonly ChatMessage[]): string { * parse / shape failure so callers can treat a corrupt row as missing. */ function parseMetaRow(raw: string): ConversationMetaRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as ConversationMetaRow).createdAt !== "number" || - typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || - typeof (parsed as ConversationMetaRow).title !== "string" - ) { - return null; - } - const row = parsed as ConversationMetaRow; - const status: ConversationStatus = - row.status === "active" || row.status === "closed" ? row.status : "idle"; - return { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status, - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as ConversationMetaRow).createdAt !== "number" || + typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || + typeof (parsed as ConversationMetaRow).title !== "string" + ) { + return null; + } + const row = parsed as ConversationMetaRow; + const status: ConversationStatus = + row.status === "active" || row.status === "closed" ? row.status : "idle"; + return { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status, + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), + }; } function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { - return { - id, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: row.status, - workspaceId: row.workspaceId ?? "default", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - }; + return { + id, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: row.status, + workspaceId: row.workspaceId ?? "default", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + }; } /** @@ -406,7 +406,7 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { * to validate before hitting the store. Pure (input → boolean). */ export function isValidWorkspaceSlug(id: string): boolean { - return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); + return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); } /** The always-present, non-deletable default workspace id. */ @@ -417,897 +417,897 @@ const DEFAULT_WORKSPACE_ID = "default"; * shape failure so callers can treat a corrupt row as missing. */ function parseWorkspaceRow(raw: string): WorkspaceRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as WorkspaceRow).title !== "string" || - typeof (parsed as WorkspaceRow).createdAt !== "number" || - typeof (parsed as WorkspaceRow).lastActivityAt !== "number" - ) { - return null; - } - const row = parsed as WorkspaceRow; - // `defaultCwd` may be null OR a string; treat anything else as null. - const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; - // `defaultComputerId` may be null OR a string; treat anything else as null - // (mirrors `defaultCwd`). Absent on legacy rows → null (local). - const defaultComputerId = - typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; - return { - title: row.title, - defaultCwd, - defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as WorkspaceRow).title !== "string" || + typeof (parsed as WorkspaceRow).createdAt !== "number" || + typeof (parsed as WorkspaceRow).lastActivityAt !== "number" + ) { + return null; + } + const row = parsed as WorkspaceRow; + // `defaultCwd` may be null OR a string; treat anything else as null. + const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; + // `defaultComputerId` may be null OR a string; treat anything else as null + // (mirrors `defaultCwd`). Absent on legacy rows → null (local). + const defaultComputerId = + typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; + return { + title: row.title, + defaultCwd, + defaultComputerId, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } function toWorkspace(id: string, row: WorkspaceRow): Workspace { - return { - id, - title: row.title, - defaultCwd: row.defaultCwd, - defaultComputerId: row.defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + return { + id, + title: row.title, + defaultCwd: row.defaultCwd, + defaultComputerId: row.defaultComputerId, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } export function createConversationStore( - storage: StorageNamespace, - logger?: Logger, - now: () => number = Date.now, - serverDefaultCwd: string = process.cwd(), + storage: StorageNamespace, + logger?: Logger, + now: () => number = Date.now, + serverDefaultCwd: string = process.cwd(), ): ConversationStore { - /** - * Add `conversationId` to the persisted index (idempotent). The store is - * not highly concurrent — the session-orchestrator serializes turns per - * conversation — so a simple read-modify-write suffices; `listConversations` - * deduplicates on read in case of a race on this update. - */ - async function ensureInIndex(conversationId: string): Promise { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - let ids: string[]; - if (raw === null) { - ids = []; - } else { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - parsed = []; - } - ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; - } - if (ids.includes(conversationId)) return; - ids.push(conversationId); - await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); - } - - /** - * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. - */ - async function readWorkspaceRow(id: string): Promise { - const raw = await storage.get(workspaceKey(id)); - if (raw === null) return null; - return parseWorkspaceRow(raw); - } - - /** - * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on - * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt - * = ts`) so that the first activity in any workspace — including the - * synthesized `"default"` — is recorded. Does NOT touch `title` or - * `defaultCwd` on an existing row. - */ - async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise { - const existing = await readWorkspaceRow(workspaceId); - const row: WorkspaceRow = - existing === null - ? { - title: workspaceId, - defaultCwd: null, - defaultComputerId: null, - createdAt: ts, - lastActivityAt: ts, - } - : { - title: existing.title, - defaultCwd: existing.defaultCwd, - defaultComputerId: existing.defaultComputerId, - createdAt: existing.createdAt, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); - } - - return { - async append(conversationId, messages) { - const raw = await storage.get(seqKey(conversationId)); - let seq = parseSeq(raw) + 1; - - for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { - const msg = messages[msgIdx]; - if (msg === undefined) continue; - for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { - const chunk = msg.chunks[chunkIdx]; - if (chunk === undefined) continue; - const entry: PersistedChunkEntry = { - chunk, - role: msg.role, - msgIdx, - chunkIdx, - }; - await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); - seq++; - } - } - - await storage.set(seqKey(conversationId), String(seq - 1)); - - // Metadata upsert: track createdAt/lastActivityAt/title and keep the - // conversation discoverable in the index. - const ts = now(); - let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; - const metaRaw = await storage.get(metaKey(conversationId)); - if (metaRaw === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - const existing = parseMetaRow(metaRaw); - if (existing === null) { - // Corrupt row — rewrite from scratch using this append. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; - const title = - existing.title === "Untitled" || existing.title === "" - ? extractTitle(messages) - : existing.title; - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: ts, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - } - } - // Bump the owning workspace's lastActivityAt to this append's time. - await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); - }, - - async load(conversationId) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const messages: ChatMessage[] = []; - let currentChunks: Chunk[] = []; - let currentRole: Role | undefined; - let currentMsgIdx = -1; - - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - let entry: PersistedChunkEntry; - try { - entry = JSON.parse(value) as PersistedChunkEntry; - } catch (err) { - // "Never leave the system broken": a single corrupt/unparseable - // row must not brick the whole conversation. Skip it (append-only - // storage untouched) and let reconcile run on the rest. loadSince - // is intentionally NOT hardened here — it is the raw FE read path. - if (logger !== undefined) { - logger.warn("skipping corrupt chunk row", { - conversationId, - key, - error: err instanceof Error ? err.message : String(err), - }); - } - continue; - } - - if (entry.msgIdx !== currentMsgIdx) { - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - currentChunks = []; - currentRole = entry.role; - currentMsgIdx = entry.msgIdx; - } - - currentChunks.push(entry.chunk); - } - - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - - const { messages: repaired, report } = reconcileWithReport(messages); - - const hasReconcileActivity = - report.repairedCount > 0 || - report.strippedErrorChunks > 0 || - report.droppedEmptyMessages > 0; - if (hasReconcileActivity && logger !== undefined) { - const child = logger.child({ conversationId }); - const span = child.span("reconcile.repair", { - repairedCount: report.repairedCount, - firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, - strippedErrorChunks: report.strippedErrorChunks, - droppedEmptyMessages: report.droppedEmptyMessages, - }); - span.end(); - } - - return repaired; - }, - - async loadSince(conversationId, sinceSeq, window) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: StoredChunk[] = []; - const minSeq = sinceSeqBase(sinceSeq); - // Forgiving: a non-positive / non-integer bound is treated as ABSENT. - const beforeSeq = positiveInt(window?.beforeSeq); - const limit = positiveInt(window?.limit); - - for (const key of sorted) { - const seq = parseSeq(key.split(":").pop() ?? null); - if (seq <= minSeq) continue; - if (beforeSeq !== undefined && seq >= beforeSeq) continue; - const value = await storage.get(key); - if (value === null) continue; - const entry = JSON.parse(value) as PersistedChunkEntry; - result.push({ seq, role: entry.role, chunk: entry.chunk }); - } - - // Window: keep only the NEWEST `limit` chunks, still ascending by seq. - if (limit !== undefined && result.length > limit) { - return result.slice(result.length - limit); - } - - return result; - }, - - async appendMetrics(conversationId, metrics) { - const raw = await storage.get(metricsSeqKey(conversationId)); - const ordinal = parseSeq(raw) + 1; - await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); - await storage.set(metricsSeqKey(conversationId), String(ordinal)); - }, - - async loadMetrics(conversationId) { - const prefix = metricsPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: TurnMetrics[] = []; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - result.push(JSON.parse(value) as TurnMetrics); - } - - return result; - }, - - async getCwd(conversationId) { - return await storage.get(cwdKey(conversationId)); - }, - - async setCwd(conversationId, cwd) { - await storage.set(cwdKey(conversationId), cwd); - if (logger !== undefined) { - logger.debug("cwd set", { conversationId }); - } - }, - - async clearCwd(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(cwdKey(conversationId)); - if (logger !== undefined) { - logger.debug("cwd cleared", { conversationId }); - } - }, - - async getComputerId(conversationId) { - return await storage.get(computerKey(conversationId)); - }, - - async setComputerId(conversationId, alias) { - // `null` is the "local" sentinel: clear the persisted key so it does - // NOT linger to shadow the workspace defaultComputerId. Idempotent - // (deleting an already-absent key is a no-op). Mirrors `setModel`'s - // clear-on-sentinel pattern. - if (alias === null) { - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - return; - } - await storage.set(computerKey(conversationId), alias); - if (logger !== undefined) { - logger.debug("computer set", { conversationId }); - } - }, - - async clearComputerId(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - }, - - async getReasoningEffort(conversationId) { - return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; - }, - - async setReasoningEffort(conversationId, effort) { - await storage.set(reasoningEffortKey(conversationId), effort); - if (logger !== undefined) { - logger.debug("reasoning-effort set", { conversationId }); - } - }, - - async getModel(conversationId) { - return await storage.get(modelKey(conversationId)); - }, - - async setModel(conversationId, model) { - if (model === "") { - // Idempotent clear: an empty model clears the persisted - // selection. Deleting an already-absent key is a no-op. - await storage.delete(modelKey(conversationId)); - if (logger !== undefined) { - logger.debug("model cleared", { conversationId }); - } - return; - } - await storage.set(modelKey(conversationId), model); - if (logger !== undefined) { - logger.debug("model set", { conversationId }); - } - }, - async listConversations(filter) { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - if (raw === null) return []; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!Array.isArray(parsed)) return []; - // Deduplicate (in case of a race on the index update) while preserving - // first-seen order. - const seen = new Set(); - const ids: string[] = []; - for (const v of parsed) { - if (typeof v !== "string" || seen.has(v)) continue; - seen.add(v); - ids.push(v); - } - - const statusFilter = filter?.status; - const workspaceFilter = filter?.workspaceId; - const metas: ConversationMeta[] = []; - for (const id of ids) { - const metaRaw = await storage.get(metaKey(id)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; - if (workspaceFilter !== undefined) { - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== workspaceFilter) continue; - } - metas.push(toMeta(id, row)); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps first-seen (index) order for ties. - return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getConversationMeta(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return toMeta(conversationId, row); - }, - - async setConversationTitle(conversationId, title) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Title set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - // Corrupt row — rewrite from scratch with this title. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - // Preserve createdAt + lastActivityAt + status; update only the title. - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getConversationStatus(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return row.status; - }, - - async setConversationStatus(conversationId, status) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Status set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async replaceHistory(conversationId, messages) { - // Delete all existing chunks. - const keys = await storage.keys(chunkPrefix(conversationId)); - for (const k of keys) { - await storage.delete(k); - } - // Reset the seq counter so the new messages start from seq 1. - await storage.set(seqKey(conversationId), "0"); - // Append the new messages (re-uses the append logic for seq - // numbering + metadata upsert). - await this.append(conversationId, messages); - }, - - async forkHistory(sourceId, targetId) { - // Copy all chunks from source to target, re-numbered from seq 1. - const keys = await storage.keys(chunkPrefix(sourceId)); - const sorted = [...keys].sort(); - let seq = 1; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - await storage.set(chunkKey(targetId, seq), value); - seq++; - } - await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); - - // Copy metadata with archive title + closed status. - // Inherit compactedFrom from the source so archives chain: - // A → Y → X (each archive points to the previous one). - const metaRaw = await storage.get(metaKey(sourceId)); - if (metaRaw !== null) { - const existing = parseMetaRow(metaRaw); - if (existing !== null) { - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: `Archive: ${existing.title}`, - status: "closed", - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(targetId), JSON.stringify(row)); - } - } - await ensureInIndex(targetId); - - // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). - const cwd = await storage.get(cwdKey(sourceId)); - if (cwd !== null) await storage.set(cwdKey(targetId), cwd); - const effort = await storage.get(reasoningEffortKey(sourceId)); - if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); - const model = await storage.get(modelKey(sourceId)); - if (model !== null) await storage.set(modelKey(targetId), model); - const computerId = await storage.get(computerKey(sourceId)); - if (computerId !== null) await storage.set(computerKey(targetId), computerId); - }, - - async getCompactPercent(conversationId) { - const raw = await storage.get(compactThresholdKey(conversationId)); - if (raw === null) return null; - const n = Number.parseInt(raw, 10); - return Number.isNaN(n) ? null : n; - }, - - async setCompactPercent(conversationId, percent) { - await storage.set(compactThresholdKey(conversationId), String(percent)); - if (logger !== undefined) { - logger.debug("compact-percent set", { conversationId, percent }); - } - }, - - async setCompactedFrom(conversationId, newConversationId) { - const raw = await storage.get(metaKey(conversationId)); - const existing = raw !== null ? parseMetaRow(raw) : null; - const ts = now(); - const row: ConversationMetaRow = existing ?? { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - }; - await storage.set( - metaKey(conversationId), - JSON.stringify({ ...row, compactedFrom: newConversationId }), - ); - }, - - async getWorkspace(id) { - const row = await readWorkspaceRow(id); - if (row !== null) return toWorkspace(id, row); - // Synthesize the always-present "default" workspace when it was - // never persisted (title "default", defaultCwd null, defaultComputerId - // null [local], timestamps 0). - if (id === DEFAULT_WORKSPACE_ID) { - return { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - } - return null; - }, - - async ensureWorkspace(id, opts) { - const existing = await readWorkspaceRow(id); - if (existing !== null) return toWorkspace(id, existing); - // Absent — create with defaults. The synthesized "default" is also - // materialized here when first explicitly ensured. - const ts = now(); - const row: WorkspaceRow = { - title: opts?.title ?? id, - defaultCwd: opts?.defaultCwd ?? null, - defaultComputerId: opts?.defaultComputerId ?? null, - createdAt: ts, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceTitle(id, title) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title, - defaultCwd: base.defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultCwd(id, defaultCwd) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd: base.defaultCwd, - defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async deleteWorkspace(id) { - if (id === DEFAULT_WORKSPACE_ID) { - throw new Error('The "default" workspace cannot be deleted.'); - } - // (1) Find all conversations with workspaceId === id, (2) set each - // to status "closed" and reassign workspaceId to "default". - let closedCount = 0; - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== id) continue; - const updated: ConversationMetaRow = { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: "closed", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - workspaceId: DEFAULT_WORKSPACE_ID, - }; - await storage.set(metaKey(convId), JSON.stringify(updated)); - closedCount++; - } - } - // (3) Delete the workspace entity. - await storage.delete(workspaceKey(id)); - return { closedCount }; - }, - - async listWorkspaces() { - // Collect persisted workspace rows via the `workspace:` key prefix. - const wsPrefix = "workspace:"; - const wsKeys = await storage.keys(wsPrefix); - const byId = new Map(); - for (const key of wsKeys) { - // Key shape: `workspace:`. Strip the prefix to recover the id. - const id = key.slice(wsPrefix.length); - if (id.length === 0) continue; - const raw = await storage.get(key); - if (raw === null) continue; - const row = parseWorkspaceRow(raw); - if (row === null) continue; - byId.set(id, toWorkspace(id, row)); - } - // Always include "default" (synthesized if not persisted). - if (!byId.has(DEFAULT_WORKSPACE_ID)) { - byId.set(DEFAULT_WORKSPACE_ID, { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - } - // Count conversations per workspace by scanning the index + meta. - const counts = new Map(); - for (const id of byId.keys()) counts.set(id, 0); - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - counts.set(wsId, (counts.get(wsId) ?? 0) + 1); - } - } - const entries: WorkspaceEntry[] = []; - for (const [id, ws] of byId) { - entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps insertion order for ties. - return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getWorkspaceId(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return DEFAULT_WORKSPACE_ID; - const row = parseMetaRow(raw); - if (row === null) return DEFAULT_WORKSPACE_ID; - return row.workspaceId ?? DEFAULT_WORKSPACE_ID; - }, - - async setWorkspaceId(conversationId, workspaceId) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Conversation doesn't exist yet — create a minimal metadata row - // (like setConversationStatus does), with the workspace assigned. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getEffectiveCwd(conversationId, overrideCwd) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceCwd = workspace?.defaultCwd ?? null; - // When an explicit override is given, resolve IT instead of the - // persisted cwd — it is always a string, never null. - const conversationCwd = - overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); - - if (conversationCwd === null) { - return workspaceCwd ?? serverDefaultCwd; - } - if (conversationCwd.startsWith("/")) { - return conversationCwd; - } - return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); - }, - - async getEffectiveComputer(conversationId, overrideAlias) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceComputerId = workspace?.defaultComputerId ?? null; - // When an explicit override is given, it wins outright — even `null` - // (explicitly local for this turn) does NOT fall through to the - // persisted / workspace values. - if (overrideAlias !== undefined) { - return overrideAlias; - } - // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). - const computerId = await this.getComputerId(conversationId); - return computerId ?? workspaceComputerId; - }, - }; + /** + * Add `conversationId` to the persisted index (idempotent). The store is + * not highly concurrent — the session-orchestrator serializes turns per + * conversation — so a simple read-modify-write suffices; `listConversations` + * deduplicates on read in case of a race on this update. + */ + async function ensureInIndex(conversationId: string): Promise { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + let ids: string[]; + if (raw === null) { + ids = []; + } else { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = []; + } + ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; + } + if (ids.includes(conversationId)) return; + ids.push(conversationId); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); + } + + /** + * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. + */ + async function readWorkspaceRow(id: string): Promise { + const raw = await storage.get(workspaceKey(id)); + if (raw === null) return null; + return parseWorkspaceRow(raw); + } + + /** + * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on + * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt + * = ts`) so that the first activity in any workspace — including the + * synthesized `"default"` — is recorded. Does NOT touch `title` or + * `defaultCwd` on an existing row. + */ + async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise { + const existing = await readWorkspaceRow(workspaceId); + const row: WorkspaceRow = + existing === null + ? { + title: workspaceId, + defaultCwd: null, + defaultComputerId: null, + createdAt: ts, + lastActivityAt: ts, + } + : { + title: existing.title, + defaultCwd: existing.defaultCwd, + defaultComputerId: existing.defaultComputerId, + createdAt: existing.createdAt, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); + } + + return { + async append(conversationId, messages) { + const raw = await storage.get(seqKey(conversationId)); + let seq = parseSeq(raw) + 1; + + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const msg = messages[msgIdx]; + if (msg === undefined) continue; + for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { + const chunk = msg.chunks[chunkIdx]; + if (chunk === undefined) continue; + const entry: PersistedChunkEntry = { + chunk, + role: msg.role, + msgIdx, + chunkIdx, + }; + await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); + seq++; + } + } + + await storage.set(seqKey(conversationId), String(seq - 1)); + + // Metadata upsert: track createdAt/lastActivityAt/title and keep the + // conversation discoverable in the index. + const ts = now(); + let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; + const metaRaw = await storage.get(metaKey(conversationId)); + if (metaRaw === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + const existing = parseMetaRow(metaRaw); + if (existing === null) { + // Corrupt row — rewrite from scratch using this append. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; + const title = + existing.title === "Untitled" || existing.title === "" + ? extractTitle(messages) + : existing.title; + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: ts, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + } + } + // Bump the owning workspace's lastActivityAt to this append's time. + await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); + }, + + async load(conversationId) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const messages: ChatMessage[] = []; + let currentChunks: Chunk[] = []; + let currentRole: Role | undefined; + let currentMsgIdx = -1; + + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + let entry: PersistedChunkEntry; + try { + entry = JSON.parse(value) as PersistedChunkEntry; + } catch (err) { + // "Never leave the system broken": a single corrupt/unparseable + // row must not brick the whole conversation. Skip it (append-only + // storage untouched) and let reconcile run on the rest. loadSince + // is intentionally NOT hardened here — it is the raw FE read path. + if (logger !== undefined) { + logger.warn("skipping corrupt chunk row", { + conversationId, + key, + error: err instanceof Error ? err.message : String(err), + }); + } + continue; + } + + if (entry.msgIdx !== currentMsgIdx) { + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + currentChunks = []; + currentRole = entry.role; + currentMsgIdx = entry.msgIdx; + } + + currentChunks.push(entry.chunk); + } + + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + + const { messages: repaired, report } = reconcileWithReport(messages); + + const hasReconcileActivity = + report.repairedCount > 0 || + report.strippedErrorChunks > 0 || + report.droppedEmptyMessages > 0; + if (hasReconcileActivity && logger !== undefined) { + const child = logger.child({ conversationId }); + const span = child.span("reconcile.repair", { + repairedCount: report.repairedCount, + firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, + strippedErrorChunks: report.strippedErrorChunks, + droppedEmptyMessages: report.droppedEmptyMessages, + }); + span.end(); + } + + return repaired; + }, + + async loadSince(conversationId, sinceSeq, window) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: StoredChunk[] = []; + const minSeq = sinceSeqBase(sinceSeq); + // Forgiving: a non-positive / non-integer bound is treated as ABSENT. + const beforeSeq = positiveInt(window?.beforeSeq); + const limit = positiveInt(window?.limit); + + for (const key of sorted) { + const seq = parseSeq(key.split(":").pop() ?? null); + if (seq <= minSeq) continue; + if (beforeSeq !== undefined && seq >= beforeSeq) continue; + const value = await storage.get(key); + if (value === null) continue; + const entry = JSON.parse(value) as PersistedChunkEntry; + result.push({ seq, role: entry.role, chunk: entry.chunk }); + } + + // Window: keep only the NEWEST `limit` chunks, still ascending by seq. + if (limit !== undefined && result.length > limit) { + return result.slice(result.length - limit); + } + + return result; + }, + + async appendMetrics(conversationId, metrics) { + const raw = await storage.get(metricsSeqKey(conversationId)); + const ordinal = parseSeq(raw) + 1; + await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); + await storage.set(metricsSeqKey(conversationId), String(ordinal)); + }, + + async loadMetrics(conversationId) { + const prefix = metricsPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: TurnMetrics[] = []; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + result.push(JSON.parse(value) as TurnMetrics); + } + + return result; + }, + + async getCwd(conversationId) { + return await storage.get(cwdKey(conversationId)); + }, + + async setCwd(conversationId, cwd) { + await storage.set(cwdKey(conversationId), cwd); + if (logger !== undefined) { + logger.debug("cwd set", { conversationId }); + } + }, + + async clearCwd(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(cwdKey(conversationId)); + if (logger !== undefined) { + logger.debug("cwd cleared", { conversationId }); + } + }, + + async getComputerId(conversationId) { + return await storage.get(computerKey(conversationId)); + }, + + async setComputerId(conversationId, alias) { + // `null` is the "local" sentinel: clear the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. Idempotent + // (deleting an already-absent key is a no-op). Mirrors `setModel`'s + // clear-on-sentinel pattern. + if (alias === null) { + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + return; + } + await storage.set(computerKey(conversationId), alias); + if (logger !== undefined) { + logger.debug("computer set", { conversationId }); + } + }, + + async clearComputerId(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + }, + + async getReasoningEffort(conversationId) { + return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; + }, + + async setReasoningEffort(conversationId, effort) { + await storage.set(reasoningEffortKey(conversationId), effort); + if (logger !== undefined) { + logger.debug("reasoning-effort set", { conversationId }); + } + }, + + async getModel(conversationId) { + return await storage.get(modelKey(conversationId)); + }, + + async setModel(conversationId, model) { + if (model === "") { + // Idempotent clear: an empty model clears the persisted + // selection. Deleting an already-absent key is a no-op. + await storage.delete(modelKey(conversationId)); + if (logger !== undefined) { + logger.debug("model cleared", { conversationId }); + } + return; + } + await storage.set(modelKey(conversationId), model); + if (logger !== undefined) { + logger.debug("model set", { conversationId }); + } + }, + async listConversations(filter) { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + if (raw === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + // Deduplicate (in case of a race on the index update) while preserving + // first-seen order. + const seen = new Set(); + const ids: string[] = []; + for (const v of parsed) { + if (typeof v !== "string" || seen.has(v)) continue; + seen.add(v); + ids.push(v); + } + + const statusFilter = filter?.status; + const workspaceFilter = filter?.workspaceId; + const metas: ConversationMeta[] = []; + for (const id of ids) { + const metaRaw = await storage.get(metaKey(id)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; + if (workspaceFilter !== undefined) { + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== workspaceFilter) continue; + } + metas.push(toMeta(id, row)); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps first-seen (index) order for ties. + return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getConversationMeta(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return toMeta(conversationId, row); + }, + + async setConversationTitle(conversationId, title) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Title set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + // Corrupt row — rewrite from scratch with this title. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + // Preserve createdAt + lastActivityAt + status; update only the title. + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getConversationStatus(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return row.status; + }, + + async setConversationStatus(conversationId, status) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Status set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async replaceHistory(conversationId, messages) { + // Delete all existing chunks. + const keys = await storage.keys(chunkPrefix(conversationId)); + for (const k of keys) { + await storage.delete(k); + } + // Reset the seq counter so the new messages start from seq 1. + await storage.set(seqKey(conversationId), "0"); + // Append the new messages (re-uses the append logic for seq + // numbering + metadata upsert). + await this.append(conversationId, messages); + }, + + async forkHistory(sourceId, targetId) { + // Copy all chunks from source to target, re-numbered from seq 1. + const keys = await storage.keys(chunkPrefix(sourceId)); + const sorted = [...keys].sort(); + let seq = 1; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + await storage.set(chunkKey(targetId, seq), value); + seq++; + } + await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); + + // Copy metadata with archive title + closed status. + // Inherit compactedFrom from the source so archives chain: + // A → Y → X (each archive points to the previous one). + const metaRaw = await storage.get(metaKey(sourceId)); + if (metaRaw !== null) { + const existing = parseMetaRow(metaRaw); + if (existing !== null) { + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: `Archive: ${existing.title}`, + status: "closed", + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(targetId), JSON.stringify(row)); + } + } + await ensureInIndex(targetId); + + // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). + const cwd = await storage.get(cwdKey(sourceId)); + if (cwd !== null) await storage.set(cwdKey(targetId), cwd); + const effort = await storage.get(reasoningEffortKey(sourceId)); + if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); + const model = await storage.get(modelKey(sourceId)); + if (model !== null) await storage.set(modelKey(targetId), model); + const computerId = await storage.get(computerKey(sourceId)); + if (computerId !== null) await storage.set(computerKey(targetId), computerId); + }, + + async getCompactPercent(conversationId) { + const raw = await storage.get(compactThresholdKey(conversationId)); + if (raw === null) return null; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }, + + async setCompactPercent(conversationId, percent) { + await storage.set(compactThresholdKey(conversationId), String(percent)); + if (logger !== undefined) { + logger.debug("compact-percent set", { conversationId, percent }); + } + }, + + async setCompactedFrom(conversationId, newConversationId) { + const raw = await storage.get(metaKey(conversationId)); + const existing = raw !== null ? parseMetaRow(raw) : null; + const ts = now(); + const row: ConversationMetaRow = existing ?? { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + }; + await storage.set( + metaKey(conversationId), + JSON.stringify({ ...row, compactedFrom: newConversationId }), + ); + }, + + async getWorkspace(id) { + const row = await readWorkspaceRow(id); + if (row !== null) return toWorkspace(id, row); + // Synthesize the always-present "default" workspace when it was + // never persisted (title "default", defaultCwd null, defaultComputerId + // null [local], timestamps 0). + if (id === DEFAULT_WORKSPACE_ID) { + return { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + } + return null; + }, + + async ensureWorkspace(id, opts) { + const existing = await readWorkspaceRow(id); + if (existing !== null) return toWorkspace(id, existing); + // Absent — create with defaults. The synthesized "default" is also + // materialized here when first explicitly ensured. + const ts = now(); + const row: WorkspaceRow = { + title: opts?.title ?? id, + defaultCwd: opts?.defaultCwd ?? null, + defaultComputerId: opts?.defaultComputerId ?? null, + createdAt: ts, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceTitle(id, title) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title, + defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultCwd(id, defaultCwd) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd, + defaultComputerId: base.defaultComputerId, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async deleteWorkspace(id) { + if (id === DEFAULT_WORKSPACE_ID) { + throw new Error('The "default" workspace cannot be deleted.'); + } + // (1) Find all conversations with workspaceId === id, (2) set each + // to status "closed" and reassign workspaceId to "default". + let closedCount = 0; + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== id) continue; + const updated: ConversationMetaRow = { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: "closed", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + workspaceId: DEFAULT_WORKSPACE_ID, + }; + await storage.set(metaKey(convId), JSON.stringify(updated)); + closedCount++; + } + } + // (3) Delete the workspace entity. + await storage.delete(workspaceKey(id)); + return { closedCount }; + }, + + async listWorkspaces() { + // Collect persisted workspace rows via the `workspace:` key prefix. + const wsPrefix = "workspace:"; + const wsKeys = await storage.keys(wsPrefix); + const byId = new Map(); + for (const key of wsKeys) { + // Key shape: `workspace:`. Strip the prefix to recover the id. + const id = key.slice(wsPrefix.length); + if (id.length === 0) continue; + const raw = await storage.get(key); + if (raw === null) continue; + const row = parseWorkspaceRow(raw); + if (row === null) continue; + byId.set(id, toWorkspace(id, row)); + } + // Always include "default" (synthesized if not persisted). + if (!byId.has(DEFAULT_WORKSPACE_ID)) { + byId.set(DEFAULT_WORKSPACE_ID, { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }); + } + // Count conversations per workspace by scanning the index + meta. + const counts = new Map(); + for (const id of byId.keys()) counts.set(id, 0); + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + counts.set(wsId, (counts.get(wsId) ?? 0) + 1); + } + } + const entries: WorkspaceEntry[] = []; + for (const [id, ws] of byId) { + entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps insertion order for ties. + return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getWorkspaceId(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return DEFAULT_WORKSPACE_ID; + const row = parseMetaRow(raw); + if (row === null) return DEFAULT_WORKSPACE_ID; + return row.workspaceId ?? DEFAULT_WORKSPACE_ID; + }, + + async setWorkspaceId(conversationId, workspaceId) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Conversation doesn't exist yet — create a minimal metadata row + // (like setConversationStatus does), with the workspace assigned. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getEffectiveCwd(conversationId, overrideCwd) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceCwd = workspace?.defaultCwd ?? null; + // When an explicit override is given, resolve IT instead of the + // persisted cwd — it is always a string, never null. + const conversationCwd = + overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); + + if (conversationCwd === null) { + return workspaceCwd ?? serverDefaultCwd; + } + if (conversationCwd.startsWith("/")) { + return conversationCwd; + } + return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); + }, + + async getEffectiveComputer(conversationId, overrideAlias) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceComputerId = workspace?.defaultComputerId ?? null; + // When an explicit override is given, it wins outright — even `null` + // (explicitly local for this turn) does NOT fall through to the + // persisted / workspace values. + if (overrideAlias !== undefined) { + return overrideAlias; + } + // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). + const computerId = await this.getComputerId(conversationId); + return computerId ?? workspaceComputerId; + }, + }; } diff --git a/packages/conversation-store/tsconfig.json b/packages/conversation-store/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/conversation-store/tsconfig.json +++ b/packages/conversation-store/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/credential-store/package.json b/packages/credential-store/package.json index 19f6b07..c0632b9 100644 --- a/packages/credential-store/package.json +++ b/packages/credential-store/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/credential-store", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/credential-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/credential-store/src/extension.ts b/packages/credential-store/src/extension.ts index 8f2a826..9cfae14 100644 --- a/packages/credential-store/src/extension.ts +++ b/packages/credential-store/src/extension.ts @@ -4,26 +4,26 @@ import { createCredentialStore } from "./registry.js"; import { credentialStoreHandle } from "./service.js"; export const manifest: Manifest = { - id: "credential-store", - name: "Credential Store", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - contributes: { services: ["credential-store/registry"] }, + id: "credential-store", + name: "Credential Store", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["credential-store/registry"] }, }; export function createCredentialStoreExtension(deps: { - credentials: readonly Credential[]; + credentials: readonly Credential[]; }): Extension { - return { - manifest, - activate(host) { - const store = createCredentialStore({ - credentials: deps.credentials, - getProvider: (id) => host.getProviders().get(id), - }); - host.provideService(credentialStoreHandle, store); - }, - }; + return { + manifest, + activate(host) { + const store = createCredentialStore({ + credentials: deps.credentials, + getProvider: (id) => host.getProviders().get(id), + }); + host.provideService(credentialStoreHandle, store); + }, + }; } diff --git a/packages/credential-store/src/index.ts b/packages/credential-store/src/index.ts index 749abed..010cf1c 100644 --- a/packages/credential-store/src/index.ts +++ b/packages/credential-store/src/index.ts @@ -1,9 +1,9 @@ export { createCredentialStoreExtension, manifest } from "./extension.js"; export type { - Credential, - CredentialStore, - CredentialStoreDeps, - ResolvedModel, + Credential, + CredentialStore, + CredentialStoreDeps, + ResolvedModel, } from "./registry.js"; export { createCredentialStore } from "./registry.js"; export { credentialStoreHandle } from "./service.js"; diff --git a/packages/credential-store/src/registry.test.ts b/packages/credential-store/src/registry.test.ts index e67b5d4..8edae0c 100644 --- a/packages/credential-store/src/registry.test.ts +++ b/packages/credential-store/src/registry.test.ts @@ -3,79 +3,79 @@ import { describe, expect, it } from "vitest"; import { createCredentialStore } from "./registry.js"; function fakeProvider( - id: string, - listModels?: () => Promise, + id: string, + listModels?: () => Promise, ): ProviderContract { - return { - id, - stream: async function* () {}, - ...(listModels ? { listModels } : {}), - }; + return { + id, + stream: async function* () {}, + ...(listModels ? { listModels } : {}), + }; } describe("createCredentialStore", () => { - const credentials = [{ name: "opencode", providerId: "openai-compat" }]; - const providers = new Map(); + const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + const providers = new Map(); - const store = createCredentialStore({ - credentials, - getProvider: (id) => providers.get(id), - }); + const store = createCredentialStore({ + credentials, + getProvider: (id) => providers.get(id), + }); - describe("resolve", () => { - it("resolves a simple model name", () => { - providers.set("openai-compat", fakeProvider("openai-compat")); - const result = store.resolve("opencode/deepseek-v4-flash"); - expect(result).toEqual({ providerId: "openai-compat", model: "deepseek-v4-flash" }); - }); + describe("resolve", () => { + it("resolves a simple model name", () => { + providers.set("openai-compat", fakeProvider("openai-compat")); + const result = store.resolve("opencode/deepseek-v4-flash"); + expect(result).toEqual({ providerId: "openai-compat", model: "deepseek-v4-flash" }); + }); - it("resolves a model name with slashes", () => { - const result = store.resolve("opencode/a/b"); - expect(result).toEqual({ providerId: "openai-compat", model: "a/b" }); - }); + it("resolves a model name with slashes", () => { + const result = store.resolve("opencode/a/b"); + expect(result).toEqual({ providerId: "openai-compat", model: "a/b" }); + }); - it("returns undefined for unknown credential name", () => { - const result = store.resolve("unknown/x"); - expect(result).toBeUndefined(); - }); + it("returns undefined for unknown credential name", () => { + const result = store.resolve("unknown/x"); + expect(result).toBeUndefined(); + }); - it("returns undefined for no slash", () => { - const result = store.resolve("noslash"); - expect(result).toBeUndefined(); - }); + it("returns undefined for no slash", () => { + const result = store.resolve("noslash"); + expect(result).toBeUndefined(); + }); - it("returns undefined for empty model segment", () => { - const result = store.resolve("opencode/"); - expect(result).toBeUndefined(); - }); - }); + it("returns undefined for empty model segment", () => { + const result = store.resolve("opencode/"); + expect(result).toBeUndefined(); + }); + }); - describe("listCatalog", () => { - it("lists models from providers with listModels", async () => { - providers.set( - "openai-compat", - fakeProvider("openai-compat", async () => [{ id: "m1" }, { id: "m2" }]), - ); + describe("listCatalog", () => { + it("lists models from providers with listModels", async () => { + providers.set( + "openai-compat", + fakeProvider("openai-compat", async () => [{ id: "m1" }, { id: "m2" }]), + ); - const catalog = await store.listCatalog(); - expect(catalog).toEqual(["opencode/m1", "opencode/m2"]); - }); + const catalog = await store.listCatalog(); + expect(catalog).toEqual(["opencode/m1", "opencode/m2"]); + }); - it("skips credentials whose provider has no listModels", async () => { - providers.set("openai-compat", fakeProvider("openai-compat")); + it("skips credentials whose provider has no listModels", async () => { + providers.set("openai-compat", fakeProvider("openai-compat")); - const catalog = await store.listCatalog(); - expect(catalog).toEqual([]); - }); + const catalog = await store.listCatalog(); + expect(catalog).toEqual([]); + }); - it("skips credentials whose provider is missing", async () => { - const emptyStore = createCredentialStore({ - credentials: [{ name: "missing", providerId: "nonexistent" }], - getProvider: () => undefined, - }); + it("skips credentials whose provider is missing", async () => { + const emptyStore = createCredentialStore({ + credentials: [{ name: "missing", providerId: "nonexistent" }], + getProvider: () => undefined, + }); - const catalog = await emptyStore.listCatalog(); - expect(catalog).toEqual([]); - }); - }); + const catalog = await emptyStore.listCatalog(); + expect(catalog).toEqual([]); + }); + }); }); diff --git a/packages/credential-store/src/registry.ts b/packages/credential-store/src/registry.ts index 89d8084..70abf9a 100644 --- a/packages/credential-store/src/registry.ts +++ b/packages/credential-store/src/registry.ts @@ -1,104 +1,104 @@ import type { ModelInfo, ProviderContract } from "@dispatch/kernel"; export interface Credential { - readonly name: string; - readonly providerId: string; + readonly name: string; + readonly providerId: string; } export interface ResolvedModel { - readonly providerId: string; - readonly model: string; + readonly providerId: string; + readonly model: string; } export interface CredentialStore { - /** - * Split a model name on the FIRST "/": name=before, model=after (model may contain "/"). - * Look up the credential by name; return its providerId + the model id, or undefined if - * the name is unknown or there is no model segment. - */ - resolve(modelName: string): ResolvedModel | undefined; - - /** - * The model catalog: for each credential, look up its provider and call listModels(), - * emitting `${credential.name}/${modelInfo.id}`. Skip credentials whose provider is - * missing or has no listModels. - */ - listCatalog(): Promise; - - /** - * Returns the full `ModelInfo` for a `/` string, or - * undefined if unknown. Caches the result of `listModels` per credential. - * Used to look up `contextWindow` for auto-compaction. - */ - getModelInfo(modelName: string): Promise; + /** + * Split a model name on the FIRST "/": name=before, model=after (model may contain "/"). + * Look up the credential by name; return its providerId + the model id, or undefined if + * the name is unknown or there is no model segment. + */ + resolve(modelName: string): ResolvedModel | undefined; + + /** + * The model catalog: for each credential, look up its provider and call listModels(), + * emitting `${credential.name}/${modelInfo.id}`. Skip credentials whose provider is + * missing or has no listModels. + */ + listCatalog(): Promise; + + /** + * Returns the full `ModelInfo` for a `/` string, or + * undefined if unknown. Caches the result of `listModels` per credential. + * Used to look up `contextWindow` for auto-compaction. + */ + getModelInfo(modelName: string): Promise; } export interface CredentialStoreDeps { - readonly credentials: readonly Credential[]; - readonly getProvider: (id: string) => ProviderContract | undefined; + readonly credentials: readonly Credential[]; + readonly getProvider: (id: string) => ProviderContract | undefined; } export function createCredentialStore(deps: CredentialStoreDeps): CredentialStore { - const credentialMap = new Map(); - for (const credential of deps.credentials) { - credentialMap.set(credential.name, credential.providerId); - } - - return { - resolve(modelName: string): ResolvedModel | undefined { - const slashIndex = modelName.indexOf("/"); - if (slashIndex === -1) { - return undefined; - } - - const credentialName = modelName.slice(0, slashIndex); - const model = modelName.slice(slashIndex + 1); - - if (!model) { - return undefined; - } - - const providerId = credentialMap.get(credentialName); - if (!providerId) { - return undefined; - } - - return { providerId, model }; - }, - - async listCatalog(): Promise { - const results: string[] = []; - - for (const credential of deps.credentials) { - const provider = deps.getProvider(credential.providerId); - if (!provider?.listModels) { - continue; - } - - const models = await provider.listModels(); - for (const model of models) { - results.push(`${credential.name}/${model.id}`); - } - } - - return results; - }, - - async getModelInfo(modelName: string): Promise { - const slashIndex = modelName.indexOf("/"); - if (slashIndex === -1) return undefined; - const credentialName = modelName.slice(0, slashIndex); - const modelId = modelName.slice(slashIndex + 1); - if (!modelId) return undefined; - - const providerId = credentialMap.get(credentialName); - if (providerId === undefined) return undefined; - - const provider = deps.getProvider(providerId); - if (provider?.listModels === undefined) return undefined; - - const models = await provider.listModels(); - return models.find((m) => m.id === modelId); - }, - }; + const credentialMap = new Map(); + for (const credential of deps.credentials) { + credentialMap.set(credential.name, credential.providerId); + } + + return { + resolve(modelName: string): ResolvedModel | undefined { + const slashIndex = modelName.indexOf("/"); + if (slashIndex === -1) { + return undefined; + } + + const credentialName = modelName.slice(0, slashIndex); + const model = modelName.slice(slashIndex + 1); + + if (!model) { + return undefined; + } + + const providerId = credentialMap.get(credentialName); + if (!providerId) { + return undefined; + } + + return { providerId, model }; + }, + + async listCatalog(): Promise { + const results: string[] = []; + + for (const credential of deps.credentials) { + const provider = deps.getProvider(credential.providerId); + if (!provider?.listModels) { + continue; + } + + const models = await provider.listModels(); + for (const model of models) { + results.push(`${credential.name}/${model.id}`); + } + } + + return results; + }, + + async getModelInfo(modelName: string): Promise { + const slashIndex = modelName.indexOf("/"); + if (slashIndex === -1) return undefined; + const credentialName = modelName.slice(0, slashIndex); + const modelId = modelName.slice(slashIndex + 1); + if (!modelId) return undefined; + + const providerId = credentialMap.get(credentialName); + if (providerId === undefined) return undefined; + + const provider = deps.getProvider(providerId); + if (provider?.listModels === undefined) return undefined; + + const models = await provider.listModels(); + return models.find((m) => m.id === modelId); + }, + }; } diff --git a/packages/credential-store/tsconfig.json b/packages/credential-store/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/credential-store/tsconfig.json +++ b/packages/credential-store/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/exec-backend/package.json b/packages/exec-backend/package.json index 19a8f9b..ac8fe6c 100644 --- a/packages/exec-backend/package.json +++ b/packages/exec-backend/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/exec-backend", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/exec-backend", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts index 30458e7..2e454ea 100644 --- a/packages/exec-backend/src/backend.test.ts +++ b/packages/exec-backend/src/backend.test.ts @@ -6,58 +6,58 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from * (Pure compile-time + runtime check; zero internal mocks.) */ describe("ExecBackend type conformance", () => { - it("a minimal fake satisfies the ExecBackend interface", () => { - const fake: ExecBackend = { - spawn: async (_params: SpawnParams): Promise => ({ - exitCode: 0, - timedOut: false, - aborted: false, - }), - readFile: async (_path: string): Promise => "", - writeFile: async (_path: string, _content: string): Promise => {}, - stat: async (_path: string): Promise => ({ isFile: true, isDirectory: false }), - readdir: async (_path: string): Promise => [], - exists: async (_path: string): Promise => true, - }; + it("a minimal fake satisfies the ExecBackend interface", () => { + const fake: ExecBackend = { + spawn: async (_params: SpawnParams): Promise => ({ + exitCode: 0, + timedOut: false, + aborted: false, + }), + readFile: async (_path: string): Promise => "", + writeFile: async (_path: string, _content: string): Promise => {}, + stat: async (_path: string): Promise => ({ isFile: true, isDirectory: false }), + readdir: async (_path: string): Promise => [], + exists: async (_path: string): Promise => true, + }; - // Runtime sanity: every method is present and callable. - expect(typeof fake.spawn).toBe("function"); - expect(typeof fake.readFile).toBe("function"); - expect(typeof fake.writeFile).toBe("function"); - expect(typeof fake.stat).toBe("function"); - expect(typeof fake.readdir).toBe("function"); - expect(typeof fake.exists).toBe("function"); - }); + // Runtime sanity: every method is present and callable. + expect(typeof fake.spawn).toBe("function"); + expect(typeof fake.readFile).toBe("function"); + expect(typeof fake.writeFile).toBe("function"); + expect(typeof fake.stat).toBe("function"); + expect(typeof fake.readdir).toBe("function"); + expect(typeof fake.exists).toBe("function"); + }); - it("ExecResult is { exitCode, timedOut, aborted }", () => { - const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; - expect(result.exitCode).toBeNull(); - expect(result.timedOut).toBe(true); - expect(result.aborted).toBe(false); - }); + it("ExecResult is { exitCode, timedOut, aborted }", () => { + const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; + expect(result.exitCode).toBeNull(); + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + }); - it("SpawnParams carries the shell-tool seam fields", () => { - const params: SpawnParams = { - command: "echo", - cwd: "/tmp", - signal: new AbortController().signal, - timeout: 1000, - onOutput: () => {}, - }; - expect(params.command).toBe("echo"); - expect(params.timeout).toBe(1000); - }); + it("SpawnParams carries the shell-tool seam fields", () => { + const params: SpawnParams = { + command: "echo", + cwd: "/tmp", + signal: new AbortController().signal, + timeout: 1000, + onOutput: () => {}, + }; + expect(params.command).toBe("echo"); + expect(params.timeout).toBe(1000); + }); - it("StatResult distinguishes file vs directory", () => { - const fileStat: StatResult = { isFile: true, isDirectory: false }; - const dirStat: StatResult = { isFile: false, isDirectory: true }; - expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); - expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); - }); + it("StatResult distinguishes file vs directory", () => { + const fileStat: StatResult = { isFile: true, isDirectory: false }; + const dirStat: StatResult = { isFile: false, isDirectory: true }; + expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); + expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); + }); - it("DirEntry carries name + isDirectory", () => { - const entry: DirEntry = { name: "sub", isDirectory: true }; - expect(entry.name).toBe("sub"); - expect(entry.isDirectory).toBe(true); - }); + it("DirEntry carries name + isDirectory", () => { + const entry: DirEntry = { name: "sub", isDirectory: true }; + expect(entry.name).toBe("sub"); + expect(entry.isDirectory).toBe(true); + }); }); diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts index f6a807f..3eea7c0 100644 --- a/packages/exec-backend/src/backend.ts +++ b/packages/exec-backend/src/backend.ts @@ -24,30 +24,30 @@ /** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */ export interface ExecResult { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; } /** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */ export interface SpawnParams { - readonly command: string; - readonly cwd: string; - readonly signal: AbortSignal; - readonly timeout: number; - readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; } /** Stat result — the subset read_file / write_file / edit_file need. */ export interface StatResult { - readonly isFile: boolean; - readonly isDirectory: boolean; + readonly isFile: boolean; + readonly isDirectory: boolean; } /** A directory entry — the subset read_file lists. */ export interface DirEntry { - readonly name: string; - readonly isDirectory: boolean; + readonly name: string; + readonly isDirectory: boolean; } /** @@ -56,23 +56,23 @@ export interface DirEntry { * `ToolExecuteContext.computerId` via the injected resolver. */ export interface ExecBackend { - /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ - readonly spawn: (params: SpawnParams) => Promise; + /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ + readonly spawn: (params: SpawnParams) => Promise; - // --- filesystem (the read_file / write_file / edit_file surface) --- + // --- filesystem (the read_file / write_file / edit_file surface) --- - /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ - readonly readFile: (path: string) => Promise; + /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ + readonly readFile: (path: string) => Promise; - /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ - readonly writeFile: (path: string, content: string) => Promise; + /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ + readonly writeFile: (path: string, content: string) => Promise; - /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ - readonly stat: (path: string) => Promise; + /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ + readonly stat: (path: string) => Promise; - /** List directory entries. Throws node:fs-style errors with `.code`. */ - readonly readdir: (path: string) => Promise; + /** List directory entries. Throws node:fs-style errors with `.code`. */ + readonly readdir: (path: string) => Promise; - /** Check existence without throwing (returns `false` when the path is missing). */ - readonly exists: (path: string) => Promise; + /** Check existence without throwing (returns `false` when the path is missing). */ + readonly exists: (path: string) => Promise; } diff --git a/packages/exec-backend/src/extension.test.ts b/packages/exec-backend/src/extension.test.ts index 57161a5..f32901b 100644 --- a/packages/exec-backend/src/extension.test.ts +++ b/packages/exec-backend/src/extension.test.ts @@ -28,94 +28,94 @@ import { execBackendHandle, remoteExecBackendFactoryHandle } from "./service.js" * against behavior-equivalent input. */ function createFakeHost(services: Map): HostAPI { - const api = { - provideService(handle: ServiceHandle, impl: T): void { - services.set(handle.id, impl); - }, - getService(handle: ServiceHandle): T { - const impl = services.get(handle.id); - if (impl === undefined) { - throw new Error( - `Service "${handle.id}" has no provider. Call provideService before getService.`, - ); - } - return impl as T; - }, - }; - // The resolver only calls getService; the rest of HostAPI is unused here. - return api as unknown as HostAPI; + const api = { + provideService(handle: ServiceHandle, impl: T): void { + services.set(handle.id, impl); + }, + getService(handle: ServiceHandle): T { + const impl = services.get(handle.id); + if (impl === undefined) { + throw new Error( + `Service "${handle.id}" has no provider. Call provideService before getService.`, + ); + } + return impl as T; + }, + }; + // The resolver only calls getService; the rest of HostAPI is unused here. + return api as unknown as HostAPI; } /** A fake remote backend — identifiable so we can assert it's the one returned. */ function createFakeRemoteBackend(marker: string): ExecBackend { - const fail = (): never => { - throw new Error(`fake remote backend (${marker}) should not be called in this test`); - }; - return { - spawn: fail, - readFile: fail, - writeFile: fail, - stat: fail, - readdir: fail, - exists: fail, - }; + const fail = (): never => { + throw new Error(`fake remote backend (${marker}) should not be called in this test`); + }; + return { + spawn: fail, + readFile: fail, + writeFile: fail, + stat: fail, + readdir: fail, + exists: fail, + }; } describe("ExecBackend resolver", () => { - it("returns localExecBackend for computerId === undefined (local path unchanged)", () => { - const services = new Map(); - const host = createFakeHost(services); - - // Activate the extension so it registers its resolver, then retrieve it. - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver(undefined)).toBe(localExecBackend); - expect(resolver()).toBe(localExecBackend); - }); - - it("returns the factory's backend for a set computerId when the factory is provided", () => { - const services = new Map(); - const host = createFakeHost(services); - - // The `ssh` extension (not built yet) would do this: - const remoteBackend = createFakeRemoteBackend("ssh-alias"); - const factory = (computerId: string): ExecBackend => { - // Confirm the alias is threaded through to the factory. - expect(computerId).toBe("ssh-alias"); - return remoteBackend; - }; - host.provideService(remoteExecBackendFactoryHandle, factory); - - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver("ssh-alias")).toBe(remoteBackend); - }); - - it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => { - const services = new Map(); - const host = createFakeHost(services); - - // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded. - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - // Not a crash: a clear, actionable error mentioning computerId + ssh. - expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/); - expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/); - expect(() => resolver("some-host")).toThrow(/some-host/); - }); - - it("local path is unaffected by whether the factory is provided", () => { - // Even with a factory present, computerId === undefined still returns local. - const services = new Map(); - const host = createFakeHost(services); - host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused")); - - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver(undefined)).toBe(localExecBackend); - }); + it("returns localExecBackend for computerId === undefined (local path unchanged)", () => { + const services = new Map(); + const host = createFakeHost(services); + + // Activate the extension so it registers its resolver, then retrieve it. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + expect(resolver()).toBe(localExecBackend); + }); + + it("returns the factory's backend for a set computerId when the factory is provided", () => { + const services = new Map(); + const host = createFakeHost(services); + + // The `ssh` extension (not built yet) would do this: + const remoteBackend = createFakeRemoteBackend("ssh-alias"); + const factory = (computerId: string): ExecBackend => { + // Confirm the alias is threaded through to the factory. + expect(computerId).toBe("ssh-alias"); + return remoteBackend; + }; + host.provideService(remoteExecBackendFactoryHandle, factory); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver("ssh-alias")).toBe(remoteBackend); + }); + + it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => { + const services = new Map(); + const host = createFakeHost(services); + + // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + // Not a crash: a clear, actionable error mentioning computerId + ssh. + expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/); + expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/); + expect(() => resolver("some-host")).toThrow(/some-host/); + }); + + it("local path is unaffected by whether the factory is provided", () => { + // Even with a factory present, computerId === undefined still returns local. + const services = new Map(); + const host = createFakeHost(services); + host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused")); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + }); }); diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts index 9d6840a..5697842 100644 --- a/packages/exec-backend/src/extension.ts +++ b/packages/exec-backend/src/extension.ts @@ -2,19 +2,19 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import type { ExecBackend } from "./backend.js"; import { localExecBackend } from "./local.js"; import { - type ExecBackendResolver, - execBackendHandle, - remoteExecBackendFactoryHandle, + type ExecBackendResolver, + execBackendHandle, + remoteExecBackendFactoryHandle, } from "./service.js"; export const manifest: Manifest = { - id: "exec-backend", - name: "Exec Backend", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - contributes: { services: ["exec-backend/resolver"] }, + id: "exec-backend", + name: "Exec Backend", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["exec-backend/resolver"] }, }; /** @@ -36,22 +36,22 @@ export const manifest: Manifest = { * inside the first backend method call, not at resolve time. */ function createResolver(host: HostAPI): ExecBackendResolver { - return (computerId?: string): ExecBackend => { - if (computerId === undefined) return localExecBackend; - // computerId set → remote. Look up the factory the `ssh` extension provides. - // `host.getService` throws when nothing provided the handle (ssh not loaded); - // convert that into a clear "not configured" error rather than a crash. - let factory: (computerId: string) => ExecBackend; - try { - factory = host.getService(remoteExecBackendFactoryHandle); - } catch { - throw new Error( - `SSH remote execution is not configured: the ssh extension is not loaded ` + - `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`, - ); - } - return factory(computerId); - }; + return (computerId?: string): ExecBackend => { + if (computerId === undefined) return localExecBackend; + // computerId set → remote. Look up the factory the `ssh` extension provides. + // `host.getService` throws when nothing provided the handle (ssh not loaded); + // convert that into a clear "not configured" error rather than a crash. + let factory: (computerId: string) => ExecBackend; + try { + factory = host.getService(remoteExecBackendFactoryHandle); + } catch { + throw new Error( + `SSH remote execution is not configured: the ssh extension is not loaded ` + + `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`, + ); + } + return factory(computerId); + }; } /** @@ -63,11 +63,11 @@ function createResolver(host: HostAPI): ExecBackendResolver { * until `ssh` is loaded, a remote request fails with a clear error. */ export function createExecBackendExtension(): Extension { - return { - manifest, - activate(host) { - const resolver: ExecBackendResolver = createResolver(host); - host.provideService(execBackendHandle, resolver); - }, - }; + return { + manifest, + activate(host) { + const resolver: ExecBackendResolver = createResolver(host); + host.provideService(execBackendHandle, resolver); + }, + }; } diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts index 30c12c8..7c8e569 100644 --- a/packages/exec-backend/src/index.ts +++ b/packages/exec-backend/src/index.ts @@ -3,6 +3,6 @@ export { createExecBackendExtension, manifest } from "./extension.js"; export { createLocalExecBackend, localExecBackend } from "./local.js"; export type { ExecBackendResolver } from "./service.js"; export { - execBackendHandle, - remoteExecBackendFactoryHandle, + execBackendHandle, + remoteExecBackendFactoryHandle, } from "./service.js"; diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts index 5357d6f..7bf2ef5 100644 --- a/packages/exec-backend/src/local.test.ts +++ b/packages/exec-backend/src/local.test.ts @@ -10,190 +10,190 @@ import { createLocalExecBackend, localExecBackend } from "./local.js"; * (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*. */ describe("LocalExecBackend", () => { - const backend: ExecBackend = createLocalExecBackend(); - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-")); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - describe("spawn", () => { - it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => { - let output = ""; - const result = await backend.spawn({ - command: "echo hi", - cwd: tmpDir, - signal: AbortSignal.timeout(5000), - timeout: 5000, - onOutput: (data) => { - output += data; - }, - }); - expect(result.exitCode).toBe(0); - expect(result.timedOut).toBe(false); - expect(result.aborted).toBe(false); - expect(output).toContain("hi"); - }); - - it("returns a non-zero exit code for a failing command", async () => { - const result = await backend.spawn({ - command: "false", - cwd: tmpDir, - signal: AbortSignal.timeout(5000), - timeout: 5000, - onOutput: () => {}, - }); - expect(result.exitCode).toBe(1); - expect(result.aborted).toBe(false); - expect(result.timedOut).toBe(false); - }); - - it("streams stderr separately from stdout", async () => { - const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - const result = await backend.spawn({ - command: "echo out; echo err 1>&2", - cwd: tmpDir, - signal: AbortSignal.timeout(5000), - timeout: 5000, - onOutput: (data, stream) => streams.push({ data, stream }), - }); - expect(result.exitCode).toBe(0); - expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true); - expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true); - }); - - it("resolves with aborted: true when the signal fires", async () => { - const controller = new AbortController(); - const promise = backend.spawn({ - command: "sleep 30", - cwd: tmpDir, - signal: controller.signal, - timeout: 60_000, - onOutput: () => {}, - }); - // Let the sleep actually start. - await new Promise((r) => setTimeout(r, 300)); - controller.abort(); - const result = await promise; - expect(result.aborted).toBe(true); - expect(result.timedOut).toBe(false); - }); - - it("resolves with timedOut: true when the timeout elapses", async () => { - const start = Date.now(); - const result = await backend.spawn({ - command: "sleep 30", - cwd: tmpDir, - signal: AbortSignal.timeout(60_000), - timeout: 300, - onOutput: () => {}, - }); - const elapsed = Date.now() - start; - expect(result.timedOut).toBe(true); - expect(result.aborted).toBe(false); - // Should resolve shortly after the 300ms timeout, well under 30s. - expect(elapsed).toBeLessThan(10_000); - }); - }); - - describe("stat", () => { - it("distinguishes file vs directory", async () => { - await fsWriteFile(join(tmpDir, "file.txt"), "hello"); - await mkdir(join(tmpDir, "subdir")); - - const fileStat = await backend.stat(join(tmpDir, "file.txt")); - expect(fileStat.isFile).toBe(true); - expect(fileStat.isDirectory).toBe(false); - - const dirStat = await backend.stat(join(tmpDir, "subdir")); - expect(dirStat.isFile).toBe(false); - expect(dirStat.isDirectory).toBe(true); - }); - - it("throws ENOENT with .code for a missing path", async () => { - try { - await backend.stat(join(tmpDir, "nope")); - expect.fail("stat should have thrown for a missing path"); - } catch (err: unknown) { - expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); - } - }); - }); - - describe("readFile / writeFile / readdir / exists round-trip", () => { - it("writes then reads a file (utf8 round-trip)", async () => { - const filePath = join(tmpDir, "round.txt"); - await backend.writeFile(filePath, "round-trip content"); - const content = await backend.readFile(filePath); - expect(content).toBe("round-trip content"); - }); - - it("readdir lists entries with correct isDirectory flags", async () => { - await fsWriteFile(join(tmpDir, "a.txt"), "a"); - await mkdir(join(tmpDir, "sub")); - - const entries = await backend.readdir(tmpDir); - const names = entries.map((e) => e.name).sort(); - expect(names).toEqual(["a.txt", "sub"]); - - const sub = entries.find((e) => e.name === "sub"); - expect(sub?.isDirectory).toBe(true); - - const file = entries.find((e) => e.name === "a.txt"); - expect(file?.isDirectory).toBe(false); - }); - - it("exists returns true for an existing file, false for a missing one", async () => { - const filePath = join(tmpDir, "exists.txt"); - await fsWriteFile(filePath, "x"); - expect(await backend.exists(filePath)).toBe(true); - expect(await backend.exists(join(tmpDir, "missing"))).toBe(false); - }); - - it("exists returns true for an existing directory", async () => { - await mkdir(join(tmpDir, "adir")); - expect(await backend.exists(join(tmpDir, "adir"))).toBe(true); - }); - - it("readFile throws ENOENT with .code for a missing file", async () => { - try { - await backend.readFile(join(tmpDir, "missing.txt")); - expect.fail("readFile should have thrown for a missing file"); - } catch (err: unknown) { - expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); - } - }); - - it("readdir throws ENOENT with .code for a missing directory", async () => { - try { - await backend.readdir(join(tmpDir, "missingdir")); - expect.fail("readdir should have thrown for a missing directory"); - } catch (err: unknown) { - expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); - } - }); - - it("writeFile throws an error with .code when the parent dir is missing", async () => { - try { - await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x"); - expect.fail("writeFile should have thrown for a missing parent dir"); - } catch (err: unknown) { - expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); - } - }); - }); - - describe("singleton", () => { - it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => { - expect(typeof localExecBackend.spawn).toBe("function"); - expect(typeof localExecBackend.readFile).toBe("function"); - const filePath = join(tmpDir, "singleton.txt"); - await localExecBackend.writeFile(filePath, "singleton"); - expect(await localExecBackend.readFile(filePath)).toBe("singleton"); - }); - }); + const backend: ExecBackend = createLocalExecBackend(); + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + describe("spawn", () => { + it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => { + let output = ""; + const result = await backend.spawn({ + command: "echo hi", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data) => { + output += data; + }, + }); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.aborted).toBe(false); + expect(output).toContain("hi"); + }); + + it("returns a non-zero exit code for a failing command", async () => { + const result = await backend.spawn({ + command: "false", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: () => {}, + }); + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(false); + expect(result.timedOut).toBe(false); + }); + + it("streams stderr separately from stdout", async () => { + const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const result = await backend.spawn({ + command: "echo out; echo err 1>&2", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data, stream) => streams.push({ data, stream }), + }); + expect(result.exitCode).toBe(0); + expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true); + expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true); + }); + + it("resolves with aborted: true when the signal fires", async () => { + const controller = new AbortController(); + const promise = backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: controller.signal, + timeout: 60_000, + onOutput: () => {}, + }); + // Let the sleep actually start. + await new Promise((r) => setTimeout(r, 300)); + controller.abort(); + const result = await promise; + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + }); + + it("resolves with timedOut: true when the timeout elapses", async () => { + const start = Date.now(); + const result = await backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: AbortSignal.timeout(60_000), + timeout: 300, + onOutput: () => {}, + }); + const elapsed = Date.now() - start; + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + // Should resolve shortly after the 300ms timeout, well under 30s. + expect(elapsed).toBeLessThan(10_000); + }); + }); + + describe("stat", () => { + it("distinguishes file vs directory", async () => { + await fsWriteFile(join(tmpDir, "file.txt"), "hello"); + await mkdir(join(tmpDir, "subdir")); + + const fileStat = await backend.stat(join(tmpDir, "file.txt")); + expect(fileStat.isFile).toBe(true); + expect(fileStat.isDirectory).toBe(false); + + const dirStat = await backend.stat(join(tmpDir, "subdir")); + expect(dirStat.isFile).toBe(false); + expect(dirStat.isDirectory).toBe(true); + }); + + it("throws ENOENT with .code for a missing path", async () => { + try { + await backend.stat(join(tmpDir, "nope")); + expect.fail("stat should have thrown for a missing path"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("readFile / writeFile / readdir / exists round-trip", () => { + it("writes then reads a file (utf8 round-trip)", async () => { + const filePath = join(tmpDir, "round.txt"); + await backend.writeFile(filePath, "round-trip content"); + const content = await backend.readFile(filePath); + expect(content).toBe("round-trip content"); + }); + + it("readdir lists entries with correct isDirectory flags", async () => { + await fsWriteFile(join(tmpDir, "a.txt"), "a"); + await mkdir(join(tmpDir, "sub")); + + const entries = await backend.readdir(tmpDir); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["a.txt", "sub"]); + + const sub = entries.find((e) => e.name === "sub"); + expect(sub?.isDirectory).toBe(true); + + const file = entries.find((e) => e.name === "a.txt"); + expect(file?.isDirectory).toBe(false); + }); + + it("exists returns true for an existing file, false for a missing one", async () => { + const filePath = join(tmpDir, "exists.txt"); + await fsWriteFile(filePath, "x"); + expect(await backend.exists(filePath)).toBe(true); + expect(await backend.exists(join(tmpDir, "missing"))).toBe(false); + }); + + it("exists returns true for an existing directory", async () => { + await mkdir(join(tmpDir, "adir")); + expect(await backend.exists(join(tmpDir, "adir"))).toBe(true); + }); + + it("readFile throws ENOENT with .code for a missing file", async () => { + try { + await backend.readFile(join(tmpDir, "missing.txt")); + expect.fail("readFile should have thrown for a missing file"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("readdir throws ENOENT with .code for a missing directory", async () => { + try { + await backend.readdir(join(tmpDir, "missingdir")); + expect.fail("readdir should have thrown for a missing directory"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("writeFile throws an error with .code when the parent dir is missing", async () => { + try { + await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x"); + expect.fail("writeFile should have thrown for a missing parent dir"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("singleton", () => { + it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => { + expect(typeof localExecBackend.spawn).toBe("function"); + expect(typeof localExecBackend.readFile).toBe("function"); + const filePath = join(tmpDir, "singleton.txt"); + await localExecBackend.writeFile(filePath, "singleton"); + expect(await localExecBackend.readFile(filePath)).toBe("singleton"); + }); + }); }); diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts index ca88a11..1812e5d 100644 --- a/packages/exec-backend/src/local.ts +++ b/packages/exec-backend/src/local.ts @@ -20,32 +20,32 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from * share as a singleton. */ export function createLocalExecBackend(): ExecBackend { - return { - spawn: localSpawn, - - readFile: (path) => readFile(path, "utf8"), - - writeFile: (path, content) => writeFile(path, content, "utf8"), - - stat: async (path): Promise => { - const s = await stat(path); - return { isFile: s.isFile(), isDirectory: s.isDirectory() }; - }, - - readdir: async (path): Promise => { - const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); - return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); - }, - - exists: async (path): Promise => { - try { - await access(path); - return true; - } catch { - return false; - } - }, - }; + return { + spawn: localSpawn, + + readFile: (path) => readFile(path, "utf8"), + + writeFile: (path, content) => writeFile(path, content, "utf8"), + + stat: async (path): Promise => { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + + readdir: async (path): Promise => { + const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); + return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); + }, + + exists: async (path): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } + }, + }; } /** Default singleton — stateless, safe to share across calls. */ @@ -61,86 +61,86 @@ export const localExecBackend: ExecBackend = createLocalExecBackend(); * listener/timer leaks. */ function localSpawn(params: SpawnParams): Promise { - return new Promise((resolve) => { - // detached: true puts the child in its own process group (pgid = child.pid). - // This lets us kill the entire group (child + any grandchildren that inherit - // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a - // backgrounded grandchild can't keep the stdio pipes open and stall the - // promise on child.on("close"). - const child = nodeSpawn("sh", ["-c", params.command], { - cwd: params.cwd, - stdio: ["ignore", "pipe", "pipe"], - detached: true, - }); - - let settled = false; - let timedOut = false; - let timer: ReturnType | undefined; - - /** Kill the entire child process group (best-effort — group may be gone). */ - const killGroup = () => { - if (child.pid !== undefined) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - // Process group may already be gone — ignore. - } - } - }; - - /** Remove the abort listener and clear the timeout timer (no leaks). */ - const cleanup = () => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - params.signal.removeEventListener("abort", onAbort); - }; - - /** Resolve once, then clean up so listeners/timers never leak. */ - const settle = (result: ExecResult) => { - if (settled) return; - settled = true; - cleanup(); - resolve(result); - }; - - const onAbort = () => { - if (settled) return; - killGroup(); - // Resolve immediately — do NOT wait for child.on("close"), which may - // never fire if a grandchild holds the pipes open. - settle({ exitCode: null, timedOut: false, aborted: true }); - }; - params.signal.addEventListener("abort", onAbort, { once: true }); - - timer = setTimeout(() => { - if (settled) return; - timedOut = true; - killGroup(); - // Resolve immediately — same reasoning as abort. - settle({ exitCode: null, timedOut: true, aborted: false }); - }, params.timeout); - - child.stdout.on("data", (chunk: Buffer) => { - params.onOutput(chunk.toString(), "stdout"); - }); - - child.stderr.on("data", (chunk: Buffer) => { - params.onOutput(chunk.toString(), "stderr"); - }); - - // Normal-completion path: wait for "close" so all stdout/stderr is captured. - // If abort/timeout already settled, this is a no-op (settled === true). - child.on("close", (code) => { - settle({ exitCode: code, timedOut, aborted: false }); - }); - - // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case - // and resolve — never leave the promise pending. - child.on("error", () => { - killGroup(); - settle({ exitCode: 1, timedOut: false, aborted: false }); - }); - }); + return new Promise((resolve) => { + // detached: true puts the child in its own process group (pgid = child.pid). + // This lets us kill the entire group (child + any grandchildren that inherit + // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a + // backgrounded grandchild can't keep the stdio pipes open and stall the + // promise on child.on("close"). + const child = nodeSpawn("sh", ["-c", params.command], { + cwd: params.cwd, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let settled = false; + let timedOut = false; + let timer: ReturnType | undefined; + + /** Kill the entire child process group (best-effort — group may be gone). */ + const killGroup = () => { + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Process group may already be gone — ignore. + } + } + }; + + /** Remove the abort listener and clear the timeout timer (no leaks). */ + const cleanup = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + params.signal.removeEventListener("abort", onAbort); + }; + + /** Resolve once, then clean up so listeners/timers never leak. */ + const settle = (result: ExecResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const onAbort = () => { + if (settled) return; + killGroup(); + // Resolve immediately — do NOT wait for child.on("close"), which may + // never fire if a grandchild holds the pipes open. + settle({ exitCode: null, timedOut: false, aborted: true }); + }; + params.signal.addEventListener("abort", onAbort, { once: true }); + + timer = setTimeout(() => { + if (settled) return; + timedOut = true; + killGroup(); + // Resolve immediately — same reasoning as abort. + settle({ exitCode: null, timedOut: true, aborted: false }); + }, params.timeout); + + child.stdout.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stdout"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stderr"); + }); + + // Normal-completion path: wait for "close" so all stdout/stderr is captured. + // If abort/timeout already settled, this is a no-op (settled === true). + child.on("close", (code) => { + settle({ exitCode: code, timedOut, aborted: false }); + }); + + // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case + // and resolve — never leave the promise pending. + child.on("error", () => { + killGroup(); + settle({ exitCode: 1, timedOut: false, aborted: false }); + }); + }); } diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts index 6cfa9de..37009c0 100644 --- a/packages/exec-backend/src/service.ts +++ b/packages/exec-backend/src/service.ts @@ -42,5 +42,5 @@ export const execBackendHandle = defineService("exec-backen * rather than crashing activation. */ export const remoteExecBackendFactoryHandle = defineService<(computerId: string) => ExecBackend>( - "exec-backend/remote-factory", + "exec-backend/remote-factory", ); diff --git a/packages/exec-backend/tsconfig.json b/packages/exec-backend/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/exec-backend/tsconfig.json +++ b/packages/exec-backend/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 64f436e..6665500 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -1,37 +1,37 @@ { - "name": "@dispatch/host-bin", - "version": "0.0.0", - "type": "module", - "private": true, - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/storage-sqlite": "workspace:*", - "@dispatch/conversation-store": "workspace:*", - "@dispatch/auth-apikey": "workspace:*", - "@dispatch/cache-warming": "workspace:*", - "@dispatch/credential-store": "workspace:*", - "@dispatch/exec-backend": "workspace:*", - "@dispatch/provider-openai-compat": "workspace:*", - "@dispatch/provider-umans": "workspace:*", - "@dispatch/message-queue": "workspace:*", - "@dispatch/mcp": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/skills": "workspace:*", - "@dispatch/ssh": "workspace:*", - "@dispatch/throughput-store": "workspace:*", - "@dispatch/todo": "workspace:*", - "@dispatch/transport-http": "workspace:*", - "@dispatch/tool-read-file": "workspace:*", - "@dispatch/tool-shell": "workspace:*", - "@dispatch/tool-edit-file": "workspace:*", - "@dispatch/tool-write-file": "workspace:*", - "@dispatch/tool-web-search": "workspace:*", - "@dispatch/tool-youtube-transcript": "workspace:*", - "@dispatch/journal-sink": "workspace:*", - "@dispatch/lsp": "workspace:*", - "@dispatch/surface-loaded-extensions": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/transport-ws": "workspace:*", - "@dispatch/system-prompt": "workspace:*" - } + "name": "@dispatch/host-bin", + "version": "0.0.0", + "type": "module", + "private": true, + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/storage-sqlite": "workspace:*", + "@dispatch/conversation-store": "workspace:*", + "@dispatch/auth-apikey": "workspace:*", + "@dispatch/cache-warming": "workspace:*", + "@dispatch/credential-store": "workspace:*", + "@dispatch/exec-backend": "workspace:*", + "@dispatch/provider-openai-compat": "workspace:*", + "@dispatch/provider-umans": "workspace:*", + "@dispatch/message-queue": "workspace:*", + "@dispatch/mcp": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/skills": "workspace:*", + "@dispatch/ssh": "workspace:*", + "@dispatch/throughput-store": "workspace:*", + "@dispatch/todo": "workspace:*", + "@dispatch/transport-http": "workspace:*", + "@dispatch/tool-read-file": "workspace:*", + "@dispatch/tool-shell": "workspace:*", + "@dispatch/tool-edit-file": "workspace:*", + "@dispatch/tool-write-file": "workspace:*", + "@dispatch/tool-web-search": "workspace:*", + "@dispatch/tool-youtube-transcript": "workspace:*", + "@dispatch/journal-sink": "workspace:*", + "@dispatch/lsp": "workspace:*", + "@dispatch/surface-loaded-extensions": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/transport-ws": "workspace:*", + "@dispatch/system-prompt": "workspace:*" + } } diff --git a/packages/host-bin/src/collector-supervisor.test.ts b/packages/host-bin/src/collector-supervisor.test.ts index 8c1f104..f590806 100644 --- a/packages/host-bin/src/collector-supervisor.test.ts +++ b/packages/host-bin/src/collector-supervisor.test.ts @@ -2,300 +2,300 @@ import { describe, expect, it } from "vitest"; import { type ChildHandle, createCollectorSupervisor } from "./collector-supervisor.js"; interface FakeChild { - readonly handle: ChildHandle; - resolveExit: (code: number) => void; - readonly signals: string[]; + readonly handle: ChildHandle; + resolveExit: (code: number) => void; + readonly signals: string[]; } function createFakeChild(code = 0): FakeChild { - let resolveExit!: (code: number) => void; - const exited = new Promise((r) => { - resolveExit = r; - }); - const signals: string[] = []; - const handle: ChildHandle = { - kill: (signal?: string) => { - signals.push(signal ?? "SIGTERM"); - if (signal === "SIGKILL") resolveExit(code); - }, - exited, - }; - return { handle, resolveExit, signals }; + let resolveExit!: (code: number) => void; + const exited = new Promise((r) => { + resolveExit = r; + }); + const signals: string[] = []; + const handle: ChildHandle = { + kill: (signal?: string) => { + signals.push(signal ?? "SIGTERM"); + if (signal === "SIGKILL") resolveExit(code); + }, + exited, + }; + return { handle, resolveExit, signals }; } function createFakeLogger() { - const msgs: Array<{ level: string; msg: string }> = []; - return { - msgs, - debug: () => {}, - info: (msg: string) => msgs.push({ level: "info", msg }), - warn: (msg: string) => msgs.push({ level: "warn", msg }), - error: () => {}, - child: () => createFakeLogger(), - span: () => ({ - id: "s", - log: createFakeLogger(), - setAttributes: () => {}, - addLink: () => {}, - child: () => ({}) as never, - end: () => {}, - }), - }; + const msgs: Array<{ level: string; msg: string }> = []; + return { + msgs, + debug: () => {}, + info: (msg: string) => msgs.push({ level: "info", msg }), + warn: (msg: string) => msgs.push({ level: "warn", msg }), + error: () => {}, + child: () => createFakeLogger(), + span: () => ({ + id: "s", + log: createFakeLogger(), + setAttributes: () => {}, + addLink: () => {}, + child: () => ({}) as never, + end: () => {}, + }), + }; } describe("createCollectorSupervisor", () => { - const DEFAULTS = { - journalPath: "/tmp/journal.ndjson", - dbPath: "/tmp/traces.db", - }; - - it("start() spawns with the correct command and args", () => { - let capturedCmd: string[] = []; - const children: FakeChild[] = []; - const spawn = (cmd: string[]) => { - capturedCmd = cmd; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - expect(capturedCmd).toEqual([ - "bun", - "packages/observability-collector/src/main.ts", - "--journal", - "/tmp/journal.ndjson", - "--db", - "/tmp/traces.db", - ]); - }); - - it("start() passes --interval when provided", () => { - let capturedCmd: string[] = []; - const spawn = (cmd: string[]) => { - capturedCmd = cmd; - return createFakeChild().handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - interval: 500, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - expect(capturedCmd).toContain("--interval"); - expect(capturedCmd).toContain("500"); - }); - - it("unexpected child exit respawns the collector", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise((r) => { - delayResolvers.push(r); - }); - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - now, - delay, - }); - supervisor.start(); - - expect(spawnCount).toBe(1); - - // Simulate unexpected exit - children[0]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - - // Trigger the backoff delay resolver - expect(delayResolvers.length).toBe(1); - delayResolvers[0]?.(); - await Promise.resolve(); - await Promise.resolve(); - - expect(spawnCount).toBe(2); - }); - - it("restart guard caps respawns in a tight loop", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise((r) => { - delayResolvers.push(r); - }); - - const logger = createFakeLogger(); - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: logger as never, - now, - delay, - }); - supervisor.start(); - - // Simulate rapid crashes (within the restart window) - for (let i = 0; i < 5; i++) { - children[i]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - if (delayResolvers.length > i) { - delayResolvers[i]?.(); - await Promise.resolve(); - await Promise.resolve(); - } - } - - // Should have spawned 6 times (1 initial + 5 restarts) - expect(spawnCount).toBe(6); - - // 6th child also dies — should NOT respawn (cap reached) - children[5]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - - // spawnCount should still be 6 - expect(spawnCount).toBe(6); - expect(logger.msgs.some((m) => m.msg === "Collector restart cap reached; giving up")).toBe( - true, - ); - }); - - it("stop() sends SIGTERM and does not respawn", async () => { - const child = createFakeChild(); - const spawn = () => child.handle; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - // Resolve exit after SIGTERM (simulating graceful shutdown) - const stopPromise = supervisor.stop(); - child.resolveExit(0); - await stopPromise; - - expect(child.signals).toContain("SIGTERM"); - }); - - it("stop() sends SIGKILL when child does not exit in time", async () => { - const child = createFakeChild(); - const spawn = () => child.handle; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise((r) => { - delayResolvers.push(r); - }); - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - now, - delay, - }); - supervisor.start(); - - const stopPromise = supervisor.stop(); - - // Don't resolve exit — simulate hung child - // Resolve the timeout delay instead - expect(delayResolvers.length).toBe(1); - delayResolvers[0]?.(); - await stopPromise; - - expect(child.signals).toContain("SIGTERM"); - expect(child.signals).toContain("SIGKILL"); - }); - - it("stop() does not respawn after unexpected exit during stop", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - expect(spawnCount).toBe(1); - - // Child exits during stop — supervisor is already stopping - const stopPromise = supervisor.stop(); - children[0]?.resolveExit(1); - await stopPromise; - - // Should NOT have respawned - expect(spawnCount).toBe(1); - }); - - it("spawn throwing does not throw to caller", () => { - const spawn = () => { - throw new Error("spawn failed"); - }; - - const logger = createFakeLogger(); - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: logger as never, - }); - - expect(() => supervisor.start()).not.toThrow(); - expect(logger.msgs.some((m) => m.msg === "Failed to spawn collector")).toBe(true); - }); - - it("stop() is safe to call when no child was started", async () => { - const spawn = () => createFakeChild().handle; - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - - await expect(supervisor.stop()).resolves.toBeUndefined(); - }); + const DEFAULTS = { + journalPath: "/tmp/journal.ndjson", + dbPath: "/tmp/traces.db", + }; + + it("start() spawns with the correct command and args", () => { + let capturedCmd: string[] = []; + const children: FakeChild[] = []; + const spawn = (cmd: string[]) => { + capturedCmd = cmd; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + expect(capturedCmd).toEqual([ + "bun", + "packages/observability-collector/src/main.ts", + "--journal", + "/tmp/journal.ndjson", + "--db", + "/tmp/traces.db", + ]); + }); + + it("start() passes --interval when provided", () => { + let capturedCmd: string[] = []; + const spawn = (cmd: string[]) => { + capturedCmd = cmd; + return createFakeChild().handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + interval: 500, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + expect(capturedCmd).toContain("--interval"); + expect(capturedCmd).toContain("500"); + }); + + it("unexpected child exit respawns the collector", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise((r) => { + delayResolvers.push(r); + }); + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + now, + delay, + }); + supervisor.start(); + + expect(spawnCount).toBe(1); + + // Simulate unexpected exit + children[0]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + + // Trigger the backoff delay resolver + expect(delayResolvers.length).toBe(1); + delayResolvers[0]?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(spawnCount).toBe(2); + }); + + it("restart guard caps respawns in a tight loop", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise((r) => { + delayResolvers.push(r); + }); + + const logger = createFakeLogger(); + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: logger as never, + now, + delay, + }); + supervisor.start(); + + // Simulate rapid crashes (within the restart window) + for (let i = 0; i < 5; i++) { + children[i]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + if (delayResolvers.length > i) { + delayResolvers[i]?.(); + await Promise.resolve(); + await Promise.resolve(); + } + } + + // Should have spawned 6 times (1 initial + 5 restarts) + expect(spawnCount).toBe(6); + + // 6th child also dies — should NOT respawn (cap reached) + children[5]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + + // spawnCount should still be 6 + expect(spawnCount).toBe(6); + expect(logger.msgs.some((m) => m.msg === "Collector restart cap reached; giving up")).toBe( + true, + ); + }); + + it("stop() sends SIGTERM and does not respawn", async () => { + const child = createFakeChild(); + const spawn = () => child.handle; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + // Resolve exit after SIGTERM (simulating graceful shutdown) + const stopPromise = supervisor.stop(); + child.resolveExit(0); + await stopPromise; + + expect(child.signals).toContain("SIGTERM"); + }); + + it("stop() sends SIGKILL when child does not exit in time", async () => { + const child = createFakeChild(); + const spawn = () => child.handle; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise((r) => { + delayResolvers.push(r); + }); + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + now, + delay, + }); + supervisor.start(); + + const stopPromise = supervisor.stop(); + + // Don't resolve exit — simulate hung child + // Resolve the timeout delay instead + expect(delayResolvers.length).toBe(1); + delayResolvers[0]?.(); + await stopPromise; + + expect(child.signals).toContain("SIGTERM"); + expect(child.signals).toContain("SIGKILL"); + }); + + it("stop() does not respawn after unexpected exit during stop", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + expect(spawnCount).toBe(1); + + // Child exits during stop — supervisor is already stopping + const stopPromise = supervisor.stop(); + children[0]?.resolveExit(1); + await stopPromise; + + // Should NOT have respawned + expect(spawnCount).toBe(1); + }); + + it("spawn throwing does not throw to caller", () => { + const spawn = () => { + throw new Error("spawn failed"); + }; + + const logger = createFakeLogger(); + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: logger as never, + }); + + expect(() => supervisor.start()).not.toThrow(); + expect(logger.msgs.some((m) => m.msg === "Failed to spawn collector")).toBe(true); + }); + + it("stop() is safe to call when no child was started", async () => { + const spawn = () => createFakeChild().handle; + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + + await expect(supervisor.stop()).resolves.toBeUndefined(); + }); }); diff --git a/packages/host-bin/src/collector-supervisor.ts b/packages/host-bin/src/collector-supervisor.ts index 7b893b9..ff51b86 100644 --- a/packages/host-bin/src/collector-supervisor.ts +++ b/packages/host-bin/src/collector-supervisor.ts @@ -1,18 +1,18 @@ import type { Logger } from "@dispatch/kernel"; export interface ChildHandle { - readonly kill: (signal?: string) => void; - readonly exited: Promise; + readonly kill: (signal?: string) => void; + readonly exited: Promise; } export interface SupervisorDeps { - readonly spawn: (cmd: string[]) => ChildHandle; - readonly journalPath: string; - readonly dbPath: string; - readonly interval?: number; - readonly logger: Logger; - readonly now?: () => number; - readonly delay?: (ms: number) => Promise; + readonly spawn: (cmd: string[]) => ChildHandle; + readonly journalPath: string; + readonly dbPath: string; + readonly interval?: number; + readonly logger: Logger; + readonly now?: () => number; + readonly delay?: (ms: number) => Promise; } const RESTART_WINDOW_MS = 10_000; @@ -21,118 +21,118 @@ const BACKOFF_BASE_MS = 500; const STOP_TIMEOUT_MS = 5_000; export function createCollectorSupervisor(deps: SupervisorDeps): { - start: () => void; - stop: () => Promise; + start: () => void; + stop: () => Promise; } { - const { - spawn, - journalPath, - dbPath, - interval, - logger, - now = () => Date.now(), - delay = (ms) => new Promise((r) => setTimeout(r, ms)), - } = deps; - - let child: ChildHandle | null = null; - let stopping = false; - const restartTimestamps: number[] = []; - - function buildCmd(): string[] { - const cmd = [ - "bun", - "packages/observability-collector/src/main.ts", - "--journal", - journalPath, - "--db", - dbPath, - ]; - if (interval !== undefined) { - cmd.push("--interval", String(interval)); - } - return cmd; - } - - function pruneOldRestarts(): void { - const cutoff = now() - RESTART_WINDOW_MS; - while (restartTimestamps.length > 0) { - const oldest = restartTimestamps[0]; - if (oldest === undefined || oldest > cutoff) break; - restartTimestamps.shift(); - } - } - - function shouldRestart(): boolean { - pruneOldRestarts(); - return restartTimestamps.length < MAX_RESTARTS_IN_WINDOW; - } - - function getBackoffMs(): number { - return BACKOFF_BASE_MS * 2 ** restartTimestamps.length; - } - - function onChildExit(code: number): void { - child = null; - if (stopping) return; - - logger.warn("Collector exited unexpectedly", { code } as never); - if (!shouldRestart()) { - logger.warn("Collector restart cap reached; giving up", { - restarts: restartTimestamps.length, - windowMs: RESTART_WINDOW_MS, - } as never); - return; - } - - restartTimestamps.push(now()); - const backoff = getBackoffMs(); - logger.info("Restarting collector after backoff", { backoffMs: backoff } as never); - delay(backoff) - .then(() => { - if (!stopping) spawnChild(); - }) - .catch(() => {}); - } - - function spawnChild(): void { - try { - const handle = spawn(buildCmd()); - child = handle; - logger.info("Collector started"); - handle.exited.then( - (code) => onChildExit(code), - () => {}, - ); - } catch (err) { - logger.warn("Failed to spawn collector", { err } as never); - } - } - - function start(): void { - spawnChild(); - } - - async function stop(): Promise { - stopping = true; - if (!child) return; - - const handle = child; - handle.kill("SIGTERM"); - - let resolved = false; - const exitedPromise = handle.exited.then(() => { - resolved = true; - }); - - const timeoutPromise = delay(STOP_TIMEOUT_MS).then(() => { - if (!resolved) { - handle.kill("SIGKILL"); - return handle.exited; - } - }); - - await Promise.race([exitedPromise, timeoutPromise]); - } - - return { start, stop }; + const { + spawn, + journalPath, + dbPath, + interval, + logger, + now = () => Date.now(), + delay = (ms) => new Promise((r) => setTimeout(r, ms)), + } = deps; + + let child: ChildHandle | null = null; + let stopping = false; + const restartTimestamps: number[] = []; + + function buildCmd(): string[] { + const cmd = [ + "bun", + "packages/observability-collector/src/main.ts", + "--journal", + journalPath, + "--db", + dbPath, + ]; + if (interval !== undefined) { + cmd.push("--interval", String(interval)); + } + return cmd; + } + + function pruneOldRestarts(): void { + const cutoff = now() - RESTART_WINDOW_MS; + while (restartTimestamps.length > 0) { + const oldest = restartTimestamps[0]; + if (oldest === undefined || oldest > cutoff) break; + restartTimestamps.shift(); + } + } + + function shouldRestart(): boolean { + pruneOldRestarts(); + return restartTimestamps.length < MAX_RESTARTS_IN_WINDOW; + } + + function getBackoffMs(): number { + return BACKOFF_BASE_MS * 2 ** restartTimestamps.length; + } + + function onChildExit(code: number): void { + child = null; + if (stopping) return; + + logger.warn("Collector exited unexpectedly", { code } as never); + if (!shouldRestart()) { + logger.warn("Collector restart cap reached; giving up", { + restarts: restartTimestamps.length, + windowMs: RESTART_WINDOW_MS, + } as never); + return; + } + + restartTimestamps.push(now()); + const backoff = getBackoffMs(); + logger.info("Restarting collector after backoff", { backoffMs: backoff } as never); + delay(backoff) + .then(() => { + if (!stopping) spawnChild(); + }) + .catch(() => {}); + } + + function spawnChild(): void { + try { + const handle = spawn(buildCmd()); + child = handle; + logger.info("Collector started"); + handle.exited.then( + (code) => onChildExit(code), + () => {}, + ); + } catch (err) { + logger.warn("Failed to spawn collector", { err } as never); + } + } + + function start(): void { + spawnChild(); + } + + async function stop(): Promise { + stopping = true; + if (!child) return; + + const handle = child; + handle.kill("SIGTERM"); + + let resolved = false; + const exitedPromise = handle.exited.then(() => { + resolved = true; + }); + + const timeoutPromise = delay(STOP_TIMEOUT_MS).then(() => { + if (!resolved) { + handle.kill("SIGKILL"); + return handle.exited; + } + }); + + await Promise.race([exitedPromise, timeoutPromise]); + } + + return { start, stop }; } diff --git a/packages/host-bin/src/config.test.ts b/packages/host-bin/src/config.test.ts index fc74a79..3a9f463 100644 --- a/packages/host-bin/src/config.test.ts +++ b/packages/host-bin/src/config.test.ts @@ -2,96 +2,96 @@ import { describe, expect, it } from "vitest"; import { configMapToAccess, envToConfigMap } from "./config.js"; describe("envToConfigMap", () => { - it("maps DISPATCH_API_KEY to provider.openai-compat.apiKey", () => { - const result = envToConfigMap({ DISPATCH_API_KEY: "sk-test-123" }); - expect(result["provider.openai-compat.apiKey"]).toBe("sk-test-123"); - }); + it("maps DISPATCH_API_KEY to provider.openai-compat.apiKey", () => { + const result = envToConfigMap({ DISPATCH_API_KEY: "sk-test-123" }); + expect(result["provider.openai-compat.apiKey"]).toBe("sk-test-123"); + }); - it("maps DISPATCH_BASE_URL to provider.openai-compat.baseURL", () => { - const result = envToConfigMap({ DISPATCH_BASE_URL: "https://custom.api/v1" }); - expect(result["provider.openai-compat.baseURL"]).toBe("https://custom.api/v1"); - }); + it("maps DISPATCH_BASE_URL to provider.openai-compat.baseURL", () => { + const result = envToConfigMap({ DISPATCH_BASE_URL: "https://custom.api/v1" }); + expect(result["provider.openai-compat.baseURL"]).toBe("https://custom.api/v1"); + }); - it("maps DISPATCH_MODEL to provider.openai-compat.model", () => { - const result = envToConfigMap({ DISPATCH_MODEL: "gpt-4" }); - expect(result["provider.openai-compat.model"]).toBe("gpt-4"); - }); + it("maps DISPATCH_MODEL to provider.openai-compat.model", () => { + const result = envToConfigMap({ DISPATCH_MODEL: "gpt-4" }); + expect(result["provider.openai-compat.model"]).toBe("gpt-4"); + }); - it("maps all three env vars together", () => { - const result = envToConfigMap({ - DISPATCH_API_KEY: "key", - DISPATCH_BASE_URL: "https://api.example.com", - DISPATCH_MODEL: "my-model", - }); - expect(result).toEqual({ - "provider.openai-compat.apiKey": "key", - "provider.openai-compat.baseURL": "https://api.example.com", - "provider.openai-compat.model": "my-model", - }); - }); + it("maps all three env vars together", () => { + const result = envToConfigMap({ + DISPATCH_API_KEY: "key", + DISPATCH_BASE_URL: "https://api.example.com", + DISPATCH_MODEL: "my-model", + }); + expect(result).toEqual({ + "provider.openai-compat.apiKey": "key", + "provider.openai-compat.baseURL": "https://api.example.com", + "provider.openai-compat.model": "my-model", + }); + }); - it("returns empty map when no relevant env vars are set", () => { - const result = envToConfigMap({ HOME: "/home/user", PATH: "/usr/bin" }); - expect(result).toEqual({}); - }); + it("returns empty map when no relevant env vars are set", () => { + const result = envToConfigMap({ HOME: "/home/user", PATH: "/usr/bin" }); + expect(result).toEqual({}); + }); - it("skips undefined env vars", () => { - const result = envToConfigMap({ DISPATCH_API_KEY: undefined }); - expect(result).toEqual({}); - }); + it("skips undefined env vars", () => { + const result = envToConfigMap({ DISPATCH_API_KEY: undefined }); + expect(result).toEqual({}); + }); - it("includes only set vars when some are missing", () => { - const result = envToConfigMap({ - DISPATCH_API_KEY: "key", - DISPATCH_MODEL: undefined, - }); - expect(result).toEqual({ - "provider.openai-compat.apiKey": "key", - }); - expect(result["provider.openai-compat.baseURL"]).toBeUndefined(); - expect(result["provider.openai-compat.model"]).toBeUndefined(); - }); + it("includes only set vars when some are missing", () => { + const result = envToConfigMap({ + DISPATCH_API_KEY: "key", + DISPATCH_MODEL: undefined, + }); + expect(result).toEqual({ + "provider.openai-compat.apiKey": "key", + }); + expect(result["provider.openai-compat.baseURL"]).toBeUndefined(); + expect(result["provider.openai-compat.model"]).toBeUndefined(); + }); - it("maps SURFACE_WS_PORT to surfaceWsPort", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "24206" }); - expect(result.surfaceWsPort).toBe(24206); - }); + it("maps SURFACE_WS_PORT to surfaceWsPort", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "24206" }); + expect(result.surfaceWsPort).toBe(24206); + }); - it("ignores a non-numeric SURFACE_WS_PORT", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "abc" }); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("ignores a non-numeric SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "abc" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); - it("ignores a non-positive SURFACE_WS_PORT", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "0" }); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("ignores a non-positive SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "0" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); - it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => { - const result = envToConfigMap({}); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => { + const result = envToConfigMap({}); + expect(result.surfaceWsPort).toBeUndefined(); + }); }); describe("configMapToAccess", () => { - it("returns value for existing key", () => { - const access = configMapToAccess({ "provider.openai-compat.apiKey": "sk-123" }); - expect(access.get("provider.openai-compat.apiKey")).toBe("sk-123"); - }); + it("returns value for existing key", () => { + const access = configMapToAccess({ "provider.openai-compat.apiKey": "sk-123" }); + expect(access.get("provider.openai-compat.apiKey")).toBe("sk-123"); + }); - it("returns undefined for missing key", () => { - const access = configMapToAccess({}); - expect(access.get("nonexistent")).toBeUndefined(); - }); + it("returns undefined for missing key", () => { + const access = configMapToAccess({}); + expect(access.get("nonexistent")).toBeUndefined(); + }); - it("returns typed value", () => { - const access = configMapToAccess({ "some.number": 42 }); - expect(access.get("some.number")).toBe(42); - }); + it("returns typed value", () => { + const access = configMapToAccess({ "some.number": 42 }); + expect(access.get("some.number")).toBe(42); + }); - it("getAll returns the full map", () => { - const map = { a: 1, b: "two" }; - const access = configMapToAccess(map); - expect(access.getAll()).toEqual(map); - }); + it("getAll returns the full map", () => { + const map = { a: 1, b: "two" }; + const access = configMapToAccess(map); + expect(access.getAll()).toEqual(map); + }); }); diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index 9a22c00..f69d799 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -1,62 +1,62 @@ import type { ConfigAccess } from "@dispatch/kernel"; export function envToConfigMap( - env: Readonly>, + env: Readonly>, ): Record { - const map: Record = {}; - - const apiKey = env.DISPATCH_API_KEY; - if (apiKey !== undefined) { - map["provider.openai-compat.apiKey"] = apiKey; - } - - const baseURL = env.DISPATCH_BASE_URL; - if (baseURL !== undefined) { - map["provider.openai-compat.baseURL"] = baseURL; - } - - const model = env.DISPATCH_MODEL; - if (model !== undefined) { - map["provider.openai-compat.model"] = model; - } - - // Optional settings consumed by external extensions (e.g. the Claude provider). - const anthropicModel = env.DISPATCH_ANTHROPIC_MODEL; - if (anthropicModel !== undefined) { - map["provider.anthropic.model"] = anthropicModel; - } - - const claudeCredentialKey = env.DISPATCH_CLAUDE_CREDENTIAL_KEY; - if (claudeCredentialKey !== undefined) { - map["claude.credentialKey"] = claudeCredentialKey; - } - - const httpPort = env.BACKEND_PORT ?? env.PORT; - if (httpPort !== undefined) { - const n = Number(httpPort); - if (Number.isFinite(n) && n > 0) { - map.httpPort = n; - } - } - - const surfaceWsPort = env.SURFACE_WS_PORT; - if (surfaceWsPort !== undefined) { - const n = Number(surfaceWsPort); - if (Number.isFinite(n) && n > 0) { - map.surfaceWsPort = n; - } - } - - return map; + const map: Record = {}; + + const apiKey = env.DISPATCH_API_KEY; + if (apiKey !== undefined) { + map["provider.openai-compat.apiKey"] = apiKey; + } + + const baseURL = env.DISPATCH_BASE_URL; + if (baseURL !== undefined) { + map["provider.openai-compat.baseURL"] = baseURL; + } + + const model = env.DISPATCH_MODEL; + if (model !== undefined) { + map["provider.openai-compat.model"] = model; + } + + // Optional settings consumed by external extensions (e.g. the Claude provider). + const anthropicModel = env.DISPATCH_ANTHROPIC_MODEL; + if (anthropicModel !== undefined) { + map["provider.anthropic.model"] = anthropicModel; + } + + const claudeCredentialKey = env.DISPATCH_CLAUDE_CREDENTIAL_KEY; + if (claudeCredentialKey !== undefined) { + map["claude.credentialKey"] = claudeCredentialKey; + } + + const httpPort = env.BACKEND_PORT ?? env.PORT; + if (httpPort !== undefined) { + const n = Number(httpPort); + if (Number.isFinite(n) && n > 0) { + map.httpPort = n; + } + } + + const surfaceWsPort = env.SURFACE_WS_PORT; + if (surfaceWsPort !== undefined) { + const n = Number(surfaceWsPort); + if (Number.isFinite(n) && n > 0) { + map.surfaceWsPort = n; + } + } + + return map; } export function configMapToAccess(map: Readonly>): ConfigAccess { - return { - get(key: string): T | undefined { - return map[key] as T | undefined; - }, - getAll(): Readonly> { - return map; - }, - }; + return { + get(key: string): T | undefined { + return map[key] as T | undefined; + }, + getAll(): Readonly> { + return map; + }, + }; } diff --git a/packages/host-bin/src/load-external.test.ts b/packages/host-bin/src/load-external.test.ts index 38e3622..a373e3d 100644 --- a/packages/host-bin/src/load-external.test.ts +++ b/packages/host-bin/src/load-external.test.ts @@ -3,45 +3,45 @@ import { describe, expect, it } from "vitest"; import { loadExternalExtensions } from "./load-external.js"; function makeLogger(): Logger { - const noop = () => {}; - const logger = { - debug: noop, - info: noop, - warn: noop, - error: noop, - child: () => logger, - span: () => { - throw new Error("not used"); - }, - } as unknown as Logger; - return logger; + const noop = () => {}; + const logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, + child: () => logger, + span: () => { + throw new Error("not used"); + }, + } as unknown as Logger; + return logger; } describe("loadExternalExtensions", () => { - it("returns an empty array for no specifiers", async () => { - expect(await loadExternalExtensions([], makeLogger())).toEqual([]); - }); + it("returns an empty array for no specifiers", async () => { + expect(await loadExternalExtensions([], makeLogger())).toEqual([]); + }); - it("loads a real extension module by package name", async () => { - // auth-apikey is a bundled extension that exports `extension`; we use it as - // a stand-in for an external module to exercise the dynamic-import path. - const loaded = await loadExternalExtensions(["@dispatch/auth-apikey"], makeLogger()); - expect(loaded).toHaveLength(1); - expect(loaded[0]?.manifest.id).toBe("auth-apikey"); - }); + it("loads a real extension module by package name", async () => { + // auth-apikey is a bundled extension that exports `extension`; we use it as + // a stand-in for an external module to exercise the dynamic-import path. + const loaded = await loadExternalExtensions(["@dispatch/auth-apikey"], makeLogger()); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.manifest.id).toBe("auth-apikey"); + }); - it("skips a specifier that cannot be imported without throwing", async () => { - const loaded = await loadExternalExtensions( - ["./does-not-exist-xyz.js", "@dispatch/auth-apikey"], - makeLogger(), - ); - // The bad one is skipped; the good one still loads. - expect(loaded.map((e) => e.manifest.id)).toEqual(["auth-apikey"]); - }); + it("skips a specifier that cannot be imported without throwing", async () => { + const loaded = await loadExternalExtensions( + ["./does-not-exist-xyz.js", "@dispatch/auth-apikey"], + makeLogger(), + ); + // The bad one is skipped; the good one still loads. + expect(loaded.map((e) => e.manifest.id)).toEqual(["auth-apikey"]); + }); - it("skips a module that exports no valid extension", async () => { - // `@dispatch/journal-sink` exports factories but no `extension`. - const loaded = await loadExternalExtensions(["@dispatch/journal-sink"], makeLogger()); - expect(loaded).toEqual([]); - }); + it("skips a module that exports no valid extension", async () => { + // `@dispatch/journal-sink` exports factories but no `extension`. + const loaded = await loadExternalExtensions(["@dispatch/journal-sink"], makeLogger()); + expect(loaded).toEqual([]); + }); }); diff --git a/packages/host-bin/src/load-external.ts b/packages/host-bin/src/load-external.ts index 34b8bce..522cff8 100644 --- a/packages/host-bin/src/load-external.ts +++ b/packages/host-bin/src/load-external.ts @@ -16,32 +16,32 @@ import type { Extension, Logger } from "@dispatch/kernel"; * boot (defend faults, not adversaries; never leave the system broken). */ export async function loadExternalExtensions( - specifiers: readonly string[], - logger: Logger, + specifiers: readonly string[], + logger: Logger, ): Promise { - const loaded: Extension[] = []; - for (const spec of specifiers) { - try { - const mod = (await import(spec)) as Record; - const candidate = mod.extension ?? mod.default ?? mod; - if (isExtension(candidate)) { - loaded.push(candidate); - logger.info(`Loaded external extension "${candidate.manifest.id}" from ${spec}`); - } else { - logger.warn(`External module "${spec}" has no valid extension export; skipped`); - } - } catch (err) { - logger.error(`Failed to load external extension "${spec}"; skipped`, { err }); - } - } - return loaded; + const loaded: Extension[] = []; + for (const spec of specifiers) { + try { + const mod = (await import(spec)) as Record; + const candidate = mod.extension ?? mod.default ?? mod; + if (isExtension(candidate)) { + loaded.push(candidate); + logger.info(`Loaded external extension "${candidate.manifest.id}" from ${spec}`); + } else { + logger.warn(`External module "${spec}" has no valid extension export; skipped`); + } + } catch (err) { + logger.error(`Failed to load external extension "${spec}"; skipped`, { err }); + } + } + return loaded; } /** Structural check that a dynamically-imported value is an `Extension`. */ function isExtension(value: unknown): value is Extension { - if (!value || typeof value !== "object") return false; - const e = value as { manifest?: unknown; activate?: unknown }; - if (typeof e.activate !== "function") return false; - const m = e.manifest as { id?: unknown } | undefined; - return !!m && typeof m.id === "string"; + if (!value || typeof value !== "object") return false; + const e = value as { manifest?: unknown; activate?: unknown }; + if (typeof e.activate !== "function") return false; + const m = e.manifest as { id?: unknown } | undefined; + return !!m && typeof m.id === "string"; } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 571628f..6760533 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -7,18 +7,18 @@ import { createCredentialStoreExtension } from "@dispatch/credential-store"; import { createExecBackendExtension } from "@dispatch/exec-backend"; import { createJournalSink } from "@dispatch/journal-sink"; import { - type ConfigAccess, - createBus, - createHost, - createLogger, - type EventsEmitter, - type Extension, - type HostDeps, - type LogDeps, - type PermissionGate, - type ScheduledJob, - type SecretsAccess, - type StorageNamespace, + type ConfigAccess, + createBus, + createHost, + createLogger, + type EventsEmitter, + type Extension, + type HostDeps, + type LogDeps, + type PermissionGate, + type ScheduledJob, + type SecretsAccess, + type StorageNamespace, } from "@dispatch/kernel"; import { extension as lspExt } from "@dispatch/lsp"; import { extension as mcpExt } from "@dispatch/mcp"; @@ -48,188 +48,188 @@ import { configMapToAccess, envToConfigMap } from "./config.js"; import { loadExternalExtensions } from "./load-external.js"; function createEmptySecrets(): SecretsAccess { - return { - get: async () => null, - set: async () => {}, - delete: async () => {}, - }; + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + }; } function createAllowAllPermissions(): PermissionGate { - return { - check: async () => ({ allowed: true }), - }; + return { + check: async () => ({ allowed: true }), + }; } function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } { - return { register: () => {} }; + return { register: () => {} }; } function createNoopEvents(): EventsEmitter { - return { emit: () => {} }; + return { emit: () => {} }; } // Core extensions EXCEPT the credential-store, which is assembled in boot() so // its credential list can include any credentials backed by external providers // (e.g. a `claude` credential once the external Anthropic provider is loaded). const CORE_EXTENSIONS: readonly Extension[] = [ - storageSqliteExt, - conversationStoreExt, - authApikeyExt, - providerOpenaiCompatExt, - providerUmansExt, - // exec-backend must precede the tool extensions that - // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It - // provides the ExecBackendResolver the tools resolve through; placing it - // here keeps the activation DAG honest (it depends only on kernel). - createExecBackendExtension(), - toolEditFileExt, - toolReadFileExt, - toolShellExt, - toolWriteFileExt, - toolWebSearchExt, - toolYoutubeTranscriptExt, - throughputStoreExt, - todoExt, - messageQueueExt, - mcpExt, - sessionOrchestratorExt, - skillsExt, - systemPromptExt, - cacheWarmingExt, - lspExt, - // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote - // exec-backend factory + the ComputerService the HTTP routes delegate to. - // Its lookups are lazy (tool-/request-time), but it is placed after - // exec-backend and the tool extensions (alongside the other standard - // tool-serving extensions) to keep the DAG honest — and before - // transport-http, whose routes consume the ComputerService it provides. - sshExt, - createTransportHttpExtension(), - // Surface extensions — dependency order: surface-registry first, then consumers. - createSurfaceRegistryExtension(), - createTransportWsExtension(), - createLoadedExtensionsExtension(), + storageSqliteExt, + conversationStoreExt, + authApikeyExt, + providerOpenaiCompatExt, + providerUmansExt, + // exec-backend must precede the tool extensions that + // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It + // provides the ExecBackendResolver the tools resolve through; placing it + // here keeps the activation DAG honest (it depends only on kernel). + createExecBackendExtension(), + toolEditFileExt, + toolReadFileExt, + toolShellExt, + toolWriteFileExt, + toolWebSearchExt, + toolYoutubeTranscriptExt, + throughputStoreExt, + todoExt, + messageQueueExt, + mcpExt, + sessionOrchestratorExt, + skillsExt, + systemPromptExt, + cacheWarmingExt, + lspExt, + // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote + // exec-backend factory + the ComputerService the HTTP routes delegate to. + // Its lookups are lazy (tool-/request-time), but it is placed after + // exec-backend and the tool extensions (alongside the other standard + // tool-serving extensions) to keep the DAG honest — and before + // transport-http, whose routes consume the ComputerService it provides. + sshExt, + createTransportHttpExtension(), + // Surface extensions — dependency order: surface-registry first, then consumers. + createSurfaceRegistryExtension(), + createTransportWsExtension(), + createLoadedExtensionsExtension(), ]; /** Parse the comma-separated list of external extension module specifiers. */ function parseExternalSpecifiers(env: Readonly>): string[] { - return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); } async function boot(): Promise { - const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; - mkdirSync(dirname(journalPath), { recursive: true }); - const logSink = createJournalSink({ path: journalPath }); - const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() }; - const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps); - - const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db"; - - // Only start the collector supervisor in dev mode (source files available). - // Compiled binaries don't have the source tree, so the collector can't spawn. - let supervisor: ReturnType | undefined; - if (existsSync("packages/observability-collector/src/main.ts")) { - supervisor = createCollectorSupervisor({ - spawn: (cmd: string[]) => { - const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); - const handle: ChildHandle = { - kill: (signal?: string) => proc.kill(signal as NodeJS.Signals), - exited: proc.exited, - }; - return handle; - }, - journalPath, - dbPath: traceDbPath, - logger: logger.child({ extensionId: "collector-supervisor" }), - }); - supervisor.start(); - } - - const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db"; - mkdirSync(dirname(dbPath), { recursive: true }); - const sqliteBackend = createSqliteStorage({ path: dbPath }); - const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace); - - const configMap = envToConfigMap(process.env as Readonly>); - const config: ConfigAccess = configMapToAccess(configMap); - - const deps: HostDeps = { - logger, - config, - storageFactory, - secrets: createEmptySecrets(), - permissions: createAllowAllPermissions(), - scheduler: createNoopScheduler(), - bus: createBus(logger), - events: createNoopEvents(), - logSink, - logDeps, - }; - - // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. - const externalSpecifiers = parseExternalSpecifiers( - process.env as Readonly>, - ); - const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); - - // Assemble the credential list. MVP keeps the hardcoded `opencode` credential - // and adds a `claude` credential when an external Anthropic provider is loaded. - const credentials = [{ name: "opencode", providerId: "openai-compat" }]; - - // The umans credential is always listed (it's the model-catalog index); the - // provider itself only registers when UMANS_API_KEY is set, so listCatalog - // gracefully skips it when the provider is absent. - if (process.env.UMANS_API_KEY) { - credentials.push({ name: "umans", providerId: "umans" }); - logger.info(`Registered credential "umans" → umans provider`); - } - const hasAnthropic = externalExtensions.some((e) => - e.manifest.contributes?.providers?.includes("anthropic"), - ); - if (hasAnthropic) { - const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; - credentials.push({ name: claudeName, providerId: "anthropic" }); - logger.info(`Registered credential "${claudeName}" → anthropic provider`); - } - - const extensions: Extension[] = [ - ...CORE_EXTENSIONS, - createCredentialStoreExtension({ credentials }), - ...externalExtensions, - ]; - - const host = createHost(extensions, deps); - await host.activate(); - - const disabled = host.getDisabled(); - if (disabled.length > 0) { - for (const d of disabled) { - logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`); - } - } - - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - logger.info("Shutting down — deactivating extensions"); - await host.deactivate(); - logger.info("Draining collector"); - await supervisor?.stop(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - logger.info("Dispatch booted"); - console.info("Dispatch booted"); + const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; + mkdirSync(dirname(journalPath), { recursive: true }); + const logSink = createJournalSink({ path: journalPath }); + const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() }; + const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps); + + const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db"; + + // Only start the collector supervisor in dev mode (source files available). + // Compiled binaries don't have the source tree, so the collector can't spawn. + let supervisor: ReturnType | undefined; + if (existsSync("packages/observability-collector/src/main.ts")) { + supervisor = createCollectorSupervisor({ + spawn: (cmd: string[]) => { + const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); + const handle: ChildHandle = { + kill: (signal?: string) => proc.kill(signal as NodeJS.Signals), + exited: proc.exited, + }; + return handle; + }, + journalPath, + dbPath: traceDbPath, + logger: logger.child({ extensionId: "collector-supervisor" }), + }); + supervisor.start(); + } + + const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db"; + mkdirSync(dirname(dbPath), { recursive: true }); + const sqliteBackend = createSqliteStorage({ path: dbPath }); + const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace); + + const configMap = envToConfigMap(process.env as Readonly>); + const config: ConfigAccess = configMapToAccess(configMap); + + const deps: HostDeps = { + logger, + config, + storageFactory, + secrets: createEmptySecrets(), + permissions: createAllowAllPermissions(), + scheduler: createNoopScheduler(), + bus: createBus(logger), + events: createNoopEvents(), + logSink, + logDeps, + }; + + // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. + const externalSpecifiers = parseExternalSpecifiers( + process.env as Readonly>, + ); + const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); + + // Assemble the credential list. MVP keeps the hardcoded `opencode` credential + // and adds a `claude` credential when an external Anthropic provider is loaded. + const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + + // The umans credential is always listed (it's the model-catalog index); the + // provider itself only registers when UMANS_API_KEY is set, so listCatalog + // gracefully skips it when the provider is absent. + if (process.env.UMANS_API_KEY) { + credentials.push({ name: "umans", providerId: "umans" }); + logger.info(`Registered credential "umans" → umans provider`); + } + const hasAnthropic = externalExtensions.some((e) => + e.manifest.contributes?.providers?.includes("anthropic"), + ); + if (hasAnthropic) { + const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; + credentials.push({ name: claudeName, providerId: "anthropic" }); + logger.info(`Registered credential "${claudeName}" → anthropic provider`); + } + + const extensions: Extension[] = [ + ...CORE_EXTENSIONS, + createCredentialStoreExtension({ credentials }), + ...externalExtensions, + ]; + + const host = createHost(extensions, deps); + await host.activate(); + + const disabled = host.getDisabled(); + if (disabled.length > 0) { + for (const d of disabled) { + logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`); + } + } + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info("Shutting down — deactivating extensions"); + await host.deactivate(); + logger.info("Draining collector"); + await supervisor?.stop(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + logger.info("Dispatch booted"); + console.info("Dispatch booted"); } boot().catch((err) => { - console.error("Fatal boot error:", err); - process.exit(1); + console.error("Fatal boot error:", err); + process.exit(1); }); diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index e445f13..2b1edf5 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -1,65 +1,65 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "composite": true - }, - "include": ["src/**/*.ts"], - "references": [ - { - "path": "../cache-warming" - }, - { - "path": "../exec-backend" - }, - { - "path": "../kernel" - }, - { - "path": "../lsp" - }, - { - "path": "../message-queue" - }, - { - "path": "../skills" - }, - { - "path": "../ssh" - }, - { - "path": "../storage-sqlite" - }, - { - "path": "../surface-loaded-extensions" - }, - { - "path": "../surface-registry" - }, - { - "path": "../system-prompt" - }, - { - "path": "../throughput-store" - }, - { - "path": "../tool-edit-file" - }, - { - "path": "../tool-read-file" - }, - { - "path": "../tool-shell" - }, - { - "path": "../tool-write-file" - }, - { - "path": "../transport-http" - }, - { - "path": "../transport-ws" - } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../cache-warming" + }, + { + "path": "../exec-backend" + }, + { + "path": "../kernel" + }, + { + "path": "../lsp" + }, + { + "path": "../message-queue" + }, + { + "path": "../skills" + }, + { + "path": "../ssh" + }, + { + "path": "../storage-sqlite" + }, + { + "path": "../surface-loaded-extensions" + }, + { + "path": "../surface-registry" + }, + { + "path": "../system-prompt" + }, + { + "path": "../throughput-store" + }, + { + "path": "../tool-edit-file" + }, + { + "path": "../tool-read-file" + }, + { + "path": "../tool-shell" + }, + { + "path": "../tool-write-file" + }, + { + "path": "../transport-http" + }, + { + "path": "../transport-ws" + } + ] } diff --git a/packages/journal-sink/package.json b/packages/journal-sink/package.json index f17d476..534af21 100644 --- a/packages/journal-sink/package.json +++ b/packages/journal-sink/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/journal-sink", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/journal-sink", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/journal-sink/src/journal-sink.test.ts b/packages/journal-sink/src/journal-sink.test.ts index a6531c7..36e4983 100644 --- a/packages/journal-sink/src/journal-sink.test.ts +++ b/packages/journal-sink/src/journal-sink.test.ts @@ -9,123 +9,123 @@ import { createJournalSink, serialize } from "./journal-sink.js"; // --- Fixtures: one per LogRecord variant --- const logRecord: LogRecord = { - kind: "log", - level: "info", - msg: "hello world", - timestamp: 1700000000000, - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - spanId: "span-1", - attributes: { key: "value" }, + kind: "log", + level: "info", + msg: "hello world", + timestamp: 1700000000000, + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + spanId: "span-1", + attributes: { key: "value" }, }; const logRecordMinimal: LogRecord = { - kind: "log", - level: "debug", - msg: "minimal", - timestamp: 1700000000001, - extensionId: "ext-min", + kind: "log", + level: "debug", + msg: "minimal", + timestamp: 1700000000001, + extensionId: "ext-min", }; const spanOpenRecord: LogRecord = { - kind: "span-open", - spanId: "span-2", - name: "provider.request", - timestamp: 1700000000100, - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - parentSpanId: "span-1", - attributes: { model: "gpt-4" }, - links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }], - body: "verbatim request body", + kind: "span-open", + spanId: "span-2", + name: "provider.request", + timestamp: 1700000000100, + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + parentSpanId: "span-1", + attributes: { model: "gpt-4" }, + links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }], + body: "verbatim request body", }; const spanOpenRecordMinimal: LogRecord = { - kind: "span-open", - spanId: "span-3", - name: "tool.call", - timestamp: 1700000000200, - extensionId: "ext-tool", + kind: "span-open", + spanId: "span-3", + name: "tool.call", + timestamp: 1700000000200, + extensionId: "ext-tool", }; const spanCloseRecord: LogRecord = { - kind: "span-close", - spanId: "span-2", - name: "provider.request", - timestamp: 1700000000500, - durationMs: 400, - status: "ok", - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - parentSpanId: "span-1", - attributes: { cacheHit: true }, - links: [{ spanId: "span-0" }], + kind: "span-close", + spanId: "span-2", + name: "provider.request", + timestamp: 1700000000500, + durationMs: 400, + status: "ok", + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + parentSpanId: "span-1", + attributes: { cacheHit: true }, + links: [{ spanId: "span-0" }], }; const spanCloseRecordError: LogRecord = { - kind: "span-close", - spanId: "span-4", - name: "failing-step", - timestamp: 1700000000600, - durationMs: 100, - status: "error", - extensionId: "ext-err", + kind: "span-close", + spanId: "span-4", + name: "failing-step", + timestamp: 1700000000600, + durationMs: 100, + status: "error", + extensionId: "ext-err", }; // --- Pure core: serialize --- describe("serialize", () => { - const allRecords = [ - { name: "log (full)", record: logRecord }, - { name: "log (minimal)", record: logRecordMinimal }, - { name: "span-open (full)", record: spanOpenRecord }, - { name: "span-open (minimal)", record: spanOpenRecordMinimal }, - { name: "span-close (full)", record: spanCloseRecord }, - { name: "span-close (error)", record: spanCloseRecordError }, - ]; - - for (const { name, record } of allRecords) { - it(`produces exactly one NDJSON line for ${name}`, () => { - const line = serialize(record); - expect(line.endsWith("\n")).toBe(true); - expect(line.endsWith("\n\n")).toBe(false); - const parsed = JSON.parse(line); - expect(parsed).toEqual(record); - }); - - it(`round-trips ${name} through JSON.parse`, () => { - const line = serialize(record); - const roundTripped: LogRecord = JSON.parse(line); - expect(roundTripped).toEqual(record); - expect(roundTripped.kind).toBe(record.kind); - }); - } - - it("preserves all LogRecord variant kinds", () => { - expect(JSON.parse(serialize(logRecord)).kind).toBe("log"); - expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open"); - expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close"); - }); - - it("preserves optional fields when present", () => { - const parsed = JSON.parse(serialize(spanOpenRecord)); - expect(parsed.body).toBe("verbatim request body"); - expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]); - expect(parsed.attributes).toEqual({ model: "gpt-4" }); - }); - - it("omits optional fields when absent (no undefined in output)", () => { - const parsed = JSON.parse(serialize(logRecordMinimal)); - expect(parsed.conversationId).toBeUndefined(); - expect(parsed.turnId).toBeUndefined(); - expect(parsed.spanId).toBeUndefined(); - expect(parsed.attributes).toBeUndefined(); - expect(parsed.body).toBeUndefined(); - expect("conversationId" in parsed).toBe(false); - }); + const allRecords = [ + { name: "log (full)", record: logRecord }, + { name: "log (minimal)", record: logRecordMinimal }, + { name: "span-open (full)", record: spanOpenRecord }, + { name: "span-open (minimal)", record: spanOpenRecordMinimal }, + { name: "span-close (full)", record: spanCloseRecord }, + { name: "span-close (error)", record: spanCloseRecordError }, + ]; + + for (const { name, record } of allRecords) { + it(`produces exactly one NDJSON line for ${name}`, () => { + const line = serialize(record); + expect(line.endsWith("\n")).toBe(true); + expect(line.endsWith("\n\n")).toBe(false); + const parsed = JSON.parse(line); + expect(parsed).toEqual(record); + }); + + it(`round-trips ${name} through JSON.parse`, () => { + const line = serialize(record); + const roundTripped: LogRecord = JSON.parse(line); + expect(roundTripped).toEqual(record); + expect(roundTripped.kind).toBe(record.kind); + }); + } + + it("preserves all LogRecord variant kinds", () => { + expect(JSON.parse(serialize(logRecord)).kind).toBe("log"); + expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open"); + expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close"); + }); + + it("preserves optional fields when present", () => { + const parsed = JSON.parse(serialize(spanOpenRecord)); + expect(parsed.body).toBe("verbatim request body"); + expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]); + expect(parsed.attributes).toEqual({ model: "gpt-4" }); + }); + + it("omits optional fields when absent (no undefined in output)", () => { + const parsed = JSON.parse(serialize(logRecordMinimal)); + expect(parsed.conversationId).toBeUndefined(); + expect(parsed.turnId).toBeUndefined(); + expect(parsed.spanId).toBeUndefined(); + expect(parsed.attributes).toBeUndefined(); + expect(parsed.body).toBeUndefined(); + expect("conversationId" in parsed).toBe(false); + }); }); // --- Imperative shell: createJournalSink (fs integration) --- @@ -133,177 +133,177 @@ describe("serialize", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-")); + tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-")); }); afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); + await rm(tmpDir, { recursive: true, force: true }); }); describe("createJournalSink", () => { - it("emits records that can be read back as NDJSON", async () => { - const path = join(tmpDir, "journal.log"); - const sink = createJournalSink({ path, fsync: "none" }); - - const records = [logRecord, spanOpenRecord, spanCloseRecord]; - for (const r of records) { - sink.emit(r); - } - - const content = await readFile(path, "utf8"); - const lines = content.split("\n").filter((l) => l.length > 0); - expect(lines).toHaveLength(3); - - for (let i = 0; i < lines.length; i++) { - const parsed: LogRecord = JSON.parse(lines[i] ?? ""); - expect(parsed).toEqual(records[i]); - } - }); - - it("warns and does NOT throw when writing to a bad path", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const badPath = join(tmpDir, "nonexistent", "deep", "journal.log"); - const sink = createJournalSink({ path: badPath, fsync: "none" }); - - expect(() => sink.emit(logRecord)).not.toThrow(); - expect(warnSpy).toHaveBeenCalled(); - expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]"); - - warnSpy.mockRestore(); - }); - - it("appends to existing file on creation", async () => { - const path = join(tmpDir, "journal.log"); - const { writeFileSync } = await import("node:fs"); - writeFileSync(path, serialize(logRecord)); - - const sink = createJournalSink({ path, fsync: "none" }); - sink.emit(spanOpenRecord); - - const content = await readFile(path, "utf8"); - const lines = content.split("\n").filter((l) => l.length > 0); - expect(lines).toHaveLength(2); - expect(JSON.parse(lines[0] ?? "").kind).toBe("log"); - expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open"); - }); - - it("warns and drops records when fs.write throws mid-emit", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const brokenFs: FsOps = { - open: () => 1, - write: () => { - throw new Error("disk full"); - }, - close: () => {}, - rename: () => {}, - statSize: () => 0, - fsync: () => {}, - }; - const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" }); - - expect(() => sink.emit(logRecord)).not.toThrow(); - expect(warnSpy).toHaveBeenCalled(); - expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error); - - warnSpy.mockRestore(); - }); + it("emits records that can be read back as NDJSON", async () => { + const path = join(tmpDir, "journal.log"); + const sink = createJournalSink({ path, fsync: "none" }); + + const records = [logRecord, spanOpenRecord, spanCloseRecord]; + for (const r of records) { + sink.emit(r); + } + + const content = await readFile(path, "utf8"); + const lines = content.split("\n").filter((l) => l.length > 0); + expect(lines).toHaveLength(3); + + for (let i = 0; i < lines.length; i++) { + const parsed: LogRecord = JSON.parse(lines[i] ?? ""); + expect(parsed).toEqual(records[i]); + } + }); + + it("warns and does NOT throw when writing to a bad path", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const badPath = join(tmpDir, "nonexistent", "deep", "journal.log"); + const sink = createJournalSink({ path: badPath, fsync: "none" }); + + expect(() => sink.emit(logRecord)).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]"); + + warnSpy.mockRestore(); + }); + + it("appends to existing file on creation", async () => { + const path = join(tmpDir, "journal.log"); + const { writeFileSync } = await import("node:fs"); + writeFileSync(path, serialize(logRecord)); + + const sink = createJournalSink({ path, fsync: "none" }); + sink.emit(spanOpenRecord); + + const content = await readFile(path, "utf8"); + const lines = content.split("\n").filter((l) => l.length > 0); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? "").kind).toBe("log"); + expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open"); + }); + + it("warns and drops records when fs.write throws mid-emit", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const brokenFs: FsOps = { + open: () => 1, + write: () => { + throw new Error("disk full"); + }, + close: () => {}, + rename: () => {}, + statSize: () => 0, + fsync: () => {}, + }; + const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" }); + + expect(() => sink.emit(logRecord)).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error); + + warnSpy.mockRestore(); + }); }); // --- Rotation --- describe("rotation", () => { - it("rotates when file exceeds maxBytes", async () => { - const path = join(tmpDir, "journal.log"); - const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" }); - - sink.emit(logRecord); - sink.emit(logRecordMinimal); - sink.emit(spanOpenRecord); - - const content = await readFile(path, "utf8"); - const lines = content.split("\n").filter((l) => l.length > 0); - expect(lines.length).toBeGreaterThan(0); - - let rotatedExists = false; - try { - await stat(`${path}.1`); - rotatedExists = true; - } catch { - // May not exist if rotation hasn't triggered yet. - } - expect(rotatedExists).toBe(true); - - const rotatedContent = await readFile(`${path}.1`, "utf8"); - const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines]; - for (const line of allLines) { - const parsed = JSON.parse(line); - expect(["log", "span-open", "span-close"]).toContain(parsed.kind); - } - }); + it("rotates when file exceeds maxBytes", async () => { + const path = join(tmpDir, "journal.log"); + const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" }); + + sink.emit(logRecord); + sink.emit(logRecordMinimal); + sink.emit(spanOpenRecord); + + const content = await readFile(path, "utf8"); + const lines = content.split("\n").filter((l) => l.length > 0); + expect(lines.length).toBeGreaterThan(0); + + let rotatedExists = false; + try { + await stat(`${path}.1`); + rotatedExists = true; + } catch { + // May not exist if rotation hasn't triggered yet. + } + expect(rotatedExists).toBe(true); + + const rotatedContent = await readFile(`${path}.1`, "utf8"); + const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines]; + for (const line of allLines) { + const parsed = JSON.parse(line); + expect(["log", "span-open", "span-close"]).toContain(parsed.kind); + } + }); }); // --- Fsync --- describe("fsync", () => { - it("calls fsync periodically when mode is periodic", () => { - let fsyncCalls = 0; - let currentTime = 0; - const mockFs: FsOps = { - open: () => 1, - write: () => {}, - close: () => {}, - rename: () => {}, - statSize: () => 0, - fsync: () => { - fsyncCalls++; - }, - }; - const mockClock: ClockOps = { - now: () => currentTime, - }; - const sink = createJournalSink({ - path: join(tmpDir, "j.log"), - fs: mockFs, - clock: mockClock, - fsync: "periodic", - }); - - sink.emit(logRecord); - expect(fsyncCalls).toBe(0); - - currentTime = 6_000; - sink.emit(logRecordMinimal); - expect(fsyncCalls).toBe(1); - - sink.emit(spanOpenRecord); - expect(fsyncCalls).toBe(1); - - currentTime = 12_000; - sink.emit(spanCloseRecord); - expect(fsyncCalls).toBe(2); - }); - - it("never calls fsync when mode is none", () => { - let fsyncCalls = 0; - const mockFs: FsOps = { - open: () => 1, - write: () => {}, - close: () => {}, - rename: () => {}, - statSize: () => 0, - fsync: () => { - fsyncCalls++; - }, - }; - const sink = createJournalSink({ - path: join(tmpDir, "j.log"), - fs: mockFs, - fsync: "none", - }); - - sink.emit(logRecord); - sink.emit(logRecord); - sink.emit(logRecord); - expect(fsyncCalls).toBe(0); - }); + it("calls fsync periodically when mode is periodic", () => { + let fsyncCalls = 0; + let currentTime = 0; + const mockFs: FsOps = { + open: () => 1, + write: () => {}, + close: () => {}, + rename: () => {}, + statSize: () => 0, + fsync: () => { + fsyncCalls++; + }, + }; + const mockClock: ClockOps = { + now: () => currentTime, + }; + const sink = createJournalSink({ + path: join(tmpDir, "j.log"), + fs: mockFs, + clock: mockClock, + fsync: "periodic", + }); + + sink.emit(logRecord); + expect(fsyncCalls).toBe(0); + + currentTime = 6_000; + sink.emit(logRecordMinimal); + expect(fsyncCalls).toBe(1); + + sink.emit(spanOpenRecord); + expect(fsyncCalls).toBe(1); + + currentTime = 12_000; + sink.emit(spanCloseRecord); + expect(fsyncCalls).toBe(2); + }); + + it("never calls fsync when mode is none", () => { + let fsyncCalls = 0; + const mockFs: FsOps = { + open: () => 1, + write: () => {}, + close: () => {}, + rename: () => {}, + statSize: () => 0, + fsync: () => { + fsyncCalls++; + }, + }; + const sink = createJournalSink({ + path: join(tmpDir, "j.log"), + fs: mockFs, + fsync: "none", + }); + + sink.emit(logRecord); + sink.emit(logRecord); + sink.emit(logRecord); + expect(fsyncCalls).toBe(0); + }); }); diff --git a/packages/journal-sink/src/journal-sink.ts b/packages/journal-sink/src/journal-sink.ts index 8e36fd6..072a013 100644 --- a/packages/journal-sink/src/journal-sink.ts +++ b/packages/journal-sink/src/journal-sink.ts @@ -8,32 +8,32 @@ import type { LogRecord, LogSink } from "@dispatch/kernel"; * Pure function — no I/O, no side effects. */ export function serialize(record: LogRecord): string { - return `${JSON.stringify(record)}\n`; + return `${JSON.stringify(record)}\n`; } // --- Imperative shell (fs edge) --- /** Injectable fs operations — confined to the edge. */ export interface FsOps { - readonly open: (path: string) => number; - readonly write: (fd: number, data: string) => void; - readonly close: (fd: number) => void; - readonly rename: (oldPath: string, newPath: string) => void; - readonly statSize: (path: string) => number; - readonly fsync: (fd: number) => void; + readonly open: (path: string) => number; + readonly write: (fd: number, data: string) => void; + readonly close: (fd: number) => void; + readonly rename: (oldPath: string, newPath: string) => void; + readonly statSize: (path: string) => number; + readonly fsync: (fd: number) => void; } /** Clock injection for fsync interval. */ export interface ClockOps { - readonly now: () => number; + readonly now: () => number; } export interface JournalSinkOpts { - readonly path: string; - readonly maxBytes?: number; - readonly fsync?: "periodic" | "none"; - readonly fs?: FsOps; - readonly clock?: ClockOps; + readonly path: string; + readonly maxBytes?: number; + readonly fsync?: "periodic" | "none"; + readonly fs?: FsOps; + readonly clock?: ClockOps; } const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB @@ -45,127 +45,127 @@ const FSYNC_INTERVAL_MS = 5_000; // 5 seconds * Rotates when file exceeds maxBytes (rename → .1, reopen fresh). */ export function createJournalSink(opts: JournalSinkOpts): LogSink { - const filePath = opts.path; - const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; - const syncMode = opts.fsync ?? "periodic"; - const fs = opts.fs ?? createDefaultFsOps(); - const clock = opts.clock ?? { now: () => Date.now() }; - - const NO_FD = -1; - let fd: number; - let bytesWritten: number; - try { - fd = fs.open(filePath); - bytesWritten = fs.statSize(filePath); - } catch { - fd = NO_FD; - bytesWritten = 0; - } - let lastFsyncAt = clock.now(); - - function tryOpen(): boolean { - if (fd !== NO_FD) return true; - try { - fd = fs.open(filePath); - bytesWritten = 0; - return true; - } catch { - return false; - } - } - - function rotate(): void { - if (fd !== NO_FD) { - try { - fs.close(fd); - } catch { - // Ignore close errors during rotation. - } - } - const rotatedPath = `${filePath}.1`; - try { - fs.rename(filePath, rotatedPath); - } catch { - // If rename fails, just truncate by reopening. - } - try { - fd = fs.open(filePath); - } catch { - fd = NO_FD; - } - bytesWritten = 0; - } - - function maybeFsync(): void { - if (syncMode !== "periodic" || fd === NO_FD) return; - const now = clock.now(); - if (now - lastFsyncAt >= FSYNC_INTERVAL_MS) { - try { - fs.fsync(fd); - } catch { - // Swallow — fail-safe. - } - lastFsyncAt = now; - } - } - - const sink: LogSink = { - emit(record: LogRecord): void { - try { - if (!tryOpen()) { - console.warn("[journal-sink] cannot open journal, dropping record"); - return; - } - - const line = serialize(record); - const lineBytes = Buffer.byteLength(line, "utf8"); - - if (bytesWritten + lineBytes > maxBytes) { - rotate(); - if (fd === NO_FD) { - console.warn("[journal-sink] rotation failed, dropping record"); - return; - } - } - - fs.write(fd, line); - bytesWritten += lineBytes; - maybeFsync(); - } catch (err) { - // Fail-safe: drop + warn, never throw to the caller (D3/D7). - console.warn("[journal-sink] write failed, dropping record:", err); - } - }, - }; - - return sink; + const filePath = opts.path; + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + const syncMode = opts.fsync ?? "periodic"; + const fs = opts.fs ?? createDefaultFsOps(); + const clock = opts.clock ?? { now: () => Date.now() }; + + const NO_FD = -1; + let fd: number; + let bytesWritten: number; + try { + fd = fs.open(filePath); + bytesWritten = fs.statSize(filePath); + } catch { + fd = NO_FD; + bytesWritten = 0; + } + let lastFsyncAt = clock.now(); + + function tryOpen(): boolean { + if (fd !== NO_FD) return true; + try { + fd = fs.open(filePath); + bytesWritten = 0; + return true; + } catch { + return false; + } + } + + function rotate(): void { + if (fd !== NO_FD) { + try { + fs.close(fd); + } catch { + // Ignore close errors during rotation. + } + } + const rotatedPath = `${filePath}.1`; + try { + fs.rename(filePath, rotatedPath); + } catch { + // If rename fails, just truncate by reopening. + } + try { + fd = fs.open(filePath); + } catch { + fd = NO_FD; + } + bytesWritten = 0; + } + + function maybeFsync(): void { + if (syncMode !== "periodic" || fd === NO_FD) return; + const now = clock.now(); + if (now - lastFsyncAt >= FSYNC_INTERVAL_MS) { + try { + fs.fsync(fd); + } catch { + // Swallow — fail-safe. + } + lastFsyncAt = now; + } + } + + const sink: LogSink = { + emit(record: LogRecord): void { + try { + if (!tryOpen()) { + console.warn("[journal-sink] cannot open journal, dropping record"); + return; + } + + const line = serialize(record); + const lineBytes = Buffer.byteLength(line, "utf8"); + + if (bytesWritten + lineBytes > maxBytes) { + rotate(); + if (fd === NO_FD) { + console.warn("[journal-sink] rotation failed, dropping record"); + return; + } + } + + fs.write(fd, line); + bytesWritten += lineBytes; + maybeFsync(); + } catch (err) { + // Fail-safe: drop + warn, never throw to the caller (D3/D7). + console.warn("[journal-sink] write failed, dropping record:", err); + } + }, + }; + + return sink; } // --- Default fs ops (real Bun/Node I/O) --- function createDefaultFsOps(): FsOps { - return { - open(path: string): number { - return openSync(path, "a"); - }, - write(fd: number, data: string): void { - writeSync(fd, data); - }, - close(fd: number): void { - closeSync(fd); - }, - rename(oldPath: string, newPath: string): void { - renameSync(oldPath, newPath); - }, - statSize(path: string): number { - try { - return statSync(path).size; - } catch { - return 0; - } - }, - fsync(fd: number): void { - fsyncSync(fd); - }, - }; + return { + open(path: string): number { + return openSync(path, "a"); + }, + write(fd: number, data: string): void { + writeSync(fd, data); + }, + close(fd: number): void { + closeSync(fd); + }, + rename(oldPath: string, newPath: string): void { + renameSync(oldPath, newPath); + }, + statSize(path: string): number { + try { + return statSync(path).size; + } catch { + return 0; + } + }, + fsync(fd: number): void { + fsyncSync(fd); + }, + }; } diff --git a/packages/journal-sink/tsconfig.json b/packages/journal-sink/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/journal-sink/tsconfig.json +++ b/packages/journal-sink/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/kernel/package.json b/packages/kernel/package.json index d8d55a7..cceff87 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/kernel", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/kernel", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/kernel/src/bus/bus.test.ts b/packages/kernel/src/bus/bus.test.ts index 05cf875..9310e1f 100644 --- a/packages/kernel/src/bus/bus.test.ts +++ b/packages/kernel/src/bus/bus.test.ts @@ -5,376 +5,376 @@ import { type Bus, createBus } from "./bus.js"; import { applyFilterChain, dispatchEventSync, sortFilters } from "./pure.js"; interface FakeLogger extends Logger { - readonly errors: Array<{ message: string; args: unknown[] }>; + readonly errors: Array<{ message: string; args: unknown[] }>; } function createFakeLogger(): FakeLogger { - const errors: Array<{ message: string; args: unknown[] }> = []; - const logger: FakeLogger = { - errors, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: (message, attrs) => { - errors.push({ message, args: attrs === undefined ? [] : [attrs] }); - }, - child: () => logger, - span: () => makeNoopSpan(logger), - }; - return logger; + const errors: Array<{ message: string; args: unknown[] }> = []; + const logger: FakeLogger = { + errors, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message, attrs) => { + errors.push({ message, args: attrs === undefined ? [] : [attrs] }); + }, + child: () => logger, + span: () => makeNoopSpan(logger), + }; + return logger; } function makeNoopSpan(log: Logger): Span { - const span: Span = { - id: "noop", - log, - setAttributes: () => {}, - addLink: () => {}, - child: () => span, - end: () => {}, - }; - return span; + const span: Span = { + id: "noop", + log, + setAttributes: () => {}, + addLink: () => {}, + child: () => span, + end: () => {}, + }; + return span; } describe("event hooks", () => { - let logger: FakeLogger; - let bus: Bus; - - beforeEach(() => { - logger = createFakeLogger(); - bus = createBus(logger); - }); - - it("fires all registered listeners", () => { - const hook = defineEventHook<{ value: number }>("test/event"); - const received: number[] = []; - - bus.on(hook, (payload) => { - received.push(payload.value); - }); - bus.on(hook, (payload) => { - received.push(payload.value * 10); - }); - - bus.emit(hook, { value: 3 }); - - expect(received).toEqual([3, 30]); - }); - - it("isolates a throwing listener (others still run, error logged)", () => { - const hook = defineEventHook("test/isolate"); - const received: string[] = []; - - bus.on(hook, () => { - throw new Error("boom"); - }); - bus.on(hook, (payload) => { - received.push(payload); - }); - - bus.emit(hook, "hello"); - - expect(received).toEqual(["hello"]); - expect(logger.errors).toHaveLength(1); - expect(logger.errors[0]?.message).toContain("test/isolate"); - }); - - it("isolates an async handler rejection", async () => { - const hook = defineEventHook("test/async-reject"); - const received: string[] = []; - - bus.on(hook, async () => { - throw new Error("async boom"); - }); - bus.on(hook, async (payload) => { - received.push(payload); - }); - - bus.emit(hook, "data"); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(received).toEqual(["data"]); - expect(logger.errors).toHaveLength(1); - expect(logger.errors[0]?.message).toContain("test/async-reject"); - }); - - it("unsubscribe removes the handler", () => { - const hook = defineEventHook("test/unsub"); - let count = 0; - - const unsub = bus.on(hook, () => { - count++; - }); - - bus.emit(hook, undefined); - expect(count).toBe(1); - - unsub(); - bus.emit(hook, undefined); - expect(count).toBe(1); - }); - - it("emit with no handlers is a no-op", () => { - const hook = defineEventHook("test/empty"); - expect(() => bus.emit(hook, "nothing")).not.toThrow(); - }); - - it("emitAsync awaits all handlers", async () => { - const hook = defineEventHook("test/async"); - const received: number[] = []; - - bus.on(hook, async (payload) => { - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - received.push(payload); - }); - bus.on(hook, async (payload) => { - received.push(payload * 2); - }); - - await bus.emitAsync(hook, 5); - - expect(received).toEqual([10, 5]); - }); - - it("emitAsync respects timeout", async () => { - const hook = defineEventHook("test/timeout"); - let completed = false; - - bus.on(hook, async () => { - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - completed = true; - }); - - await bus.emitAsync(hook, undefined, 10); - - expect(completed).toBe(false); - }); + let logger: FakeLogger; + let bus: Bus; + + beforeEach(() => { + logger = createFakeLogger(); + bus = createBus(logger); + }); + + it("fires all registered listeners", () => { + const hook = defineEventHook<{ value: number }>("test/event"); + const received: number[] = []; + + bus.on(hook, (payload) => { + received.push(payload.value); + }); + bus.on(hook, (payload) => { + received.push(payload.value * 10); + }); + + bus.emit(hook, { value: 3 }); + + expect(received).toEqual([3, 30]); + }); + + it("isolates a throwing listener (others still run, error logged)", () => { + const hook = defineEventHook("test/isolate"); + const received: string[] = []; + + bus.on(hook, () => { + throw new Error("boom"); + }); + bus.on(hook, (payload) => { + received.push(payload); + }); + + bus.emit(hook, "hello"); + + expect(received).toEqual(["hello"]); + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0]?.message).toContain("test/isolate"); + }); + + it("isolates an async handler rejection", async () => { + const hook = defineEventHook("test/async-reject"); + const received: string[] = []; + + bus.on(hook, async () => { + throw new Error("async boom"); + }); + bus.on(hook, async (payload) => { + received.push(payload); + }); + + bus.emit(hook, "data"); + + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + expect(received).toEqual(["data"]); + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0]?.message).toContain("test/async-reject"); + }); + + it("unsubscribe removes the handler", () => { + const hook = defineEventHook("test/unsub"); + let count = 0; + + const unsub = bus.on(hook, () => { + count++; + }); + + bus.emit(hook, undefined); + expect(count).toBe(1); + + unsub(); + bus.emit(hook, undefined); + expect(count).toBe(1); + }); + + it("emit with no handlers is a no-op", () => { + const hook = defineEventHook("test/empty"); + expect(() => bus.emit(hook, "nothing")).not.toThrow(); + }); + + it("emitAsync awaits all handlers", async () => { + const hook = defineEventHook("test/async"); + const received: number[] = []; + + bus.on(hook, async (payload) => { + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + received.push(payload); + }); + bus.on(hook, async (payload) => { + received.push(payload * 2); + }); + + await bus.emitAsync(hook, 5); + + expect(received).toEqual([10, 5]); + }); + + it("emitAsync respects timeout", async () => { + const hook = defineEventHook("test/timeout"); + let completed = false; + + bus.on(hook, async () => { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + completed = true; + }); + + await bus.emitAsync(hook, undefined, 10); + + expect(completed).toBe(false); + }); }); describe("filter hooks", () => { - let logger: FakeLogger; - let bus: Bus; + let logger: FakeLogger; + let bus: Bus; - beforeEach(() => { - logger = createFakeLogger(); - bus = createBus(logger); - }); + beforeEach(() => { + logger = createFakeLogger(); + bus = createBus(logger); + }); - it("chains filters in registration order", async () => { - const hook = defineFilter("test/chain"); + it("chains filters in registration order", async () => { + const hook = defineFilter("test/chain"); - bus.addFilter(hook, (value) => `${value}-a`); - bus.addFilter(hook, (value) => `${value}-b`); + bus.addFilter(hook, (value) => `${value}-a`); + bus.addFilter(hook, (value) => `${value}-b`); - const result = await bus.applyFilters(hook, "start"); - expect(result).toBe("start-a-b"); - }); + const result = await bus.applyFilters(hook, "start"); + expect(result).toBe("start-a-b"); + }); - it("respects priority ordering (lower runs first)", async () => { - const hook = defineFilter("test/priority"); + it("respects priority ordering (lower runs first)", async () => { + const hook = defineFilter("test/priority"); - bus.addFilter(hook, (value) => `${value}-second`); - bus.addFilter(hook, (value) => `${value}-first`, { priority: -1 }); + bus.addFilter(hook, (value) => `${value}-second`); + bus.addFilter(hook, (value) => `${value}-first`, { priority: -1 }); - const result = await bus.applyFilters(hook, "start"); - expect(result).toBe("start-first-second"); - }); + const result = await bus.applyFilters(hook, "start"); + expect(result).toBe("start-first-second"); + }); - it("fail-open passes value through on throw", async () => { - const hook = defineFilter("test/fail-open"); + it("fail-open passes value through on throw", async () => { + const hook = defineFilter("test/fail-open"); - bus.addFilter(hook, (value) => value + 1); - bus.addFilter(hook, () => { - throw new Error("filter boom"); - }); - bus.addFilter(hook, (value) => value * 2); + bus.addFilter(hook, (value) => value + 1); + bus.addFilter(hook, () => { + throw new Error("filter boom"); + }); + bus.addFilter(hook, (value) => value * 2); - const result = await bus.applyFilters(hook, 5); - expect(result).toBe(12); - expect(logger.errors).toHaveLength(1); - expect(logger.errors[0]?.message).toContain("test/fail-open"); - }); + const result = await bus.applyFilters(hook, 5); + expect(result).toBe(12); + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0]?.message).toContain("test/fail-open"); + }); - it("fail-closed propagates the error", async () => { - const hook = defineFilter("test/fail-closed"); + it("fail-closed propagates the error", async () => { + const hook = defineFilter("test/fail-closed"); - bus.addFilter(hook, () => { - throw new Error("closed boom"); - }); + bus.addFilter(hook, () => { + throw new Error("closed boom"); + }); - await expect(bus.applyFilters(hook, 5, { failClosed: true })).rejects.toThrow("closed boom"); - }); + await expect(bus.applyFilters(hook, 5, { failClosed: true })).rejects.toThrow("closed boom"); + }); - it("applyFilters with no filters returns value unchanged", async () => { - const hook = defineFilter("test/no-filters"); - const result = await bus.applyFilters(hook, "unchanged"); - expect(result).toBe("unchanged"); - }); + it("applyFilters with no filters returns value unchanged", async () => { + const hook = defineFilter("test/no-filters"); + const result = await bus.applyFilters(hook, "unchanged"); + expect(result).toBe("unchanged"); + }); - it("unsubscribe removes a filter from the chain", async () => { - const hook = defineFilter("test/filter-unsub"); + it("unsubscribe removes a filter from the chain", async () => { + const hook = defineFilter("test/filter-unsub"); - const unsub = bus.addFilter(hook, (value) => `${value}-removed`); - bus.addFilter(hook, (value) => `${value}-kept`); + const unsub = bus.addFilter(hook, (value) => `${value}-removed`); + bus.addFilter(hook, (value) => `${value}-kept`); - unsub(); + unsub(); - const result = await bus.applyFilters(hook, "start"); - expect(result).toBe("start-kept"); - }); + const result = await bus.applyFilters(hook, "start"); + expect(result).toBe("start-kept"); + }); }); describe("services", () => { - let logger: FakeLogger; - let bus: Bus; + let logger: FakeLogger; + let bus: Bus; - beforeEach(() => { - logger = createFakeLogger(); - bus = createBus(logger); - }); + beforeEach(() => { + logger = createFakeLogger(); + bus = createBus(logger); + }); - it("provide and get round-trips", () => { - const handle = defineService<{ greet: (name: string) => string }>("test/service"); - const impl = { greet: (name: string) => `hello ${name}` }; + it("provide and get round-trips", () => { + const handle = defineService<{ greet: (name: string) => string }>("test/service"); + const impl = { greet: (name: string) => `hello ${name}` }; - bus.provideService(handle, impl); - const retrieved = bus.getService(handle); + bus.provideService(handle, impl); + const retrieved = bus.getService(handle); - expect(retrieved.greet("world")).toBe("hello world"); - }); + expect(retrieved.greet("world")).toBe("hello world"); + }); - it("getService on missing service throws", () => { - const handle = defineService("test/missing"); - expect(() => bus.getService(handle)).toThrow("test/missing"); - }); + it("getService on missing service throws", () => { + const handle = defineService("test/missing"); + expect(() => bus.getService(handle)).toThrow("test/missing"); + }); - it("double-provide throws", () => { - const handle = defineService("test/double"); + it("double-provide throws", () => { + const handle = defineService("test/double"); - bus.provideService(handle, 1); - expect(() => bus.provideService(handle, 2)).toThrow("test/double"); - }); + bus.provideService(handle, 1); + expect(() => bus.provideService(handle, 2)).toThrow("test/double"); + }); }); describe("pure functions", () => { - describe("dispatchEventSync", () => { - it("calls all handlers with the payload", () => { - const logger = createFakeLogger(); - const received: number[] = []; - - dispatchEventSync( - [ - (payload) => { - received.push(payload); - }, - (payload) => { - received.push(payload * 2); - }, - ], - 5, - logger, - "test", - ); - - expect(received).toEqual([5, 10]); - }); - - it("catches sync throws and logs them", () => { - const logger = createFakeLogger(); - const received: number[] = []; - - dispatchEventSync( - [ - () => { - throw new Error("sync boom"); - }, - (payload) => { - received.push(payload); - }, - ], - 42, - logger, - "test/sync", - ); - - expect(received).toEqual([42]); - expect(logger.errors).toHaveLength(1); - }); - }); - - describe("sortFilters", () => { - it("sorts by priority ascending, then by order ascending", () => { - const entries = [ - { fn: async (v: number) => v, priority: 10, order: 0 }, - { fn: async (v: number) => v, priority: -1, order: 1 }, - { fn: async (v: number) => v, priority: 10, order: 2 }, - { fn: async (v: number) => v, priority: 0, order: 3 }, - ]; - - const sorted = sortFilters(entries); - expect(sorted.map((e) => e.order)).toEqual([1, 3, 0, 2]); - }); - - it("preserves registration order when priorities are equal", () => { - const entries = [ - { fn: async (v: string) => v, priority: 0, order: 0 }, - { fn: async (v: string) => v, priority: 0, order: 1 }, - { fn: async (v: string) => v, priority: 0, order: 2 }, - ]; - - const sorted = sortFilters(entries); - expect(sorted.map((e) => e.order)).toEqual([0, 1, 2]); - }); - }); - - describe("applyFilterChain", () => { - it("applies filters in order", async () => { - const logger = createFakeLogger(); - const result = await applyFilterChain([(v) => v + 1, (v) => v * 3], 2, logger, "test", false); - expect(result).toBe(9); - }); - - it("fail-open skips the throwing filter", async () => { - const logger = createFakeLogger(); - const result = await applyFilterChain( - [ - (v) => v + 10, - () => { - throw new Error("skip me"); - }, - (v) => v + 1, - ], - 0, - logger, - "test", - false, - ); - expect(result).toBe(11); - expect(logger.errors).toHaveLength(1); - }); - - it("fail-closed throws on error", async () => { - const logger = createFakeLogger(); - await expect( - applyFilterChain( - [ - () => { - throw new Error("closed"); - }, - ], - 0, - logger, - "test", - true, - ), - ).rejects.toThrow("closed"); - }); - }); + describe("dispatchEventSync", () => { + it("calls all handlers with the payload", () => { + const logger = createFakeLogger(); + const received: number[] = []; + + dispatchEventSync( + [ + (payload) => { + received.push(payload); + }, + (payload) => { + received.push(payload * 2); + }, + ], + 5, + logger, + "test", + ); + + expect(received).toEqual([5, 10]); + }); + + it("catches sync throws and logs them", () => { + const logger = createFakeLogger(); + const received: number[] = []; + + dispatchEventSync( + [ + () => { + throw new Error("sync boom"); + }, + (payload) => { + received.push(payload); + }, + ], + 42, + logger, + "test/sync", + ); + + expect(received).toEqual([42]); + expect(logger.errors).toHaveLength(1); + }); + }); + + describe("sortFilters", () => { + it("sorts by priority ascending, then by order ascending", () => { + const entries = [ + { fn: async (v: number) => v, priority: 10, order: 0 }, + { fn: async (v: number) => v, priority: -1, order: 1 }, + { fn: async (v: number) => v, priority: 10, order: 2 }, + { fn: async (v: number) => v, priority: 0, order: 3 }, + ]; + + const sorted = sortFilters(entries); + expect(sorted.map((e) => e.order)).toEqual([1, 3, 0, 2]); + }); + + it("preserves registration order when priorities are equal", () => { + const entries = [ + { fn: async (v: string) => v, priority: 0, order: 0 }, + { fn: async (v: string) => v, priority: 0, order: 1 }, + { fn: async (v: string) => v, priority: 0, order: 2 }, + ]; + + const sorted = sortFilters(entries); + expect(sorted.map((e) => e.order)).toEqual([0, 1, 2]); + }); + }); + + describe("applyFilterChain", () => { + it("applies filters in order", async () => { + const logger = createFakeLogger(); + const result = await applyFilterChain([(v) => v + 1, (v) => v * 3], 2, logger, "test", false); + expect(result).toBe(9); + }); + + it("fail-open skips the throwing filter", async () => { + const logger = createFakeLogger(); + const result = await applyFilterChain( + [ + (v) => v + 10, + () => { + throw new Error("skip me"); + }, + (v) => v + 1, + ], + 0, + logger, + "test", + false, + ); + expect(result).toBe(11); + expect(logger.errors).toHaveLength(1); + }); + + it("fail-closed throws on error", async () => { + const logger = createFakeLogger(); + await expect( + applyFilterChain( + [ + () => { + throw new Error("closed"); + }, + ], + 0, + logger, + "test", + true, + ), + ).rejects.toThrow("closed"); + }); + }); }); diff --git a/packages/kernel/src/bus/bus.ts b/packages/kernel/src/bus/bus.ts index 03d692e..013d426 100644 --- a/packages/kernel/src/bus/bus.ts +++ b/packages/kernel/src/bus/bus.ts @@ -1,139 +1,139 @@ import type { Logger } from "../contracts/extension.js"; import type { - EventHandler, - EventHookDescriptor, - FilterDescriptor, - FilterHandler, - ServiceHandle, + EventHandler, + EventHookDescriptor, + FilterDescriptor, + FilterHandler, + ServiceHandle, } from "../contracts/hooks.js"; import { - applyFilterChain, - dispatchEventAsync, - dispatchEventSync, - type FilterEntry, - sortFilters, + applyFilterChain, + dispatchEventAsync, + dispatchEventSync, + type FilterEntry, + sortFilters, } from "./pure.js"; export interface Bus { - readonly on: (hook: EventHookDescriptor, handler: EventHandler) => () => void; - readonly emit: (hook: EventHookDescriptor, payload: T) => void; - readonly emitAsync: ( - hook: EventHookDescriptor, - payload: T, - timeoutMs?: number, - ) => Promise; - readonly addFilter: ( - hook: FilterDescriptor, - fn: FilterHandler, - opts?: { readonly priority?: number }, - ) => () => void; - readonly applyFilters: ( - hook: FilterDescriptor, - value: T, - opts?: { readonly failClosed?: boolean }, - ) => Promise; - readonly provideService: (handle: ServiceHandle, impl: T) => void; - readonly getService: (handle: ServiceHandle) => T; + readonly on: (hook: EventHookDescriptor, handler: EventHandler) => () => void; + readonly emit: (hook: EventHookDescriptor, payload: T) => void; + readonly emitAsync: ( + hook: EventHookDescriptor, + payload: T, + timeoutMs?: number, + ) => Promise; + readonly addFilter: ( + hook: FilterDescriptor, + fn: FilterHandler, + opts?: { readonly priority?: number }, + ) => () => void; + readonly applyFilters: ( + hook: FilterDescriptor, + value: T, + opts?: { readonly failClosed?: boolean }, + ) => Promise; + readonly provideService: (handle: ServiceHandle, impl: T) => void; + readonly getService: (handle: ServiceHandle) => T; } interface StoredFilterEntry { - readonly fn: unknown; - readonly priority: number; - readonly order: number; + readonly fn: unknown; + readonly priority: number; + readonly order: number; } export function createBus(logger: Logger): Bus { - const eventHandlers = new Map>(); - const filterEntries = new Map(); - const services = new Map(); - let filterOrderCounter = 0; + const eventHandlers = new Map>(); + const filterEntries = new Map(); + const services = new Map(); + let filterOrderCounter = 0; - return { - on(hook: EventHookDescriptor, handler: EventHandler): () => void { - let set = eventHandlers.get(hook.id); - if (set === undefined) { - set = new Set(); - eventHandlers.set(hook.id, set); - } - const stored: unknown = handler; - set.add(stored); - return () => { - const current = eventHandlers.get(hook.id); - if (current !== undefined) current.delete(stored); - }; - }, + return { + on(hook: EventHookDescriptor, handler: EventHandler): () => void { + let set = eventHandlers.get(hook.id); + if (set === undefined) { + set = new Set(); + eventHandlers.set(hook.id, set); + } + const stored: unknown = handler; + set.add(stored); + return () => { + const current = eventHandlers.get(hook.id); + if (current !== undefined) current.delete(stored); + }; + }, - emit(hook: EventHookDescriptor, payload: T): void { - const set = eventHandlers.get(hook.id); - if (set === undefined || set.size === 0) return; - const handlers = [...set] as Array>; - dispatchEventSync(handlers, payload, logger, hook.id); - }, + emit(hook: EventHookDescriptor, payload: T): void { + const set = eventHandlers.get(hook.id); + if (set === undefined || set.size === 0) return; + const handlers = [...set] as Array>; + dispatchEventSync(handlers, payload, logger, hook.id); + }, - async emitAsync( - hook: EventHookDescriptor, - payload: T, - timeoutMs?: number, - ): Promise { - const set = eventHandlers.get(hook.id); - if (set === undefined || set.size === 0) return; - const handlers = [...set] as Array>; - await dispatchEventAsync(handlers, payload, logger, hook.id, timeoutMs); - }, + async emitAsync( + hook: EventHookDescriptor, + payload: T, + timeoutMs?: number, + ): Promise { + const set = eventHandlers.get(hook.id); + if (set === undefined || set.size === 0) return; + const handlers = [...set] as Array>; + await dispatchEventAsync(handlers, payload, logger, hook.id, timeoutMs); + }, - addFilter( - hook: FilterDescriptor, - fn: FilterHandler, - opts?: { readonly priority?: number }, - ): () => void { - let entries = filterEntries.get(hook.id); - if (entries === undefined) { - entries = []; - filterEntries.set(hook.id, entries); - } - const entry: StoredFilterEntry = { - fn, - priority: opts?.priority ?? 0, - order: filterOrderCounter++, - }; - entries.push(entry); - return () => { - const current = filterEntries.get(hook.id); - if (current === undefined) return; - const idx = current.indexOf(entry); - if (idx !== -1) current.splice(idx, 1); - }; - }, + addFilter( + hook: FilterDescriptor, + fn: FilterHandler, + opts?: { readonly priority?: number }, + ): () => void { + let entries = filterEntries.get(hook.id); + if (entries === undefined) { + entries = []; + filterEntries.set(hook.id, entries); + } + const entry: StoredFilterEntry = { + fn, + priority: opts?.priority ?? 0, + order: filterOrderCounter++, + }; + entries.push(entry); + return () => { + const current = filterEntries.get(hook.id); + if (current === undefined) return; + const idx = current.indexOf(entry); + if (idx !== -1) current.splice(idx, 1); + }; + }, - async applyFilters( - hook: FilterDescriptor, - value: T, - opts?: { readonly failClosed?: boolean }, - ): Promise { - const entries = filterEntries.get(hook.id); - if (entries === undefined || entries.length === 0) return value; - const sorted = sortFilters(entries as ReadonlyArray>); - const fns = sorted.map((e) => e.fn) as Array>; - return applyFilterChain(fns, value, logger, hook.id, opts?.failClosed ?? false); - }, + async applyFilters( + hook: FilterDescriptor, + value: T, + opts?: { readonly failClosed?: boolean }, + ): Promise { + const entries = filterEntries.get(hook.id); + if (entries === undefined || entries.length === 0) return value; + const sorted = sortFilters(entries as ReadonlyArray>); + const fns = sorted.map((e) => e.fn) as Array>; + return applyFilterChain(fns, value, logger, hook.id, opts?.failClosed ?? false); + }, - provideService(handle: ServiceHandle, impl: T): void { - if (services.has(handle.id)) { - throw new Error( - `Service "${handle.id}" is already provided. Only one provider per handle is allowed.`, - ); - } - services.set(handle.id, impl); - }, + provideService(handle: ServiceHandle, impl: T): void { + if (services.has(handle.id)) { + throw new Error( + `Service "${handle.id}" is already provided. Only one provider per handle is allowed.`, + ); + } + services.set(handle.id, impl); + }, - getService(handle: ServiceHandle): T { - const impl = services.get(handle.id); - if (impl === undefined) { - throw new Error( - `Service "${handle.id}" has no provider. Call provideService before getService.`, - ); - } - return impl as T; - }, - }; + getService(handle: ServiceHandle): T { + const impl = services.get(handle.id); + if (impl === undefined) { + throw new Error( + `Service "${handle.id}" has no provider. Call provideService before getService.`, + ); + } + return impl as T; + }, + }; } diff --git a/packages/kernel/src/bus/pure.ts b/packages/kernel/src/bus/pure.ts index 4d90fc6..a1c7a86 100644 --- a/packages/kernel/src/bus/pure.ts +++ b/packages/kernel/src/bus/pure.ts @@ -2,82 +2,82 @@ import type { Logger } from "../contracts/extension.js"; import type { EventHandler, FilterHandler } from "../contracts/hooks.js"; export function dispatchEventSync( - handlers: ReadonlyArray>, - payload: T, - logger: Logger, - hookId: string, + handlers: ReadonlyArray>, + payload: T, + logger: Logger, + hookId: string, ): void { - for (const handler of handlers) { - try { - const result = handler(payload); - if (result instanceof Promise) { - result.catch((err: unknown) => { - logger.error(`Event hook "${hookId}" handler rejected`, { err }); - }); - } - } catch (err) { - logger.error(`Event hook "${hookId}" handler threw`, { err }); - } - } + for (const handler of handlers) { + try { + const result = handler(payload); + if (result instanceof Promise) { + result.catch((err: unknown) => { + logger.error(`Event hook "${hookId}" handler rejected`, { err }); + }); + } + } catch (err) { + logger.error(`Event hook "${hookId}" handler threw`, { err }); + } + } } export async function dispatchEventAsync( - handlers: ReadonlyArray>, - payload: T, - logger: Logger, - hookId: string, - timeoutMs?: number, + handlers: ReadonlyArray>, + payload: T, + logger: Logger, + hookId: string, + timeoutMs?: number, ): Promise { - const promises = handlers.map(async (handler) => { - try { - await handler(payload); - } catch (err) { - logger.error(`Event hook "${hookId}" handler threw`, { err }); - } - }); + const promises = handlers.map(async (handler) => { + try { + await handler(payload); + } catch (err) { + logger.error(`Event hook "${hookId}" handler threw`, { err }); + } + }); - if (timeoutMs !== undefined) { - await Promise.race([ - Promise.all(promises), - new Promise((resolve) => { - setTimeout(resolve, timeoutMs); - }), - ]); - } else { - await Promise.all(promises); - } + if (timeoutMs !== undefined) { + await Promise.race([ + Promise.all(promises), + new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }), + ]); + } else { + await Promise.all(promises); + } } export interface FilterEntry { - readonly fn: FilterHandler; - readonly priority: number; - readonly order: number; + readonly fn: FilterHandler; + readonly priority: number; + readonly order: number; } export function sortFilters( - entries: ReadonlyArray>, + entries: ReadonlyArray>, ): ReadonlyArray> { - return [...entries].sort((a, b) => { - if (a.priority !== b.priority) return a.priority - b.priority; - return a.order - b.order; - }); + return [...entries].sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + return a.order - b.order; + }); } export async function applyFilterChain( - filters: ReadonlyArray>, - value: T, - logger: Logger, - hookId: string, - failClosed: boolean, + filters: ReadonlyArray>, + value: T, + logger: Logger, + hookId: string, + failClosed: boolean, ): Promise { - let current = value; - for (const fn of filters) { - try { - current = await fn(current); - } catch (err) { - if (failClosed) throw err; - logger.error(`Filter "${hookId}" handler threw (fail-open, passing through)`, { err }); - } - } - return current; + let current = value; + for (const fn of filters) { + try { + current = await fn(current); + } catch (err) { + if (failClosed) throw err; + logger.error(`Filter "${hookId}" handler threw (fail-open, passing through)`, { err }); + } + } + return current; } diff --git a/packages/kernel/src/contracts/auth.ts b/packages/kernel/src/contracts/auth.ts index 6058156..85963ba 100644 --- a/packages/kernel/src/contracts/auth.ts +++ b/packages/kernel/src/contracts/auth.ts @@ -11,9 +11,9 @@ * This is the common case for OpenAI-compatible and most provider extensions. */ export interface ApiKeyCredentials { - readonly type: "api-key"; - readonly apiKey: string; - readonly baseURL?: string; + readonly type: "api-key"; + readonly apiKey: string; + readonly baseURL?: string; } /** @@ -22,9 +22,9 @@ export interface ApiKeyCredentials { * receives a currently-valid token. */ export interface BearerTokenCredentials { - readonly type: "bearer-token"; - readonly token: string; - readonly baseURL?: string; + readonly type: "bearer-token"; + readonly token: string; + readonly baseURL?: string; } /** Union of credential shapes the kernel recognizes. */ @@ -36,13 +36,13 @@ export type Credentials = ApiKeyCredentials | BearerTokenCredentials; * directly (the concrete vault is a core extension). */ export interface AuthContract { - /** Unique identifier for this auth provider (e.g. "apikey", "claude-oauth"). */ - readonly id: string; + /** Unique identifier for this auth provider (e.g. "apikey", "claude-oauth"). */ + readonly id: string; - /** - * Resolve currently-valid credentials. May involve reading from the - * secret vault (injected via Host API) or performing a token refresh. - * Returns `null` if credentials are unavailable (e.g. not yet configured). - */ - readonly resolve: () => Promise; + /** + * Resolve currently-valid credentials. May involve reading from the + * secret vault (injected via Host API) or performing a token refresh. + * Returns `null` if credentials are unavailable (e.g. not yet configured). + */ + readonly resolve: () => Promise; } diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index b459532..f074c52 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -6,23 +6,23 @@ */ export type { - ChatMessage, - Chunk, - CompactionResult, - ConversationMeta, - ConversationStatus, - ErrorChunk, - Role, - StepId, - StepMetrics, - StoredChunk, - SystemChunk, - TextChunk, - ThinkingChunk, - ToolCallChunk, - ToolResultChunk, - TurnId, - TurnMetrics, - Workspace, - WorkspaceEntry, + ChatMessage, + Chunk, + CompactionResult, + ConversationMeta, + ConversationStatus, + ErrorChunk, + Role, + StepId, + StepMetrics, + StoredChunk, + SystemChunk, + TextChunk, + ThinkingChunk, + ToolCallChunk, + ToolResultChunk, + TurnId, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/dispatch.ts b/packages/kernel/src/contracts/dispatch.ts index c2914cf..318f14a 100644 --- a/packages/kernel/src/contracts/dispatch.ts +++ b/packages/kernel/src/contracts/dispatch.ts @@ -26,6 +26,6 @@ * (safe for any tool), yet the first tool starts during generation. */ export interface ToolDispatchPolicy { - readonly maxConcurrent: number; - readonly eager: boolean; + readonly maxConcurrent: number; + readonly eager: boolean; } diff --git a/packages/kernel/src/contracts/events.ts b/packages/kernel/src/contracts/events.ts index dca34c2..dfd7456 100644 --- a/packages/kernel/src/contracts/events.ts +++ b/packages/kernel/src/contracts/events.ts @@ -6,20 +6,20 @@ */ export type { - AgentEvent, - StatusEvent, - TurnDoneEvent, - TurnErrorEvent, - TurnInputEvent, - TurnProviderRetryEvent, - TurnReasoningDeltaEvent, - TurnSealedEvent, - TurnStartEvent, - TurnSteeringEvent, - TurnStepCompleteEvent, - TurnTextDeltaEvent, - TurnToolCallEvent, - TurnToolOutputEvent, - TurnToolResultEvent, - TurnUsageEvent, + AgentEvent, + StatusEvent, + TurnDoneEvent, + TurnErrorEvent, + TurnInputEvent, + TurnProviderRetryEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnSteeringEvent, + TurnStepCompleteEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolOutputEvent, + TurnToolResultEvent, + TurnUsageEvent, } from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts index 4d6cf07..fd1594b 100644 --- a/packages/kernel/src/contracts/extension.ts +++ b/packages/kernel/src/contracts/extension.ts @@ -9,11 +9,11 @@ import type { AuthContract } from "./auth.js"; import type { - EventHandler, - EventHookDescriptor, - FilterDescriptor, - FilterHandler, - ServiceHandle, + EventHandler, + EventHookDescriptor, + FilterDescriptor, + FilterHandler, + ServiceHandle, } from "./hooks.js"; import type { Logger } from "./logging.js"; @@ -32,16 +32,16 @@ export type TrustLevel = "bundled" | "local" | "external"; * discovery, dependency resolution, and the capability gate. */ export interface ManifestContributions { - readonly tools?: readonly string[]; - readonly providers?: readonly string[]; - readonly auth?: readonly string[]; - readonly hooks?: readonly string[]; - readonly routes?: readonly string[]; - readonly commands?: readonly string[]; - readonly services?: readonly string[]; - readonly migrations?: readonly string[]; - readonly scheduledJobs?: readonly string[]; - readonly settings?: readonly string[]; + readonly tools?: readonly string[]; + readonly providers?: readonly string[]; + readonly auth?: readonly string[]; + readonly hooks?: readonly string[]; + readonly routes?: readonly string[]; + readonly commands?: readonly string[]; + readonly services?: readonly string[]; + readonly migrations?: readonly string[]; + readonly scheduledJobs?: readonly string[]; + readonly settings?: readonly string[]; } /** @@ -50,12 +50,12 @@ export interface ManifestContributions { * declared capability for. */ export interface ManifestCapabilities { - readonly fs?: boolean; - readonly shell?: boolean; - readonly network?: boolean; - readonly secrets?: boolean; - readonly db?: boolean; - readonly spawn?: boolean; + readonly fs?: boolean; + readonly shell?: boolean; + readonly network?: boolean; + readonly secrets?: boolean; + readonly db?: boolean; + readonly spawn?: boolean; } /** @@ -64,32 +64,32 @@ export interface ManifestCapabilities { * compatibility, and enforce the capability gate. */ export interface Manifest { - /** Unique extension identifier (e.g. "tools-fs", "provider-anthropic"). */ - readonly id: string; + /** Unique extension identifier (e.g. "tools-fs", "provider-anthropic"). */ + readonly id: string; - /** Human-readable display name. */ - readonly name: string; + /** Human-readable display name. */ + readonly name: string; - /** Extension's own version (semver). */ - readonly version: string; + /** Extension's own version (semver). */ + readonly version: string; - /** Semver range of kernel API versions this extension is compatible with. */ - readonly apiVersion: string; + /** Semver range of kernel API versions this extension is compatible with. */ + readonly apiVersion: string; - /** Ids of extensions this one depends on (resolved topologically). */ - readonly dependsOn?: readonly string[]; + /** Ids of extensions this one depends on (resolved topologically). */ + readonly dependsOn?: readonly string[]; - /** Activation strategy: "eager" (on boot) or lazy event triggers. */ - readonly activation?: "eager" | string; + /** Activation strategy: "eager" (on boot) or lazy event triggers. */ + readonly activation?: "eager" | string; - /** What this extension contributes to the system. */ - readonly contributes?: ManifestContributions; + /** What this extension contributes to the system. */ + readonly contributes?: ManifestContributions; - /** Capabilities this extension requires from the host. */ - readonly capabilities?: ManifestCapabilities; + /** Capabilities this extension requires from the host. */ + readonly capabilities?: ManifestCapabilities; - /** Trust level — bundled (first-party), local (project), or external. */ - readonly trust: TrustLevel; + /** Trust level — bundled (first-party), local (project), or external. */ + readonly trust: TrustLevel; } // --- Storage interface --- @@ -100,40 +100,40 @@ export interface Manifest { * only the contract. Supports key-value and simple query operations. */ export interface StorageNamespace { - readonly get: (key: string) => Promise; - readonly set: (key: string, value: string) => Promise; - readonly delete: (key: string) => Promise; - readonly has: (key: string) => Promise; - readonly keys: (prefix?: string) => Promise; + readonly get: (key: string) => Promise; + readonly set: (key: string, value: string) => Promise; + readonly delete: (key: string) => Promise; + readonly has: (key: string) => Promise; + readonly keys: (prefix?: string) => Promise; } // --- Permission --- /** The outcome of a permission check. */ export interface PermissionDecision { - readonly allowed: boolean; - readonly reason?: string; + readonly allowed: boolean; + readonly reason?: string; } /** A request to check whether an action is permitted. */ export interface PermissionRequest { - readonly tool: string; - readonly action: string; - readonly context?: Readonly>; + readonly tool: string; + readonly action: string; + readonly context?: Readonly>; } /** Permission gate exposed through the Host API. */ export interface PermissionGate { - readonly check: (request: PermissionRequest) => Promise; + readonly check: (request: PermissionRequest) => Promise; } // --- Scheduler --- /** A scheduled job definition an extension can register with the host. */ export interface ScheduledJob { - readonly id: string; - readonly cron: string; - readonly execute: () => void | Promise; + readonly id: string; + readonly cron: string; + readonly execute: () => void | Promise; } // --- Logger is re-exported from logging.ts (structured, correlated) --- @@ -142,24 +142,24 @@ export interface ScheduledJob { /** Read-only config access for an extension's own settings namespace. */ export interface ConfigAccess { - readonly get: (key: string) => T | undefined; - readonly getAll: () => Readonly>; + readonly get: (key: string) => T | undefined; + readonly getAll: () => Readonly>; } // --- Secrets --- /** Capability-gated access to the secret/credential vault. */ export interface SecretsAccess { - readonly get: (key: string) => Promise; - readonly set: (key: string, value: string) => Promise; - readonly delete: (key: string) => Promise; + readonly get: (key: string) => Promise; + readonly set: (key: string, value: string) => Promise; + readonly delete: (key: string) => Promise; } // --- Events emitter --- /** Outward event emitter available to extensions via the Host API. */ export interface EventsEmitter { - readonly emit: (event: { readonly type: string; readonly [key: string]: unknown }) => void; + readonly emit: (event: { readonly type: string; readonly [key: string]: unknown }) => void; } // --- Host API --- @@ -175,102 +175,102 @@ export interface EventsEmitter { * module (not the kernel contracts). */ export interface HostAPI { - /** Register a tool with the kernel's tool registry. */ - readonly defineTool: (tool: ToolContract) => void; - - /** Register a provider with the kernel's provider registry. */ - readonly defineProvider: (provider: ProviderContract) => void; - - /** Register an auth provider with the kernel's auth registry. */ - readonly defineAuth: (auth: AuthContract) => void; - - /** Subscribe to an event hook. Handlers are error-isolated per call. */ - readonly on: ( - hook: EventHookDescriptor, - handler: EventHandler, - ) => () => void; - - /** - * Emit an event hook: fire-and-forget dispatch to every `on` subscriber, - * error-isolated per handler (a thrown handler is caught + logged, never - * breaks the caller). The counterpart of `on`. - * - * This lets a core extension that OWNS a lifecycle publish typed events that - * standard extensions react to — e.g. the session-orchestrator emitting - * per-turn start/settle events a cache-warming extension subscribes to. The - * kernel owns the mechanism; the owner declares the typed `EventHookDescriptor`. - */ - readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; - - /** Add a filter to a filter hook chain. Filters are awaited in-band. */ - readonly addFilter: ( - hook: FilterDescriptor, - fn: FilterHandler, - ) => () => void; - - /** - * Run a filter chain: thread `value` through every filter registered for - * `hook` in priority/registration order and return the final value. The - * single-value-in/value-out counterpart to `addFilter`. Awaited in-band. - * - * Fail-open by default (a thrown filter is logged and the value passes - * through unchanged); pass `{ failClosed: true }` to make a thrown filter - * reject. With no registered filters the input value is returned as-is. - * - * This is what lets a core extension expose a contribution point (e.g. the - * session-orchestrator running a per-turn tool/context-assembly chain) that - * standard extensions plug into via `addFilter` — the kernel owns the - * mechanism, the owner declares the typed `FilterDescriptor`. - */ - readonly applyFilters: ( - hook: FilterDescriptor, - value: TValue, - opts?: { readonly failClosed?: boolean }, - ) => Promise; - - /** Provide an implementation for a typed service handle. */ - readonly provideService: (handle: ServiceHandle, impl: T) => void; - - /** Retrieve the implementation for a typed service handle. */ - readonly getService: (handle: ServiceHandle) => T; - - /** Get a namespaced storage interface for this extension. */ - readonly storage: (namespace: string) => StorageNamespace; - - /** Read-only access to merged config (global → project → extension). */ - readonly config: ConfigAccess; - - /** Capability-gated access to the secret/credential vault. */ - readonly secrets: SecretsAccess; - - /** Permission gate — check whether an action is allowed. */ - readonly permissions: PermissionGate; - - /** Emit outward events (transport pushes these to clients). */ - readonly events: EventsEmitter; - - /** Logger — always available, even before other extensions activate. */ - readonly logger: Logger; - - /** Read-only view of all registered providers. */ - readonly getProviders: () => ReadonlyMap; - - /** Read-only view of all registered tools. */ - readonly getTools: () => ReadonlyMap; - - /** Read-only view of all registered auth providers. */ - readonly getAuthProviders: () => ReadonlyMap; - - /** Look up a single auth provider by id. */ - readonly getAuthProvider: (id: string) => AuthContract | undefined; - - /** Read-only view of all activated extensions' manifests (what is loaded). */ - readonly getExtensions: () => readonly Manifest[]; - - /** Register a scheduled job with the host's scheduler. */ - readonly scheduler: { - readonly register: (job: ScheduledJob) => void; - }; + /** Register a tool with the kernel's tool registry. */ + readonly defineTool: (tool: ToolContract) => void; + + /** Register a provider with the kernel's provider registry. */ + readonly defineProvider: (provider: ProviderContract) => void; + + /** Register an auth provider with the kernel's auth registry. */ + readonly defineAuth: (auth: AuthContract) => void; + + /** Subscribe to an event hook. Handlers are error-isolated per call. */ + readonly on: ( + hook: EventHookDescriptor, + handler: EventHandler, + ) => () => void; + + /** + * Emit an event hook: fire-and-forget dispatch to every `on` subscriber, + * error-isolated per handler (a thrown handler is caught + logged, never + * breaks the caller). The counterpart of `on`. + * + * This lets a core extension that OWNS a lifecycle publish typed events that + * standard extensions react to — e.g. the session-orchestrator emitting + * per-turn start/settle events a cache-warming extension subscribes to. The + * kernel owns the mechanism; the owner declares the typed `EventHookDescriptor`. + */ + readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; + + /** Add a filter to a filter hook chain. Filters are awaited in-band. */ + readonly addFilter: ( + hook: FilterDescriptor, + fn: FilterHandler, + ) => () => void; + + /** + * Run a filter chain: thread `value` through every filter registered for + * `hook` in priority/registration order and return the final value. The + * single-value-in/value-out counterpart to `addFilter`. Awaited in-band. + * + * Fail-open by default (a thrown filter is logged and the value passes + * through unchanged); pass `{ failClosed: true }` to make a thrown filter + * reject. With no registered filters the input value is returned as-is. + * + * This is what lets a core extension expose a contribution point (e.g. the + * session-orchestrator running a per-turn tool/context-assembly chain) that + * standard extensions plug into via `addFilter` — the kernel owns the + * mechanism, the owner declares the typed `FilterDescriptor`. + */ + readonly applyFilters: ( + hook: FilterDescriptor, + value: TValue, + opts?: { readonly failClosed?: boolean }, + ) => Promise; + + /** Provide an implementation for a typed service handle. */ + readonly provideService: (handle: ServiceHandle, impl: T) => void; + + /** Retrieve the implementation for a typed service handle. */ + readonly getService: (handle: ServiceHandle) => T; + + /** Get a namespaced storage interface for this extension. */ + readonly storage: (namespace: string) => StorageNamespace; + + /** Read-only access to merged config (global → project → extension). */ + readonly config: ConfigAccess; + + /** Capability-gated access to the secret/credential vault. */ + readonly secrets: SecretsAccess; + + /** Permission gate — check whether an action is allowed. */ + readonly permissions: PermissionGate; + + /** Emit outward events (transport pushes these to clients). */ + readonly events: EventsEmitter; + + /** Logger — always available, even before other extensions activate. */ + readonly logger: Logger; + + /** Read-only view of all registered providers. */ + readonly getProviders: () => ReadonlyMap; + + /** Read-only view of all registered tools. */ + readonly getTools: () => ReadonlyMap; + + /** Read-only view of all registered auth providers. */ + readonly getAuthProviders: () => ReadonlyMap; + + /** Look up a single auth provider by id. */ + readonly getAuthProvider: (id: string) => AuthContract | undefined; + + /** Read-only view of all activated extensions' manifests (what is loaded). */ + readonly getExtensions: () => readonly Manifest[]; + + /** Register a scheduled job with the host's scheduler. */ + readonly scheduler: { + readonly register: (job: ScheduledJob) => void; + }; } // --- Extension lifecycle --- @@ -281,18 +281,18 @@ export interface HostAPI { * `deactivate` is optional and called on shutdown or reload. */ export interface Extension { - /** The extension's manifest — its declaration of identity and capabilities. */ - readonly manifest: Manifest; - - /** - * Called by the host to activate the extension. The extension registers - * its contributions (tools, providers, hooks, services) through the Host API. - */ - readonly activate: (host: HostAPI) => void | Promise; - - /** - * Optional cleanup called when the extension is deactivated (shutdown, - * reload, or auto-disable). Should dispose resources the extension owns. - */ - readonly deactivate?: () => void | Promise; + /** The extension's manifest — its declaration of identity and capabilities. */ + readonly manifest: Manifest; + + /** + * Called by the host to activate the extension. The extension registers + * its contributions (tools, providers, hooks, services) through the Host API. + */ + readonly activate: (host: HostAPI) => void | Promise; + + /** + * Optional cleanup called when the extension is deactivated (shutdown, + * reload, or auto-disable). Should dispose resources the extension owns. + */ + readonly deactivate?: () => void | Promise; } diff --git a/packages/kernel/src/contracts/hooks.ts b/packages/kernel/src/contracts/hooks.ts index eb94465..8f2bd1f 100644 --- a/packages/kernel/src/contracts/hooks.ts +++ b/packages/kernel/src/contracts/hooks.ts @@ -21,9 +21,9 @@ * (a thrown handler is caught and logged — it never breaks the turn). */ export interface EventHookDescriptor { - readonly kind: "event"; - readonly id: string; - readonly _payload?: TPayload; + readonly kind: "event"; + readonly id: string; + readonly _payload?: TPayload; } /** @@ -35,9 +35,9 @@ export interface EventHookDescriptor { * the owner may mark a chain fail-closed. */ export interface FilterDescriptor { - readonly kind: "filter"; - readonly id: string; - readonly _value?: TValue; + readonly kind: "filter"; + readonly id: string; + readonly _value?: TValue; } /** Union of hook descriptor kinds the kernel mechanism supports. */ @@ -49,9 +49,9 @@ export type HookDescriptor = EventHookDescriptor | FilterDes * "which of N handlers wins?" ambiguity; a service has exactly one provider. */ export interface ServiceHandle { - readonly kind: "service"; - readonly id: string; - readonly _type?: T; + readonly kind: "service"; + readonly id: string; + readonly _type?: T; } /** @@ -61,7 +61,7 @@ export interface ServiceHandle { * @param id - Namespaced hook id in `owner/name` form (e.g. "kernel/turn.sealed"). */ export function defineEventHook(id: string): EventHookDescriptor { - return { kind: "event", id }; + return { kind: "event", id }; } /** @@ -71,7 +71,7 @@ export function defineEventHook(id: string): EventHookDescriptor(id: string): FilterDescriptor { - return { kind: "filter", id }; + return { kind: "filter", id }; } /** @@ -82,7 +82,7 @@ export function defineFilter(id: string): FilterDescriptor { * @param id - Namespaced service id in `owner/name` form. */ export function defineService(id: string): ServiceHandle { - return { kind: "service", id }; + return { kind: "service", id }; } /** Handler function for an event hook subscription. */ diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts index f3e5bca..09e0a56 100644 --- a/packages/kernel/src/contracts/index.ts +++ b/packages/kernel/src/contracts/index.ts @@ -7,118 +7,118 @@ */ export type { - ApiKeyCredentials, - AuthContract, - BearerTokenCredentials, - Credentials, + ApiKeyCredentials, + AuthContract, + BearerTokenCredentials, + Credentials, } from "./auth.js"; export type { - ChatMessage, - Chunk, - CompactionResult, - ConversationMeta, - ConversationStatus, - ErrorChunk, - Role, - StepId, - StepMetrics, - StoredChunk, - SystemChunk, - TextChunk, - ThinkingChunk, - ToolCallChunk, - ToolResultChunk, - TurnId, - TurnMetrics, - Workspace, - WorkspaceEntry, + ChatMessage, + Chunk, + CompactionResult, + ConversationMeta, + ConversationStatus, + ErrorChunk, + Role, + StepId, + StepMetrics, + StoredChunk, + SystemChunk, + TextChunk, + ThinkingChunk, + ToolCallChunk, + ToolResultChunk, + TurnId, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "./conversation.js"; export type { ToolDispatchPolicy } from "./dispatch.js"; export type { - AgentEvent, - StatusEvent, - TurnDoneEvent, - TurnErrorEvent, - TurnInputEvent, - TurnProviderRetryEvent, - TurnReasoningDeltaEvent, - TurnSealedEvent, - TurnStartEvent, - TurnSteeringEvent, - TurnStepCompleteEvent, - TurnTextDeltaEvent, - TurnToolCallEvent, - TurnToolOutputEvent, - TurnToolResultEvent, - TurnUsageEvent, + AgentEvent, + StatusEvent, + TurnDoneEvent, + TurnErrorEvent, + TurnInputEvent, + TurnProviderRetryEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnSteeringEvent, + TurnStepCompleteEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolOutputEvent, + TurnToolResultEvent, + TurnUsageEvent, } from "./events.js"; export type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - ManifestCapabilities, - ManifestContributions, - PermissionDecision, - PermissionGate, - PermissionRequest, - ScheduledJob, - SecretsAccess, - StorageNamespace, - TrustLevel, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + ManifestCapabilities, + ManifestContributions, + PermissionDecision, + PermissionGate, + PermissionRequest, + ScheduledJob, + SecretsAccess, + StorageNamespace, + TrustLevel, } from "./extension.js"; export type { - EventHandler, - EventHookDescriptor, - FilterDescriptor, - FilterHandler, - HookDescriptor, - ServiceHandle, + EventHandler, + EventHookDescriptor, + FilterDescriptor, + FilterHandler, + HookDescriptor, + ServiceHandle, } from "./hooks.js"; export { defineEventHook, defineFilter, defineService } from "./hooks.js"; export type { - Attributes, - ErrorAttributes, - Level, - LogContext, - LogDeps, - Logger, - LogLineRecord, - LogRecord, - LogSink, - Span, - SpanCloseRecord, - SpanLink, - SpanOpenRecord, - SpanStatus, + Attributes, + ErrorAttributes, + Level, + LogContext, + LogDeps, + Logger, + LogLineRecord, + LogRecord, + LogSink, + Span, + SpanCloseRecord, + SpanLink, + SpanOpenRecord, + SpanStatus, } from "./logging.js"; export type { - FinishEvent, - ModelInfo, - ProviderContract, - ProviderErrorEvent, - ProviderEvent, - ProviderStreamOptions, - ProviderToolCallEvent, - ReasoningDeltaEvent, - ReasoningEffort, - TextDeltaEvent, - Usage, - UsageEvent, + FinishEvent, + ModelInfo, + ProviderContract, + ProviderErrorEvent, + ProviderEvent, + ProviderStreamOptions, + ProviderToolCallEvent, + ReasoningDeltaEvent, + ReasoningEffort, + TextDeltaEvent, + Usage, + UsageEvent, } from "./provider.js"; export type { - EventEmitter, - FinishReason, - RetryStrategy, - RunTurnInput, - RunTurnResult, + EventEmitter, + FinishReason, + RetryStrategy, + RunTurnInput, + RunTurnResult, } from "./runtime.js"; export type { - JsonSchemaProperty, - ToolCall, - ToolContract, - ToolExecuteContext, - ToolParameterSchema, - ToolResult, + JsonSchemaProperty, + ToolCall, + ToolContract, + ToolExecuteContext, + ToolParameterSchema, + ToolResult, } from "./tool.js"; diff --git a/packages/kernel/src/contracts/logging.ts b/packages/kernel/src/contracts/logging.ts index a8bab7c..d121777 100644 --- a/packages/kernel/src/contracts/logging.ts +++ b/packages/kernel/src/contracts/logging.ts @@ -27,12 +27,12 @@ export type Attributes = Readonly void; - /** Record a causal link to another span (D4 cross-feature causality). */ - readonly addLink: ( - target: { readonly spanId: string; readonly turnId?: string }, - reason?: string, - ) => void; - /** Open a child span nested under this one. */ - readonly child: (name: string, attrs?: Attributes, body?: string) => Span; - /** - * Close this span. Records duration + status. Optionally records an - * error, additional attributes, and/or a body payload. - */ - readonly end: (outcome?: { - readonly err?: unknown; - readonly attrs?: Attributes; - readonly body?: string; - }) => void; + readonly id: string; + /** Pre-bound Logger scoped to this span's correlation. */ + readonly log: Logger; + /** Add or overwrite attributes on this span. */ + readonly setAttributes: (attrs: Attributes) => void; + /** Record a causal link to another span (D4 cross-feature causality). */ + readonly addLink: ( + target: { readonly spanId: string; readonly turnId?: string }, + reason?: string, + ) => void; + /** Open a child span nested under this one. */ + readonly child: (name: string, attrs?: Attributes, body?: string) => Span; + /** + * Close this span. Records duration + status. Optionally records an + * error, additional attributes, and/or a body payload. + */ + readonly end: (outcome?: { + readonly err?: unknown; + readonly attrs?: Attributes; + readonly body?: string; + }) => void; } // --- Logger --- @@ -75,17 +75,17 @@ export interface Span { * `info("msg")` must still compile — attrs is optional (backward compat). */ export interface Logger { - readonly debug: (msg: string, attrs?: Attributes) => void; - readonly info: (msg: string, attrs?: Attributes) => void; - readonly warn: (msg: string, attrs?: Attributes) => void; - readonly error: (msg: string, attrs?: ErrorAttributes) => void; - /** - * Create a child logger with additional correlation context. - * Explicit values passed down (P3 — no ambient state). - */ - readonly child: (ctx: Partial & { readonly attrs?: Attributes }) => Logger; - /** Open a new span. Emits a `span-open` record immediately (D3). */ - readonly span: (name: string, attrs?: Attributes, body?: string) => Span; + readonly debug: (msg: string, attrs?: Attributes) => void; + readonly info: (msg: string, attrs?: Attributes) => void; + readonly warn: (msg: string, attrs?: Attributes) => void; + readonly error: (msg: string, attrs?: ErrorAttributes) => void; + /** + * Create a child logger with additional correlation context. + * Explicit values passed down (P3 — no ambient state). + */ + readonly child: (ctx: Partial & { readonly attrs?: Attributes }) => Logger; + /** Open a new span. Emits a `span-open` record immediately (D3). */ + readonly span: (name: string, attrs?: Attributes, body?: string) => Span; } /** @@ -94,8 +94,8 @@ export interface Logger { * pass `error("msg", { err })` directly. */ export interface ErrorAttributes { - readonly err?: unknown; - readonly [key: string]: unknown; + readonly err?: unknown; + readonly [key: string]: unknown; } // --- LogRecord (discriminated union) --- @@ -109,9 +109,9 @@ export type SpanStatus = "ok" | "error"; * A link to another span, recorded at a handoff moment (D4). */ export interface SpanLink { - readonly spanId: string; - readonly turnId?: string; - readonly reason?: string; + readonly spanId: string; + readonly turnId?: string; + readonly reason?: string; } /** @@ -126,50 +126,50 @@ export type LogRecord = LogLineRecord | SpanOpenRecord | SpanCloseRecord; /** A structured log line (debug/info/warn/error). */ export interface LogLineRecord { - readonly kind: "log"; - readonly level: Level; - readonly msg: string; - readonly timestamp: number; - readonly extensionId: string; - readonly conversationId?: string; - readonly turnId?: string; - readonly spanId?: string; - readonly parentSpanId?: string; - readonly attributes?: Attributes; - /** Optional large verbatim payload (store-fat, serve-thin). */ - readonly body?: string; + readonly kind: "log"; + readonly level: Level; + readonly msg: string; + readonly timestamp: number; + readonly extensionId: string; + readonly conversationId?: string; + readonly turnId?: string; + readonly spanId?: string; + readonly parentSpanId?: string; + readonly attributes?: Attributes; + /** Optional large verbatim payload (store-fat, serve-thin). */ + readonly body?: string; } /** Emitted when a span is opened (at `logger.span(name)`). */ export interface SpanOpenRecord { - readonly kind: "span-open"; - readonly spanId: string; - readonly name: string; - readonly timestamp: number; - readonly extensionId: string; - readonly conversationId?: string; - readonly turnId?: string; - readonly parentSpanId?: string; - readonly attributes?: Attributes; - readonly links?: readonly SpanLink[]; - readonly body?: string; + readonly kind: "span-open"; + readonly spanId: string; + readonly name: string; + readonly timestamp: number; + readonly extensionId: string; + readonly conversationId?: string; + readonly turnId?: string; + readonly parentSpanId?: string; + readonly attributes?: Attributes; + readonly links?: readonly SpanLink[]; + readonly body?: string; } /** Emitted when a span is closed (at `span.end()`). Carries duration + status. */ export interface SpanCloseRecord { - readonly kind: "span-close"; - readonly spanId: string; - readonly name: string; - readonly timestamp: number; - readonly durationMs: number; - readonly status: SpanStatus; - readonly extensionId: string; - readonly conversationId?: string; - readonly turnId?: string; - readonly parentSpanId?: string; - readonly attributes?: Attributes; - readonly links?: readonly SpanLink[]; - readonly body?: string; + readonly kind: "span-close"; + readonly spanId: string; + readonly name: string; + readonly timestamp: number; + readonly durationMs: number; + readonly status: SpanStatus; + readonly extensionId: string; + readonly conversationId?: string; + readonly turnId?: string; + readonly parentSpanId?: string; + readonly attributes?: Attributes; + readonly links?: readonly SpanLink[]; + readonly body?: string; } // --- LogSink --- @@ -179,13 +179,13 @@ export interface SpanCloseRecord { * a concrete implementation. Kernel never lets sink errors escape (D7). */ export interface LogSink { - readonly emit: (record: LogRecord) => void; + readonly emit: (record: LogRecord) => void; } // --- Deterministic helpers (injected for testability) --- /** Clock + id generator injected into the logger factory. */ export interface LogDeps { - readonly now: () => number; - readonly newId: () => string; + readonly now: () => number; + readonly newId: () => string; } diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts index 52d853b..b6dc8ca 100644 --- a/packages/kernel/src/contracts/provider.ts +++ b/packages/kernel/src/contracts/provider.ts @@ -19,23 +19,23 @@ export type { ReasoningEffort, Usage } from "@dispatch/wire"; * Discriminated by `type`. */ export type ProviderEvent = - | TextDeltaEvent - | ReasoningDeltaEvent - | ProviderToolCallEvent - | UsageEvent - | FinishEvent - | ProviderErrorEvent; + | TextDeltaEvent + | ReasoningDeltaEvent + | ProviderToolCallEvent + | UsageEvent + | FinishEvent + | ProviderErrorEvent; /** Incremental text content from the model. */ export interface TextDeltaEvent { - readonly type: "text-delta"; - readonly delta: string; + readonly type: "text-delta"; + readonly delta: string; } /** Incremental reasoning / thinking content from the model. */ export interface ReasoningDeltaEvent { - readonly type: "reasoning-delta"; - readonly delta: string; + readonly type: "reasoning-delta"; + readonly delta: string; } /** @@ -43,16 +43,16 @@ export interface ReasoningDeltaEvent { * dispatch to the matching `ToolContract`. */ export interface ProviderToolCallEvent { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; } /** Token usage report, typically emitted at step end. */ export interface UsageEvent { - readonly type: "usage"; - readonly usage: Usage; + readonly type: "usage"; + readonly usage: Usage; } /** @@ -60,16 +60,16 @@ export interface UsageEvent { * generating (e.g. "stop", "tool-calls", "length", "content-filter"). */ export interface FinishEvent { - readonly type: "finish"; - readonly reason: string; + readonly type: "finish"; + readonly reason: string; } /** An error from the provider (network, rate-limit, model error, etc.). */ export interface ProviderErrorEvent { - readonly type: "error"; - readonly message: string; - readonly code?: string; - readonly retryable?: boolean; + readonly type: "error"; + readonly message: string; + readonly code?: string; + readonly retryable?: boolean; } /** @@ -77,30 +77,30 @@ export interface ProviderErrorEvent { * Kept minimal — providers may ignore fields they don't support. */ export interface ProviderStreamOptions { - /** Model identifier to use. */ - readonly model?: string; - /** Sampling temperature override. */ - readonly temperature?: number; - /** Maximum output tokens override. */ - readonly maxTokens?: number; - /** System prompt to prepend. */ - readonly systemPrompt?: string; - /** - * Reasoning-effort level for this request (already RESOLVED by the caller — - * the session-orchestrator applies the request → conversation → `"high"` - * default chain, so a provider receiving `undefined` may treat it as "no - * preference"). The provider maps the level to its native thinking knob in - * its own code; providers without such a knob ignore it. - */ - readonly reasoningEffort?: ReasoningEffort; - /** - * Correlated logger for this turn's step (Phase A logging ABI). When present, - * the provider should open a child `provider.request` span and capture the - * verbatim post-transform request + raw response/error there, self-redacting - * secrets in its own code. Optional so non-instrumented callers/tests still - * compile (the provider falls back to no capture). - */ - readonly logger?: Logger; + /** Model identifier to use. */ + readonly model?: string; + /** Sampling temperature override. */ + readonly temperature?: number; + /** Maximum output tokens override. */ + readonly maxTokens?: number; + /** System prompt to prepend. */ + readonly systemPrompt?: string; + /** + * Reasoning-effort level for this request (already RESOLVED by the caller — + * the session-orchestrator applies the request → conversation → `"high"` + * default chain, so a provider receiving `undefined` may treat it as "no + * preference"). The provider maps the level to its native thinking knob in + * its own code; providers without such a knob ignore it. + */ + readonly reasoningEffort?: ReasoningEffort; + /** + * Correlated logger for this turn's step (Phase A logging ABI). When present, + * the provider should open a child `provider.request` span and capture the + * verbatim post-transform request + raw response/error there, self-redacting + * secrets in its own code. Optional so non-instrumented callers/tests still + * compile (the provider falls back to no capture). + */ + readonly logger?: Logger; } /** @@ -110,10 +110,10 @@ export interface ProviderStreamOptions { * is the wire model identifier; `displayName` is an optional human label. */ export interface ModelInfo { - readonly id: string; - readonly displayName?: string; - /** The model's max context window in tokens (e.g. 200000). Optional — providers that don't report it leave it undefined. */ - readonly contextWindow?: number; + readonly id: string; + readonly displayName?: string; + /** The model's max context window in tokens (e.g. 200000). Optional — providers that don't report it leave it undefined. */ + readonly contextWindow?: number; } /** @@ -122,26 +122,26 @@ export interface ModelInfo { * concrete LLM API is behind it. */ export interface ProviderContract { - /** Unique identifier for this provider (e.g. "anthropic", "openai-compat"). */ - readonly id: string; + /** Unique identifier for this provider (e.g. "anthropic", "openai-compat"). */ + readonly id: string; - /** - * Stream a response for the given messages and available tools. - * The provider yields `ProviderEvent`s incrementally; the kernel drives - * tool dispatch and chunk assembly from them. - */ - readonly stream: ( - messages: readonly ChatMessage[], - tools: readonly ToolContract[], - opts?: ProviderStreamOptions, - ) => AsyncIterable; + /** + * Stream a response for the given messages and available tools. + * The provider yields `ProviderEvent`s incrementally; the kernel drives + * tool dispatch and chunk assembly from them. + */ + readonly stream: ( + messages: readonly ChatMessage[], + tools: readonly ToolContract[], + opts?: ProviderStreamOptions, + ) => AsyncIterable; - /** - * Enumerate the models this provider can serve, each in its own way (e.g. an - * OpenAI-compatible provider GETs `/v1/models`). Optional: a provider that - * cannot (or chooses not to) enumerate omits it, and a catalog simply lists - * none for it. A future multi-credential design may pass per-credential - * credentials in; today the provider uses the key it resolved at activate. - */ - readonly listModels?: () => Promise; + /** + * Enumerate the models this provider can serve, each in its own way (e.g. an + * OpenAI-compatible provider GETs `/v1/models`). Optional: a provider that + * cannot (or chooses not to) enumerate omits it, and a catalog simply lists + * none for it. A future multi-credential design may pass per-credential + * credentials in; today the provider uses the key it resolved at activate. + */ + readonly listModels?: () => Promise; } diff --git a/packages/kernel/src/contracts/runtime.ts b/packages/kernel/src/contracts/runtime.ts index dc74c84..71d2211 100644 --- a/packages/kernel/src/contracts/runtime.ts +++ b/packages/kernel/src/contracts/runtime.ts @@ -26,14 +26,14 @@ export type EventEmitter = (event: AgentEvent) => void; * passed through verbatim without losing autocomplete on the known values. */ export type FinishReason = - | "stop" - | "tool-calls" - | "length" - | "content-filter" - | "max-steps" - | "error" - | "aborted" - | (string & {}); + | "stop" + | "tool-calls" + | "length" + | "content-filter" + | "max-steps" + | "error" + | "aborted" + | (string & {}); /** * Input to `runTurn` — everything the kernel needs to execute one turn. @@ -41,121 +41,121 @@ export type FinishReason = * the kernel never reads config or resolves providers/tools itself. */ export interface RunTurnInput { - /** The resolved provider to stream from. */ - readonly provider: ProviderContract; - - /** The conversation history (including system prompt as first message). */ - readonly messages: readonly ChatMessage[]; - - /** The tool set available for this turn (may be empty). */ - readonly tools: readonly ToolContract[]; - - /** How to dispatch tool calls within each step. */ - readonly dispatch: ToolDispatchPolicy; - - /** The emitter the kernel calls for each outward event. */ - readonly emit: EventEmitter; - - /** - * Identifiers used to attribute every emitted `AgentEvent`. The kernel does - * not generate these — the session-orchestrator owns turn/conversation identity - * and passes them in, so events are traceable to their conversation. - */ - readonly conversationId: string; - readonly turnId: string; - - /** - * Optional per-turn provider options (model, temperature, maxTokens, - * systemPrompt). The orchestrator resolves these; the kernel forwards them - * verbatim to `provider.stream` and never interprets them. A provider may - * also be pre-configured at construction and ignore these. - */ - readonly providerOpts?: ProviderStreamOptions; - - /** Cancellation signal for the entire turn. */ - readonly signal?: AbortSignal; - - /** - * Working directory for this turn's tool execution. The kernel does NOT - * interpret it — it forwards the value verbatim to each `ToolExecuteContext.cwd` - * so tools resolve/contain paths against it. It never enters the model prompt, - * so it does not affect prompt caching. When omitted, tools fall back to their - * own configured/default workdir. - */ - readonly cwd?: string; - - /** - * The computer to execute this turn's tools on (SSH support). Omitted/undefined - * = LOCAL (today's behavior). When set, it is an SSH config alias; the kernel - * does NOT interpret it — it forwards the value verbatim to each - * `ToolExecuteContext.computerId`, exactly like `cwd`. It never enters the - * model prompt, so it does not affect prompt caching. Tools resolve their - * execution backend (local vs. remote) from this; see - * `notes/ssh-support-plan.md`. - */ - readonly computerId?: string; - - /** - * Optional logger for structured span instrumentation. The runtime opens - * turn/step/tool-call spans using this logger. If omitted, no spans are - * emitted (backward-compatible with callers that don't yet pass a logger). - */ - readonly logger?: Logger; - - /** - * Optional monotonic-ish clock (milliseconds) for emitting wall-clock timing - * on outward events: per-step `step-complete` (ttft/decode/genTotal), tool - * execution `durationMs` on `tool-result`, and turn `durationMs` on `done`. - * Injected (not ambient) so the runtime stays pure and deterministic in tests. - * If omitted, the runtime emits no such timing (the optional fields stay - * absent) — backward-compatible with callers that don't provide a clock. - */ - readonly now?: () => number; - - /** - * Optional. Called by the runtime at the tool-result boundary — after a - * step whose tool calls have all executed, before the next step begins — - * to drain messages to inject alongside the tool results. Whatever it - * returns is appended as user-role messages to the next step's input, so - * a caller can inject mid-turn guidance the model sees with the tool - * results. When omitted or returning an empty array, no injection happens - * (the runtime is unchanged). - * - * Injected (not ambient) so the kernel stays pure: it owns no queue and - * names no feature — it just calls the callback and appends what it gets. - * Only invoked when a step PRODUCED tool calls (the tool-result boundary); - * a step that ends without tool calls does not drain (the caller decides - * what to do with any pending messages after the turn ends). - */ - readonly drainSteering?: () => readonly ChatMessage[]; - - /** - * Optional. Called by the runtime after each step's messages are finalized - * (the assistant message + tool-result messages are built). The caller can - * use this to persist step messages incrementally — assigning seq numbers - * during generation so consumers can `GET /conversations/:id?sinceSeq=N` - * mid-turn. When omitted, the caller must persist all messages at turn end - * (via `RunTurnResult.messages`). The messages passed to this callback are - * the SAME objects in `RunTurnResult.messages` — the caller must NOT - * double-persist them. - */ - readonly onStepComplete?: (messages: readonly ChatMessage[]) => Promise | void; - - /** - * Optional injected retry strategy for retryable provider errors (e.g. HTTP - * 429 / 5xx "overloaded"). When omitted, a retryable error ends the step - * exactly as before (backward-compatible). When provided, the runtime wraps - * `provider.stream()` consumption in a retry loop: on a retryable error - * (an emitted `error` ProviderEvent with `retryable === true`, OR a thrown - * error) — ONLY when no content was emitted yet this step (the safety - * invariant — never duplicate partial output) — it asks `retry.delayFor` - * for a delay, emits a transient `provider-retry` AgentEvent, sleeps via the - * injected `retry.sleep` (abortable), and re-calls `provider.stream()`. - * - * Injected (not ambient): the kernel imports no timer and owns no schedule. - * Mirrors the `now`/`logger` injection pattern — optional + backward-compatible. - */ - readonly retry?: RetryStrategy; + /** The resolved provider to stream from. */ + readonly provider: ProviderContract; + + /** The conversation history (including system prompt as first message). */ + readonly messages: readonly ChatMessage[]; + + /** The tool set available for this turn (may be empty). */ + readonly tools: readonly ToolContract[]; + + /** How to dispatch tool calls within each step. */ + readonly dispatch: ToolDispatchPolicy; + + /** The emitter the kernel calls for each outward event. */ + readonly emit: EventEmitter; + + /** + * Identifiers used to attribute every emitted `AgentEvent`. The kernel does + * not generate these — the session-orchestrator owns turn/conversation identity + * and passes them in, so events are traceable to their conversation. + */ + readonly conversationId: string; + readonly turnId: string; + + /** + * Optional per-turn provider options (model, temperature, maxTokens, + * systemPrompt). The orchestrator resolves these; the kernel forwards them + * verbatim to `provider.stream` and never interprets them. A provider may + * also be pre-configured at construction and ignore these. + */ + readonly providerOpts?: ProviderStreamOptions; + + /** Cancellation signal for the entire turn. */ + readonly signal?: AbortSignal; + + /** + * Working directory for this turn's tool execution. The kernel does NOT + * interpret it — it forwards the value verbatim to each `ToolExecuteContext.cwd` + * so tools resolve/contain paths against it. It never enters the model prompt, + * so it does not affect prompt caching. When omitted, tools fall back to their + * own configured/default workdir. + */ + readonly cwd?: string; + + /** + * The computer to execute this turn's tools on (SSH support). Omitted/undefined + * = LOCAL (today's behavior). When set, it is an SSH config alias; the kernel + * does NOT interpret it — it forwards the value verbatim to each + * `ToolExecuteContext.computerId`, exactly like `cwd`. It never enters the + * model prompt, so it does not affect prompt caching. Tools resolve their + * execution backend (local vs. remote) from this; see + * `notes/ssh-support-plan.md`. + */ + readonly computerId?: string; + + /** + * Optional logger for structured span instrumentation. The runtime opens + * turn/step/tool-call spans using this logger. If omitted, no spans are + * emitted (backward-compatible with callers that don't yet pass a logger). + */ + readonly logger?: Logger; + + /** + * Optional monotonic-ish clock (milliseconds) for emitting wall-clock timing + * on outward events: per-step `step-complete` (ttft/decode/genTotal), tool + * execution `durationMs` on `tool-result`, and turn `durationMs` on `done`. + * Injected (not ambient) so the runtime stays pure and deterministic in tests. + * If omitted, the runtime emits no such timing (the optional fields stay + * absent) — backward-compatible with callers that don't provide a clock. + */ + readonly now?: () => number; + + /** + * Optional. Called by the runtime at the tool-result boundary — after a + * step whose tool calls have all executed, before the next step begins — + * to drain messages to inject alongside the tool results. Whatever it + * returns is appended as user-role messages to the next step's input, so + * a caller can inject mid-turn guidance the model sees with the tool + * results. When omitted or returning an empty array, no injection happens + * (the runtime is unchanged). + * + * Injected (not ambient) so the kernel stays pure: it owns no queue and + * names no feature — it just calls the callback and appends what it gets. + * Only invoked when a step PRODUCED tool calls (the tool-result boundary); + * a step that ends without tool calls does not drain (the caller decides + * what to do with any pending messages after the turn ends). + */ + readonly drainSteering?: () => readonly ChatMessage[]; + + /** + * Optional. Called by the runtime after each step's messages are finalized + * (the assistant message + tool-result messages are built). The caller can + * use this to persist step messages incrementally — assigning seq numbers + * during generation so consumers can `GET /conversations/:id?sinceSeq=N` + * mid-turn. When omitted, the caller must persist all messages at turn end + * (via `RunTurnResult.messages`). The messages passed to this callback are + * the SAME objects in `RunTurnResult.messages` — the caller must NOT + * double-persist them. + */ + readonly onStepComplete?: (messages: readonly ChatMessage[]) => Promise | void; + + /** + * Optional injected retry strategy for retryable provider errors (e.g. HTTP + * 429 / 5xx "overloaded"). When omitted, a retryable error ends the step + * exactly as before (backward-compatible). When provided, the runtime wraps + * `provider.stream()` consumption in a retry loop: on a retryable error + * (an emitted `error` ProviderEvent with `retryable === true`, OR a thrown + * error) — ONLY when no content was emitted yet this step (the safety + * invariant — never duplicate partial output) — it asks `retry.delayFor` + * for a delay, emits a transient `provider-retry` AgentEvent, sleeps via the + * injected `retry.sleep` (abortable), and re-calls `provider.stream()`. + * + * Injected (not ambient): the kernel imports no timer and owns no schedule. + * Mirrors the `now`/`logger` injection pattern — optional + backward-compatible. + */ + readonly retry?: RetryStrategy; } /** @@ -163,14 +163,14 @@ export interface RunTurnInput { * persist the new messages and report usage. */ export interface RunTurnResult { - /** The assistant messages produced by this turn (appended to history). */ - readonly messages: readonly ChatMessage[]; + /** The assistant messages produced by this turn (appended to history). */ + readonly messages: readonly ChatMessage[]; - /** Aggregated token usage across all steps in the turn. */ - readonly usage: Usage; + /** Aggregated token usage across all steps in the turn. */ + readonly usage: Usage; - /** Why the turn ended. */ - readonly finishReason: FinishReason; + /** Why the turn ended. */ + readonly finishReason: FinishReason; } /** @@ -187,16 +187,16 @@ export interface RunTurnResult { * the step exactly as before). */ export interface RetryStrategy { - /** - * Pure, deterministic decision: given the 0-based attempt index, return the - * delay in ms to sleep before the next retry, or `undefined` to stop (budget - * exhausted). No I/O, no clock — fully testable. - */ - readonly delayFor: (attempt: number) => number | undefined; - /** - * Injected effect: actually sleep for the given ms. Must honor the abort - * signal — reject when aborted so the turn seals `aborted`. The kernel - * imports no timer; the shell provides a `setTimeout`-based implementation. - */ - readonly sleep: (ms: number, signal: AbortSignal) => Promise; + /** + * Pure, deterministic decision: given the 0-based attempt index, return the + * delay in ms to sleep before the next retry, or `undefined` to stop (budget + * exhausted). No I/O, no clock — fully testable. + */ + readonly delayFor: (attempt: number) => number | undefined; + /** + * Injected effect: actually sleep for the given ms. Must honor the abort + * signal — reject when aborted so the turn seals `aborted`. The kernel + * imports no timer; the shell provides a `setTimeout`-based implementation. + */ + readonly sleep: (ms: number, signal: AbortSignal) => Promise; } diff --git a/packages/kernel/src/contracts/tool.ts b/packages/kernel/src/contracts/tool.ts index 589fbd0..897b86e 100644 --- a/packages/kernel/src/contracts/tool.ts +++ b/packages/kernel/src/contracts/tool.ts @@ -16,22 +16,22 @@ import type { Logger } from "./logging.js"; * Using a structural type (not a library) keeps the kernel dependency-free. */ export interface ToolParameterSchema { - readonly type: "object"; - readonly properties?: Readonly>; - readonly required?: readonly string[]; - readonly additionalProperties?: boolean; - readonly description?: string; + readonly type: "object"; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean; + readonly description?: string; } /** A single property within a tool's parameter schema. */ export interface JsonSchemaProperty { - readonly type?: string; - readonly description?: string; - readonly enum?: readonly string[]; - readonly items?: JsonSchemaProperty; - readonly properties?: Readonly>; - readonly required?: readonly string[]; - readonly default?: unknown; + readonly type?: string; + readonly description?: string; + readonly enum?: readonly string[]; + readonly items?: JsonSchemaProperty; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly default?: unknown; } /** @@ -40,56 +40,56 @@ export interface JsonSchemaProperty { * concurrent tool output is never interleaved ambiguously. */ export interface ToolExecuteContext { - /** Unique id of the tool-call this execution serves. */ - readonly toolCallId: string; - - /** - * Stream output from the tool. The kernel attributes every call to the - * tool-call id, so concurrent shell output from different tools is - * correctly separated. - */ - readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; - - /** - * Cancellation signal. An aborted turn sets this so in-flight tool work - * can clean up rather than leak. - */ - readonly signal: AbortSignal; - - /** - * Pre-bound Logger scoped to this tool-call span. Tools log correlated - * without a global (P3). The kernel stamps extensionId, conversationId, - * turnId, and spanId automatically. - */ - readonly log: Logger; - - /** - * Working directory for this turn, forwarded verbatim from `RunTurnInput.cwd`. - * Tools that touch the filesystem resolve and contain paths against it. - * Optional: when omitted, a tool falls back to its own configured/default - * workdir. The kernel never interprets it. - */ - readonly cwd?: string; - - /** - * The conversation this tool-call belongs to. Tools that maintain - * per-conversation state (e.g. a todo list) key on this. Forwarded - * verbatim from `RunTurnInput.conversationId`. Optional: when omitted, - * a tool has no conversation scope (e.g. a global tool). - */ - readonly conversationId?: string; - - /** - * The computer this tool-call executes on (SSH support). When - * omitted/undefined, execution is LOCAL (today's behavior — the tool uses - * the local node fs/child_process). When set, it is an SSH config alias - * (see `notes/ssh-support-plan.md` §3); a tool resolves a remote - * `ExecBackend` for it via its injected resolver. The kernel never - * interprets it — it forwards the value verbatim from - * `RunTurnInput.computerId`, exactly like `cwd`. It never enters the model - * prompt, so it does not affect prompt caching. - */ - readonly computerId?: string; + /** Unique id of the tool-call this execution serves. */ + readonly toolCallId: string; + + /** + * Stream output from the tool. The kernel attributes every call to the + * tool-call id, so concurrent shell output from different tools is + * correctly separated. + */ + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; + + /** + * Cancellation signal. An aborted turn sets this so in-flight tool work + * can clean up rather than leak. + */ + readonly signal: AbortSignal; + + /** + * Pre-bound Logger scoped to this tool-call span. Tools log correlated + * without a global (P3). The kernel stamps extensionId, conversationId, + * turnId, and spanId automatically. + */ + readonly log: Logger; + + /** + * Working directory for this turn, forwarded verbatim from `RunTurnInput.cwd`. + * Tools that touch the filesystem resolve and contain paths against it. + * Optional: when omitted, a tool falls back to its own configured/default + * workdir. The kernel never interprets it. + */ + readonly cwd?: string; + + /** + * The conversation this tool-call belongs to. Tools that maintain + * per-conversation state (e.g. a todo list) key on this. Forwarded + * verbatim from `RunTurnInput.conversationId`. Optional: when omitted, + * a tool has no conversation scope (e.g. a global tool). + */ + readonly conversationId?: string; + + /** + * The computer this tool-call executes on (SSH support). When + * omitted/undefined, execution is LOCAL (today's behavior — the tool uses + * the local node fs/child_process). When set, it is an SSH config alias + * (see `notes/ssh-support-plan.md` §3); a tool resolves a remote + * `ExecBackend` for it via its injected resolver. The kernel never + * interprets it — it forwards the value verbatim from + * `RunTurnInput.computerId`, exactly like `cwd`. It never enters the model + * prompt, so it does not affect prompt caching. + */ + readonly computerId?: string; } /** @@ -98,8 +98,8 @@ export interface ToolExecuteContext { * react without the kernel interpreting the content. */ export interface ToolResult { - readonly content: string; - readonly isError?: boolean; + readonly content: string; + readonly isError?: boolean; } /** @@ -108,9 +108,9 @@ export interface ToolResult { * to the matched tool's `execute`. */ export interface ToolCall { - readonly id: string; - readonly name: string; - readonly input: unknown; + readonly id: string; + readonly name: string; + readonly input: unknown; } /** @@ -119,26 +119,26 @@ export interface ToolCall { * concrete tools exist. */ export interface ToolContract { - /** Unique name the model uses to invoke this tool. */ - readonly name: string; - - /** Human-readable description shown to the model. */ - readonly description: string; - - /** JSON-Schema-ish parameter declaration (structural, no library dep). */ - readonly parameters: ToolParameterSchema; - - /** - * Execute the tool with parsed input. The kernel provides a per-call - * context (cancellation, output streaming, attribution). - */ - readonly execute: (args: unknown, ctx: ToolExecuteContext) => Promise; - - /** - * Whether this tool is safe to run concurrently with other tools. - * When `false`, the kernel serializes this tool's calls even when the - * dispatch policy allows parallelism. Defaults to `true` if omitted. - * This overrides the global setting downward only (never widens parallelism). - */ - readonly concurrencySafe?: boolean; + /** Unique name the model uses to invoke this tool. */ + readonly name: string; + + /** Human-readable description shown to the model. */ + readonly description: string; + + /** JSON-Schema-ish parameter declaration (structural, no library dep). */ + readonly parameters: ToolParameterSchema; + + /** + * Execute the tool with parsed input. The kernel provides a per-call + * context (cancellation, output streaming, attribution). + */ + readonly execute: (args: unknown, ctx: ToolExecuteContext) => Promise; + + /** + * Whether this tool is safe to run concurrently with other tools. + * When `false`, the kernel serializes this tool's calls even when the + * dispatch policy allows parallelism. Defaults to `true` if omitted. + * This overrides the global setting downward only (never widens parallelism). + */ + readonly concurrencySafe?: boolean; } diff --git a/packages/kernel/src/host/dag.test.ts b/packages/kernel/src/host/dag.test.ts index 1a0431f..352965c 100644 --- a/packages/kernel/src/host/dag.test.ts +++ b/packages/kernel/src/host/dag.test.ts @@ -3,105 +3,105 @@ import type { Manifest } from "../contracts/extension.js"; import { resolveActivationOrder } from "./dag.js"; function manifest(id: string, deps?: readonly string[]): Manifest { - const base: Manifest = { - id, - name: id, - version: "1.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - }; - if (deps !== undefined) { - return { ...base, dependsOn: deps }; - } - return base; + const base: Manifest = { + id, + name: id, + version: "1.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + }; + if (deps !== undefined) { + return { ...base, dependsOn: deps }; + } + return base; } describe("resolveActivationOrder", () => { - it("returns empty array for no extensions", () => { - expect(resolveActivationOrder([])).toEqual([]); - }); - - it("returns a single extension with no deps", () => { - const result = resolveActivationOrder([manifest("a")]); - expect(result.map((m) => m.id)).toEqual(["a"]); - }); - - it("orders a linear chain (A → B → C)", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c", ["b"]); - - const result = resolveActivationOrder([c, b, a]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c")); - }); - - it("orders a diamond (A → B, A → C, B → D, C → D)", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c", ["a"]); - const d = manifest("d", ["b", "c"]); - - const result = resolveActivationOrder([d, c, b, a]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c")); - expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d")); - expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); - }); - - it("handles independent sets (no deps between them)", () => { - const a = manifest("a"); - const b = manifest("b"); - const c = manifest("c"); - - const result = resolveActivationOrder([a, b, c]); - expect(result).toHaveLength(3); - expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]); - }); - - it("throws on a cycle (A → B → A)", () => { - const a = manifest("a", ["b"]); - const b = manifest("b", ["a"]); - - expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i); - }); - - it("throws on a larger cycle (A → B → C → A)", () => { - const a = manifest("a", ["c"]); - const b = manifest("b", ["a"]); - const c = manifest("c", ["b"]); - - expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i); - }); - - it("throws on a missing dependency", () => { - const a = manifest("a", ["nonexistent"]); - - expect(() => resolveActivationOrder([a])).toThrow(/not available/); - }); - - it("throws on duplicate extension ids", () => { - const a1 = manifest("a"); - const a2 = manifest("a"); - - expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i); - }); - - it("handles mixed independent and dependent extensions", () => { - const a = manifest("a"); - const b = manifest("b", ["a"]); - const c = manifest("c"); - const d = manifest("d", ["c"]); - - const result = resolveActivationOrder([a, b, c, d]); - const ids = result.map((m) => m.id); - - expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); - expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); - expect(result).toHaveLength(4); - }); + it("returns empty array for no extensions", () => { + expect(resolveActivationOrder([])).toEqual([]); + }); + + it("returns a single extension with no deps", () => { + const result = resolveActivationOrder([manifest("a")]); + expect(result.map((m) => m.id)).toEqual(["a"]); + }); + + it("orders a linear chain (A → B → C)", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c", ["b"]); + + const result = resolveActivationOrder([c, b, a]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c")); + }); + + it("orders a diamond (A → B, A → C, B → D, C → D)", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c", ["a"]); + const d = manifest("d", ["b", "c"]); + + const result = resolveActivationOrder([d, c, b, a]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c")); + expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d")); + expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); + }); + + it("handles independent sets (no deps between them)", () => { + const a = manifest("a"); + const b = manifest("b"); + const c = manifest("c"); + + const result = resolveActivationOrder([a, b, c]); + expect(result).toHaveLength(3); + expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]); + }); + + it("throws on a cycle (A → B → A)", () => { + const a = manifest("a", ["b"]); + const b = manifest("b", ["a"]); + + expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i); + }); + + it("throws on a larger cycle (A → B → C → A)", () => { + const a = manifest("a", ["c"]); + const b = manifest("b", ["a"]); + const c = manifest("c", ["b"]); + + expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i); + }); + + it("throws on a missing dependency", () => { + const a = manifest("a", ["nonexistent"]); + + expect(() => resolveActivationOrder([a])).toThrow(/not available/); + }); + + it("throws on duplicate extension ids", () => { + const a1 = manifest("a"); + const a2 = manifest("a"); + + expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i); + }); + + it("handles mixed independent and dependent extensions", () => { + const a = manifest("a"); + const b = manifest("b", ["a"]); + const c = manifest("c"); + const d = manifest("d", ["c"]); + + const result = resolveActivationOrder([a, b, c, d]); + const ids = result.map((m) => m.id); + + expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b")); + expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d")); + expect(result).toHaveLength(4); + }); }); diff --git a/packages/kernel/src/host/dag.ts b/packages/kernel/src/host/dag.ts index 5cde2f5..1ce35e6 100644 --- a/packages/kernel/src/host/dag.ts +++ b/packages/kernel/src/host/dag.ts @@ -1,64 +1,64 @@ import type { Manifest } from "../contracts/extension.js"; export function resolveActivationOrder(manifests: readonly Manifest[]): Manifest[] { - const byId = new Map(); - for (const m of manifests) { - if (byId.has(m.id)) { - throw new Error(`Duplicate extension id: "${m.id}"`); - } - byId.set(m.id, m); - } + const byId = new Map(); + for (const m of manifests) { + if (byId.has(m.id)) { + throw new Error(`Duplicate extension id: "${m.id}"`); + } + byId.set(m.id, m); + } - for (const m of manifests) { - for (const dep of m.dependsOn ?? []) { - if (!byId.has(dep)) { - throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`); - } - } - } + for (const m of manifests) { + for (const dep of m.dependsOn ?? []) { + if (!byId.has(dep)) { + throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`); + } + } + } - const inDegree = new Map(); - const dependents = new Map(); + const inDegree = new Map(); + const dependents = new Map(); - for (const m of manifests) { - inDegree.set(m.id, 0); - dependents.set(m.id, []); - } + for (const m of manifests) { + inDegree.set(m.id, 0); + dependents.set(m.id, []); + } - for (const m of manifests) { - for (const dep of m.dependsOn ?? []) { - const list = dependents.get(dep); - if (list !== undefined) list.push(m.id); - inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1); - } - } + for (const m of manifests) { + for (const dep of m.dependsOn ?? []) { + const list = dependents.get(dep); + if (list !== undefined) list.push(m.id); + inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1); + } + } - const queue: string[] = []; - for (const [id, deg] of inDegree) { - if (deg === 0) queue.push(id); - } + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } - const result: Manifest[] = []; - let idx = 0; - while (idx < queue.length) { - const id = queue[idx]; - if (id === undefined) break; - idx++; - const m = byId.get(id); - if (m === undefined) continue; - result.push(m); - for (const dep of dependents.get(id) ?? []) { - const newDeg = (inDegree.get(dep) ?? 1) - 1; - inDegree.set(dep, newDeg); - if (newDeg === 0) queue.push(dep); - } - } + const result: Manifest[] = []; + let idx = 0; + while (idx < queue.length) { + const id = queue[idx]; + if (id === undefined) break; + idx++; + const m = byId.get(id); + if (m === undefined) continue; + result.push(m); + for (const dep of dependents.get(id) ?? []) { + const newDeg = (inDegree.get(dep) ?? 1) - 1; + inDegree.set(dep, newDeg); + if (newDeg === 0) queue.push(dep); + } + } - if (result.length !== manifests.length) { - const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id)); - const ids = remaining.map((m) => m.id).join(", "); - throw new Error(`Dependency cycle detected among extensions: ${ids}`); - } + if (result.length !== manifests.length) { + const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id)); + const ids = remaining.map((m) => m.id).join(", "); + throw new Error(`Dependency cycle detected among extensions: ${ids}`); + } - return result; + return result; } diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 0067091..88e2836 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -2,1276 +2,1276 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBus } from "../bus/bus.js"; import type { AuthContract } from "../contracts/auth.js"; import type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - ManifestContributions, - PermissionDecision, - PermissionGate, - PermissionRequest, - ScheduledJob, - SecretsAccess, - StorageNamespace, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + ManifestContributions, + PermissionDecision, + PermissionGate, + PermissionRequest, + ScheduledJob, + SecretsAccess, + StorageNamespace, } from "../contracts/extension.js"; import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js"; import type { - Attributes, - ErrorAttributes, - LogDeps, - Logger, - LogRecord, - LogSink, + Attributes, + ErrorAttributes, + LogDeps, + Logger, + LogRecord, + LogSink, } from "../contracts/logging.js"; import type { ProviderContract } from "../contracts/provider.js"; import type { ToolContract } from "../contracts/tool.js"; import { createHost, type HostDeps, KERNEL_API_VERSION } from "./host.js"; interface FakeLogger extends Logger { - readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; + readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>; } function createFakeLogger(): FakeLogger { - const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; - return { - logs, - debug: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "debug", message, attrs }); - } else { - logs.push({ level: "debug", message }); - } - }, - info: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "info", message, attrs }); - } else { - logs.push({ level: "info", message }); - } - }, - warn: (message: string, attrs?: Attributes) => { - if (attrs !== undefined) { - logs.push({ level: "warn", message, attrs }); - } else { - logs.push({ level: "warn", message }); - } - }, - error: (message: string, attrs?: ErrorAttributes) => { - if (attrs !== undefined) { - logs.push({ level: "error", message, attrs }); - } else { - logs.push({ level: "error", message }); - } - }, - child( - _ctx: Partial & { readonly attrs?: Attributes }, - ): Logger { - return createFakeLogger(); - }, - span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { - return { - id: "fake-span", - log: createFakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = []; + return { + logs, + debug: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "debug", message, attrs }); + } else { + logs.push({ level: "debug", message }); + } + }, + info: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "info", message, attrs }); + } else { + logs.push({ level: "info", message }); + } + }, + warn: (message: string, attrs?: Attributes) => { + if (attrs !== undefined) { + logs.push({ level: "warn", message, attrs }); + } else { + logs.push({ level: "warn", message }); + } + }, + error: (message: string, attrs?: ErrorAttributes) => { + if (attrs !== undefined) { + logs.push({ level: "error", message, attrs }); + } else { + logs.push({ level: "error", message }); + } + }, + child( + _ctx: Partial & { readonly attrs?: Attributes }, + ): Logger { + return createFakeLogger(); + }, + span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span { + return { + id: "fake-span", + log: createFakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function createFakeLogSink(): LogSink & { readonly records: LogRecord[] } { - const records: LogRecord[] = []; - return { - records, - emit: (record: LogRecord) => { - records.push(record); - }, - }; + const records: LogRecord[] = []; + return { + records, + emit: (record: LogRecord) => { + records.push(record); + }, + }; } function createFakeLogDeps(): LogDeps { - let idCounter = 0; - return { - now: () => 1000 + idCounter * 100, - newId: () => `span-${++idCounter}`, - }; + let idCounter = 0; + return { + now: () => 1000 + idCounter * 100, + newId: () => `span-${++idCounter}`, + }; } function createFakeConfig(): ConfigAccess { - return { - get: () => undefined, - getAll: () => ({}), - }; + return { + get: () => undefined, + getAll: () => ({}), + }; } function createFakeStorageFactory(): (ns: string) => StorageNamespace { - const stores = new Map>(); - return (ns: string) => { - let store = stores.get(ns); - if (!store) { - store = new Map(); - stores.set(ns, store); - } - const s = store; - return { - get: async (key: string) => s.get(key) ?? null, - set: async (key: string, value: string) => { - s.set(key, value); - }, - delete: async (key: string) => { - s.delete(key); - }, - has: async (key: string) => s.has(key), - keys: async () => [...s.keys()], - }; - }; + const stores = new Map>(); + return (ns: string) => { + let store = stores.get(ns); + if (!store) { + store = new Map(); + stores.set(ns, store); + } + const s = store; + return { + get: async (key: string) => s.get(key) ?? null, + set: async (key: string, value: string) => { + s.set(key, value); + }, + delete: async (key: string) => { + s.delete(key); + }, + has: async (key: string) => s.has(key), + keys: async () => [...s.keys()], + }; + }; } function createFakeSecrets(): SecretsAccess { - const store = new Map(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - }; + const store = new Map(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + }; } function createFakePermissions(): PermissionGate { - return { - check: async (_request: PermissionRequest): Promise => ({ - allowed: true, - }), - }; + return { + check: async (_request: PermissionRequest): Promise => ({ + allowed: true, + }), + }; } function createFakeScheduler(): { - readonly register: (job: ScheduledJob) => void; - readonly jobs: ScheduledJob[]; + readonly register: (job: ScheduledJob) => void; + readonly jobs: ScheduledJob[]; } { - const jobs: ScheduledJob[] = []; - return { - register: (job: ScheduledJob) => { - jobs.push(job); - }, - jobs, - }; + const jobs: ScheduledJob[] = []; + return { + register: (job: ScheduledJob) => { + jobs.push(job); + }, + jobs, + }; } function createFakeEvents(): EventsEmitter & { readonly emitted: unknown[] } { - const emitted: unknown[] = []; - return { - emitted, - emit: (event) => { - emitted.push(event); - }, - }; + const emitted: unknown[] = []; + return { + emitted, + emit: (event) => { + emitted.push(event); + }, + }; } function createExtension( - id: string, - opts: { - readonly dependsOn?: readonly string[]; - readonly apiVersion?: string; - readonly activate?: (host: HostAPI) => void | Promise; - readonly deactivate?: () => void | Promise; - readonly contributes?: ManifestContributions; - } = {}, + id: string, + opts: { + readonly dependsOn?: readonly string[]; + readonly apiVersion?: string; + readonly activate?: (host: HostAPI) => void | Promise; + readonly deactivate?: () => void | Promise; + readonly contributes?: ManifestContributions; + } = {}, ): Extension { - const base: Manifest = { - id, - name: id, - version: "1.0.0", - apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, - trust: "bundled", - }; - const manifest: Manifest = - opts.dependsOn !== undefined - ? { ...base, dependsOn: opts.dependsOn } - : opts.contributes !== undefined - ? { ...base, contributes: opts.contributes } - : base; - const ext: Extension = { - manifest, - activate: opts.activate ?? (() => {}), - }; - if (opts.deactivate !== undefined) { - return { ...ext, deactivate: opts.deactivate }; - } - return ext; + const base: Manifest = { + id, + name: id, + version: "1.0.0", + apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`, + trust: "bundled", + }; + const manifest: Manifest = + opts.dependsOn !== undefined + ? { ...base, dependsOn: opts.dependsOn } + : opts.contributes !== undefined + ? { ...base, contributes: opts.contributes } + : base; + const ext: Extension = { + manifest, + activate: opts.activate ?? (() => {}), + }; + if (opts.deactivate !== undefined) { + return { ...ext, deactivate: opts.deactivate }; + } + return ext; } function createFakeTool(name: string): ToolContract { - return { - name, - description: `Tool ${name}`, - parameters: { type: "object" }, - execute: async () => ({ content: "ok" }), - }; + return { + name, + description: `Tool ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; } function createFakeProvider(id: string): ProviderContract { - return { - id, - stream: async function* () {}, - }; + return { + id, + stream: async function* () {}, + }; } function createFakeAuth(id: string): AuthContract { - return { - id, - resolve: async () => null, - }; + return { + id, + resolve: async () => null, + }; } describe("createHost", () => { - let logger: FakeLogger; - let logSink: ReturnType; - let logDeps: LogDeps; - let deps: HostDeps; - let scheduler: ReturnType; - let events: ReturnType; - - beforeEach(() => { - logger = createFakeLogger(); - logSink = createFakeLogSink(); - logDeps = createFakeLogDeps(); - scheduler = createFakeScheduler(); - events = createFakeEvents(); - deps = { - logger, - config: createFakeConfig(), - storageFactory: createFakeStorageFactory(), - secrets: createFakeSecrets(), - permissions: createFakePermissions(), - scheduler, - bus: createBus(logger), - events, - logSink, - logDeps, - }; - }); - - describe("activation order", () => { - it("activates extensions in topological order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - dependsOn: ["a"], - activate: () => { - order.push("b"); - }, - }); - const c = createExtension("c", { - dependsOn: ["b"], - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - expect(order).toEqual(["a", "b", "c"]); - }); - - it("activates independent extensions", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("b"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(order).toHaveLength(2); - expect(order).toContain("a"); - expect(order).toContain("b"); - }); - }); - - describe("fault isolation", () => { - it("a throwing extension is isolated — others still activate", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("a"); - }, - }); - const b = createExtension("b", { - activate: () => { - throw new Error("boom"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - - expect(order).toEqual(["a", "c"]); - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("b"); - expect(host.getDisabled()[0]?.reason).toContain("boom"); - }); - - it("an async-rejecting extension is isolated", async () => { - const a = createExtension("a", { - activate: async () => { - throw new Error("async fail"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("a"); - }); - }); - - describe("apiVersion compatibility", () => { - it("activates compatible extensions", async () => { - const ext = createExtension("good", { - apiVersion: `^${KERNEL_API_VERSION}`, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("disables incompatible extensions without crashing", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(1); - expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); - expect(host.getDisabled()[0]?.reason).toContain("incompatible"); - }); - - it("logs a warning for disabled extensions", async () => { - const ext = createExtension("bad", { - apiVersion: "^99.0.0", - }); - - const host = createHost([ext], deps); - await host.activate(); - - const warnings = logger.logs.filter((l) => l.level === "warn"); - expect(warnings).toHaveLength(1); - expect(warnings[0]?.message).toContain("bad"); - }); - }); - - describe("registries", () => { - it("defineTool populates the tool registry", async () => { - const tool = createFakeTool("read-file"); - const ext = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getTools().size).toBe(1); - expect(host.getTool("read-file")).toBe(tool); - }); - - it("defineProvider populates the provider registry", async () => { - const provider = createFakeProvider("anthropic"); - const ext = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getProviders().size).toBe(1); - expect(host.getProvider("anthropic")).toBe(provider); - }); - - it("defineAuth populates the auth registry", async () => { - const auth = createFakeAuth("apikey"); - const ext = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getAuthProviders().size).toBe(1); - expect(host.getAuthProvider("apikey")).toBe(auth); - }); - - it("getService returns what an extension provided via provideService", async () => { - const handle = defineService<{ value: number }>("test/svc"); - const ext = createExtension("svc-provider", { - activate: (host) => { - host.provideService(handle, { value: 42 }); - }, - }); - const consumer = createExtension("svc-consumer", { - dependsOn: ["svc-provider"], - activate: (host) => { - const svc = host.getService(handle); - expect(svc.value).toBe(42); - }, - }); - - const host = createHost([ext, consumer], deps); - await host.activate(); - - expect(host.getDisabled()).toHaveLength(0); - }); - - it("multiple extensions contribute to the same registry", async () => { - const ext1 = createExtension("tools-a", { - activate: (host) => { - host.defineTool(createFakeTool("tool-a")); - }, - }); - const ext2 = createExtension("tools-b", { - activate: (host) => { - host.defineTool(createFakeTool("tool-b")); - }, - }); - - const host = createHost([ext1, ext2], deps); - await host.activate(); - - expect(host.getTools().size).toBe(2); - expect(host.getTool("tool-a")).toBeDefined(); - expect(host.getTool("tool-b")).toBeDefined(); - }); - }); - - describe("scheduler", () => { - it("collects scheduled jobs and forwards to sink", async () => { - const job: ScheduledJob = { - id: "cache-warm", - cron: "*/5 * * * *", - execute: () => {}, - }; - const ext = createExtension("scheduler-ext", { - activate: (host) => { - host.scheduler.register(job); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getScheduledJobs()).toHaveLength(1); - expect(host.getScheduledJobs()[0]).toBe(job); - expect(scheduler.jobs).toHaveLength(1); - expect(scheduler.jobs[0]).toBe(job); - }); - }); - - describe("migrations", () => { - it("collects migrations from manifests", async () => { - const ext = createExtension("store-ext", { - contributes: { migrations: ["001-init", "002-add-index"] }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); - }); - }); - - describe("deactivation", () => { - it("deactivates in reverse activation order", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => { - order.push("activate-a"); - }, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => { - order.push("activate-b"); - }, - deactivate: () => { - order.push("deactivate-b"); - }, - }); - const c = createExtension("c", { - activate: () => { - order.push("activate-c"); - }, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual([ - "activate-a", - "activate-b", - "activate-c", - "deactivate-c", - "deactivate-b", - "deactivate-a", - ]); - }); - - it("a failing deactivate does not prevent others", async () => { - const order: string[] = []; - - const a = createExtension("a", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-a"); - }, - }); - const b = createExtension("b", { - activate: () => {}, - deactivate: () => { - throw new Error("deactivate boom"); - }, - }); - const c = createExtension("c", { - activate: () => {}, - deactivate: () => { - order.push("deactivate-c"); - }, - }); - - const host = createHost([a, b, c], deps); - await host.activate(); - await host.deactivate(); - - expect(order).toEqual(["deactivate-c", "deactivate-a"]); - const errors = logger.logs.filter((l) => l.level === "error"); - expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); - expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); - }); - }); - - describe("HostAPI delegation", () => { - it("on/addFilter delegate to the bus", async () => { - const hook = defineEventHook("test/host-event"); - const received: string[] = []; - - const ext = createExtension("hook-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - deps.bus.emit(hook, "hello"); - expect(received).toEqual(["hello"]); - }); - - it("emit dispatches to handlers registered via on", async () => { - const hook = defineEventHook("test/emit-dispatch"); - const received: string[] = []; - - const ext = createExtension("emit-ext", { - activate: (host) => { - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - api.emit(hook, "world"); - expect(received).toEqual(["world"]); - }); - - it("emit isolates a throwing handler (does not propagate)", async () => { - const hook = defineEventHook("test/emit-isolation"); - const received: string[] = []; - - const ext = createExtension("emit-isolation-ext", { - activate: (host) => { - host.on(hook, () => { - throw new Error("handler boom"); - }); - host.on(hook, (payload) => { - received.push(payload); - }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.emit(hook, "safe")).not.toThrow(); - expect(received).toEqual(["safe"]); - }); - - it("applyFilters threads a value through registered filters in order", async () => { - const hook = defineFilter("test/text-transform"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-first`); - host.addFilter(hook, (value) => `${value}-second`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "start"); - expect(result).toBe("start-first-second"); - }); - - it("applyFilters returns the input unchanged when no filters are registered", async () => { - const hook = defineFilter("test/unused-filter"); - - const ext = createExtension("no-filter-ext", { - activate: () => {}, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "unchanged"); - expect(result).toBe("unchanged"); - }); - - it("storage delegates to the factory", async () => { - let storageResult: StorageNamespace | undefined; - - const ext = createExtension("storage-ext", { - activate: (host) => { - storageResult = host.storage("my-ns"); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(storageResult).toBeDefined(); - await storageResult?.set("key", "value"); - expect(await storageResult?.get("key")).toBe("value"); - }); - - it("events delegates to the emitter", async () => { - const ext = createExtension("event-ext", { - activate: (host) => { - host.events.emit({ type: "custom", data: 42 }); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - expect(events.emitted).toHaveLength(1); - expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); - }); - }); - - describe("HostAPI registry access", () => { - it("getTools returns registered tools via HostAPI", async () => { - const tool = createFakeTool("read-file"); - let capturedTools: ReadonlyMap | undefined; - - const producer = createExtension("tools-fs", { - activate: (host) => { - host.defineTool(tool); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["tools-fs"], - activate: (host) => { - capturedTools = host.getTools(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedTools).toBeDefined(); - expect(capturedTools?.size).toBe(1); - expect(capturedTools?.get("read-file")).toBe(tool); - }); - - it("getProviders returns registered providers via HostAPI", async () => { - const provider = createFakeProvider("anthropic"); - let capturedProviders: ReadonlyMap | undefined; - - const producer = createExtension("provider-anthropic", { - activate: (host) => { - host.defineProvider(provider); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["provider-anthropic"], - activate: (host) => { - capturedProviders = host.getProviders(); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedProviders).toBeDefined(); - expect(capturedProviders?.size).toBe(1); - expect(capturedProviders?.get("anthropic")).toBe(provider); - }); - - it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { - const auth = createFakeAuth("apikey"); - let capturedAuth: ReadonlyMap | undefined; - let capturedSingle: AuthContract | undefined; - - const producer = createExtension("auth-apikey", { - activate: (host) => { - host.defineAuth(auth); - }, - }); - const consumer = createExtension("consumer", { - dependsOn: ["auth-apikey"], - activate: (host) => { - capturedAuth = host.getAuthProviders(); - capturedSingle = host.getAuthProvider("apikey"); - }, - }); - - const host = createHost([producer, consumer], deps); - await host.activate(); - - expect(capturedAuth).toBeDefined(); - expect(capturedAuth?.size).toBe(1); - expect(capturedAuth?.get("apikey")).toBe(auth); - expect(capturedSingle).toBe(auth); - }); - }); - - describe("getExtensions", () => { - it("returns empty array when no extensions are activated", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getExtensions()).toEqual([]); - }); - - it("returns manifests of all activated extensions", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b"); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(2); - expect(exts.map((e) => e.id)).toContain("ext-a"); - expect(exts.map((e) => e.id)).toContain("ext-b"); - }); - - it("returns manifests in activation order", async () => { - const a = createExtension("a"); - const b = createExtension("b", { dependsOn: ["a"] }); - const c = createExtension("c", { dependsOn: ["b"] }); - - const host = createHost([c, b, a], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); - }); - - it("excludes extensions that failed to activate", async () => { - const a = createExtension("good"); - const b = createExtension("bad", { - activate: () => { - throw new Error("boom"); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("excludes extensions disabled by apiVersion incompatibility", async () => { - const good = createExtension("good"); - const bad = createExtension("bad", { apiVersion: "^99.0.0" }); - - const host = createHost([good, bad], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(exts).toHaveLength(1); - expect(exts[0]?.id).toBe("good"); - }); - - it("returns a frozen array", async () => { - const ext = createExtension("ext"); - const host = createHost([ext], deps); - await host.activate(); - - const exts = host.getExtensions(); - expect(Object.isFrozen(exts)).toBe(true); - }); - - it("HostAPI getExtensions reflects activated extensions after full activation", async () => { - const a = createExtension("ext-a"); - const b = createExtension("ext-b", { - dependsOn: ["ext-a"], - activate: () => {}, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // Use getHostAPI() to verify the post-activation view - const api = host.getHostAPI(); - const capturedExtsAfter = api.getExtensions(); - - expect(capturedExtsAfter).toHaveLength(2); - expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); - }); - - it("HostAPI getExtensions during activation sees only previously activated", async () => { - const seenDuringActivation: string[][] = []; - - const a = createExtension("a", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - const b = createExtension("b", { - activate: (host) => { - seenDuringActivation.push(host.getExtensions().map((e) => e.id)); - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - // When a activates, activated[] is empty (a hasn't been pushed yet) - // When b activates, activated[] has [a] (b hasn't been pushed yet) - expect(seenDuringActivation).toEqual([[], ["a"]]); - }); - }); - - describe("DAG errors", () => { - it("throws on missing dependency", () => { - const ext = createExtension("a", { dependsOn: ["missing"] }); - expect(() => createHost([ext], deps)).toThrow(/not available/); - }); - - it("throws on dependency cycle", () => { - const a = createExtension("a", { dependsOn: ["b"] }); - const b = createExtension("b", { dependsOn: ["a"] }); - expect(() => createHost([a, b], deps)).toThrow(/cycle/i); - }); - }); - - describe("empty host", () => { - it("works with no extensions", async () => { - const host = createHost([], deps); - await host.activate(); - - expect(host.getTools().size).toBe(0); - expect(host.getProviders().size).toBe(0); - expect(host.getAuthProviders().size).toBe(0); - expect(host.getScheduledJobs()).toHaveLength(0); - expect(host.getMigrations()).toHaveLength(0); - expect(host.getDisabled()).toHaveLength(0); - }); - }); - - describe("getHostAPI", () => { - it("returns a HostAPI whose read-views reflect registrations from activation", async () => { - const tool = createFakeTool("read-file"); - const provider = createFakeProvider("anthropic"); - const auth = createFakeAuth("apikey"); - - const ext = createExtension("multi-ext", { - activate: (host) => { - host.defineTool(tool); - host.defineProvider(provider); - host.defineAuth(auth); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - - expect(api.getTools().size).toBe(1); - expect(api.getTools().get("read-file")).toBe(tool); - - expect(api.getProviders().size).toBe(1); - expect(api.getProviders().get("anthropic")).toBe(provider); - - expect(api.getAuthProviders().size).toBe(1); - expect(api.getAuthProvider("apikey")).toBe(auth); - }); - - it("throws on defineTool after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineTool(createFakeTool("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineProvider after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("throws on defineAuth after activation", async () => { - const ext = createExtension("ext", { activate: () => {} }); - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( - "Registration not available after activation", - ); - }); - - it("applyFilters is available on registration-closed HostAPI", async () => { - const hook = defineFilter("test/closed-filter"); - - const ext = createExtension("filter-ext", { - activate: (host) => { - host.addFilter(hook, (value) => `${value}-filtered`); - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const api = host.getHostAPI(); - const result = await api.applyFilters(hook, "input"); - expect(result).toBe("input-filtered"); - }); - }); - - describe("auto-scoped logger (D6)", () => { - it("each extension's logger stamps its own manifest.id as extensionId", async () => { - let extALogger: Logger | undefined; - let extBLogger: Logger | undefined; - - const a = createExtension("ext-a", { - activate: (host) => { - extALogger = host.logger; - }, - }); - const b = createExtension("ext-b", { - activate: (host) => { - extBLogger = host.logger; - }, - }); - - const host = createHost([a, b], deps); - await host.activate(); - - extALogger?.info("from-a"); - extBLogger?.info("from-b"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(2); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("ext-a"); - expect(logRecords[0].msg).toBe("from-a"); - } - if (logRecords[1]?.kind === "log") { - expect(logRecords[1].extensionId).toBe("ext-b"); - expect(logRecords[1].msg).toBe("from-b"); - } - }); - - it("an extension cannot spoof extensionId — it is auto-stamped", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("real-id", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - // child() cannot override extensionId - const child = extLogger?.child({ extensionId: "spoofed" }); - child?.info("msg"); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].extensionId).toBe("real-id"); - } - }); - - it("host.logger.error uses structured { err } shape", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - extLogger?.error("something broke", { err: new Error("boom") }); - - const logRecords = logSink.records.filter((r) => r.kind === "log"); - expect(logRecords).toHaveLength(1); - if (logRecords[0]?.kind === "log") { - expect(logRecords[0].level).toBe("error"); - expect(logRecords[0].msg).toBe("something broke"); - expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); - } - }); - - it("a throwing sink does NOT break the caller", async () => { - const brokenSink: LogSink = { - emit() { - throw new Error("sink down"); - }, - }; - const brokenDeps: HostDeps = { - ...deps, - logSink: brokenSink, - }; - - let extLogger: Logger | undefined; - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], brokenDeps); - await host.activate(); - - // Should not throw - expect(() => extLogger?.info("msg")).not.toThrow(); - }); - - it("span() + end() emit incremental span-open and span-close records", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("my-span", { key: "value" }); - span?.setAttributes({ extra: "attr" }); - span?.end({ attrs: { result: "ok" } }); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - - expect(spanOpens).toHaveLength(1); - expect(spanCloses).toHaveLength(1); - - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].name).toBe("my-span"); - expect(spanOpens[0].extensionId).toBe("ext"); - expect(spanOpens[0].attributes?.key).toBe("value"); - } - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].name).toBe("my-span"); - expect(spanCloses[0].status).toBe("ok"); - expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); - expect(spanCloses[0].attributes?.extra).toBe("attr"); - expect(spanCloses[0].attributes?.result).toBe("ok"); - } - }); - - it("span() with body emits body on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBe('{"payload":"hello"}'); - } - }); - - it("span() without body omits body field on span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-body"); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - expect(spanOpens).toHaveLength(1); - if (spanOpens[0]?.kind === "span-open") { - expect(spanOpens[0].body).toBeUndefined(); - } - }); - - it("child() with body emits body on child span-open record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("parent"); - const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); - child?.end(); - span?.end(); - - const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); - const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); - expect(childOpen).toBeDefined(); - if (childOpen?.kind === "span-open") { - expect(childOpen.body).toBe('{"child":"body"}'); - } - }); - - it("end() with body emits body on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("close-body"); - span?.end({ body: '{"result":"data"}' }); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBe('{"result":"data"}'); - } - }); - - it("end() without body omits body field on span-close record", async () => { - let extLogger: Logger | undefined; - - const ext = createExtension("ext", { - activate: (host) => { - extLogger = host.logger; - }, - }); - - const host = createHost([ext], deps); - await host.activate(); - - const span = extLogger?.span("no-close-body"); - span?.end(); - - const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); - expect(spanCloses).toHaveLength(1); - if (spanCloses[0]?.kind === "span-close") { - expect(spanCloses[0].body).toBeUndefined(); - } - }); - }); + let logger: FakeLogger; + let logSink: ReturnType; + let logDeps: LogDeps; + let deps: HostDeps; + let scheduler: ReturnType; + let events: ReturnType; + + beforeEach(() => { + logger = createFakeLogger(); + logSink = createFakeLogSink(); + logDeps = createFakeLogDeps(); + scheduler = createFakeScheduler(); + events = createFakeEvents(); + deps = { + logger, + config: createFakeConfig(), + storageFactory: createFakeStorageFactory(), + secrets: createFakeSecrets(), + permissions: createFakePermissions(), + scheduler, + bus: createBus(logger), + events, + logSink, + logDeps, + }; + }); + + describe("activation order", () => { + it("activates extensions in topological order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + dependsOn: ["a"], + activate: () => { + order.push("b"); + }, + }); + const c = createExtension("c", { + dependsOn: ["b"], + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + expect(order).toEqual(["a", "b", "c"]); + }); + + it("activates independent extensions", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("b"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(order).toHaveLength(2); + expect(order).toContain("a"); + expect(order).toContain("b"); + }); + }); + + describe("fault isolation", () => { + it("a throwing extension is isolated — others still activate", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("a"); + }, + }); + const b = createExtension("b", { + activate: () => { + throw new Error("boom"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + + expect(order).toEqual(["a", "c"]); + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("b"); + expect(host.getDisabled()[0]?.reason).toContain("boom"); + }); + + it("an async-rejecting extension is isolated", async () => { + const a = createExtension("a", { + activate: async () => { + throw new Error("async fail"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("a"); + }); + }); + + describe("apiVersion compatibility", () => { + it("activates compatible extensions", async () => { + const ext = createExtension("good", { + apiVersion: `^${KERNEL_API_VERSION}`, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("disables incompatible extensions without crashing", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(1); + expect(host.getDisabled()[0]?.manifest.id).toBe("bad"); + expect(host.getDisabled()[0]?.reason).toContain("incompatible"); + }); + + it("logs a warning for disabled extensions", async () => { + const ext = createExtension("bad", { + apiVersion: "^99.0.0", + }); + + const host = createHost([ext], deps); + await host.activate(); + + const warnings = logger.logs.filter((l) => l.level === "warn"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain("bad"); + }); + }); + + describe("registries", () => { + it("defineTool populates the tool registry", async () => { + const tool = createFakeTool("read-file"); + const ext = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getTools().size).toBe(1); + expect(host.getTool("read-file")).toBe(tool); + }); + + it("defineProvider populates the provider registry", async () => { + const provider = createFakeProvider("anthropic"); + const ext = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getProviders().size).toBe(1); + expect(host.getProvider("anthropic")).toBe(provider); + }); + + it("defineAuth populates the auth registry", async () => { + const auth = createFakeAuth("apikey"); + const ext = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getAuthProviders().size).toBe(1); + expect(host.getAuthProvider("apikey")).toBe(auth); + }); + + it("getService returns what an extension provided via provideService", async () => { + const handle = defineService<{ value: number }>("test/svc"); + const ext = createExtension("svc-provider", { + activate: (host) => { + host.provideService(handle, { value: 42 }); + }, + }); + const consumer = createExtension("svc-consumer", { + dependsOn: ["svc-provider"], + activate: (host) => { + const svc = host.getService(handle); + expect(svc.value).toBe(42); + }, + }); + + const host = createHost([ext, consumer], deps); + await host.activate(); + + expect(host.getDisabled()).toHaveLength(0); + }); + + it("multiple extensions contribute to the same registry", async () => { + const ext1 = createExtension("tools-a", { + activate: (host) => { + host.defineTool(createFakeTool("tool-a")); + }, + }); + const ext2 = createExtension("tools-b", { + activate: (host) => { + host.defineTool(createFakeTool("tool-b")); + }, + }); + + const host = createHost([ext1, ext2], deps); + await host.activate(); + + expect(host.getTools().size).toBe(2); + expect(host.getTool("tool-a")).toBeDefined(); + expect(host.getTool("tool-b")).toBeDefined(); + }); + }); + + describe("scheduler", () => { + it("collects scheduled jobs and forwards to sink", async () => { + const job: ScheduledJob = { + id: "cache-warm", + cron: "*/5 * * * *", + execute: () => {}, + }; + const ext = createExtension("scheduler-ext", { + activate: (host) => { + host.scheduler.register(job); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getScheduledJobs()).toHaveLength(1); + expect(host.getScheduledJobs()[0]).toBe(job); + expect(scheduler.jobs).toHaveLength(1); + expect(scheduler.jobs[0]).toBe(job); + }); + }); + + describe("migrations", () => { + it("collects migrations from manifests", async () => { + const ext = createExtension("store-ext", { + contributes: { migrations: ["001-init", "002-add-index"] }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]); + }); + }); + + describe("deactivation", () => { + it("deactivates in reverse activation order", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => { + order.push("activate-a"); + }, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => { + order.push("activate-b"); + }, + deactivate: () => { + order.push("deactivate-b"); + }, + }); + const c = createExtension("c", { + activate: () => { + order.push("activate-c"); + }, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual([ + "activate-a", + "activate-b", + "activate-c", + "deactivate-c", + "deactivate-b", + "deactivate-a", + ]); + }); + + it("a failing deactivate does not prevent others", async () => { + const order: string[] = []; + + const a = createExtension("a", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-a"); + }, + }); + const b = createExtension("b", { + activate: () => {}, + deactivate: () => { + throw new Error("deactivate boom"); + }, + }); + const c = createExtension("c", { + activate: () => {}, + deactivate: () => { + order.push("deactivate-c"); + }, + }); + + const host = createHost([a, b, c], deps); + await host.activate(); + await host.deactivate(); + + expect(order).toEqual(["deactivate-c", "deactivate-a"]); + const errors = logger.logs.filter((l) => l.level === "error"); + expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true); + expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true); + }); + }); + + describe("HostAPI delegation", () => { + it("on/addFilter delegate to the bus", async () => { + const hook = defineEventHook("test/host-event"); + const received: string[] = []; + + const ext = createExtension("hook-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + deps.bus.emit(hook, "hello"); + expect(received).toEqual(["hello"]); + }); + + it("emit dispatches to handlers registered via on", async () => { + const hook = defineEventHook("test/emit-dispatch"); + const received: string[] = []; + + const ext = createExtension("emit-ext", { + activate: (host) => { + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + api.emit(hook, "world"); + expect(received).toEqual(["world"]); + }); + + it("emit isolates a throwing handler (does not propagate)", async () => { + const hook = defineEventHook("test/emit-isolation"); + const received: string[] = []; + + const ext = createExtension("emit-isolation-ext", { + activate: (host) => { + host.on(hook, () => { + throw new Error("handler boom"); + }); + host.on(hook, (payload) => { + received.push(payload); + }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.emit(hook, "safe")).not.toThrow(); + expect(received).toEqual(["safe"]); + }); + + it("applyFilters threads a value through registered filters in order", async () => { + const hook = defineFilter("test/text-transform"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-first`); + host.addFilter(hook, (value) => `${value}-second`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "start"); + expect(result).toBe("start-first-second"); + }); + + it("applyFilters returns the input unchanged when no filters are registered", async () => { + const hook = defineFilter("test/unused-filter"); + + const ext = createExtension("no-filter-ext", { + activate: () => {}, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "unchanged"); + expect(result).toBe("unchanged"); + }); + + it("storage delegates to the factory", async () => { + let storageResult: StorageNamespace | undefined; + + const ext = createExtension("storage-ext", { + activate: (host) => { + storageResult = host.storage("my-ns"); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(storageResult).toBeDefined(); + await storageResult?.set("key", "value"); + expect(await storageResult?.get("key")).toBe("value"); + }); + + it("events delegates to the emitter", async () => { + const ext = createExtension("event-ext", { + activate: (host) => { + host.events.emit({ type: "custom", data: 42 }); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + expect(events.emitted).toHaveLength(1); + expect(events.emitted[0]).toEqual({ type: "custom", data: 42 }); + }); + }); + + describe("HostAPI registry access", () => { + it("getTools returns registered tools via HostAPI", async () => { + const tool = createFakeTool("read-file"); + let capturedTools: ReadonlyMap | undefined; + + const producer = createExtension("tools-fs", { + activate: (host) => { + host.defineTool(tool); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["tools-fs"], + activate: (host) => { + capturedTools = host.getTools(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedTools).toBeDefined(); + expect(capturedTools?.size).toBe(1); + expect(capturedTools?.get("read-file")).toBe(tool); + }); + + it("getProviders returns registered providers via HostAPI", async () => { + const provider = createFakeProvider("anthropic"); + let capturedProviders: ReadonlyMap | undefined; + + const producer = createExtension("provider-anthropic", { + activate: (host) => { + host.defineProvider(provider); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["provider-anthropic"], + activate: (host) => { + capturedProviders = host.getProviders(); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedProviders).toBeDefined(); + expect(capturedProviders?.size).toBe(1); + expect(capturedProviders?.get("anthropic")).toBe(provider); + }); + + it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { + const auth = createFakeAuth("apikey"); + let capturedAuth: ReadonlyMap | undefined; + let capturedSingle: AuthContract | undefined; + + const producer = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["auth-apikey"], + activate: (host) => { + capturedAuth = host.getAuthProviders(); + capturedSingle = host.getAuthProvider("apikey"); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedAuth).toBeDefined(); + expect(capturedAuth?.size).toBe(1); + expect(capturedAuth?.get("apikey")).toBe(auth); + expect(capturedSingle).toBe(auth); + }); + }); + + describe("getExtensions", () => { + it("returns empty array when no extensions are activated", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getExtensions()).toEqual([]); + }); + + it("returns manifests of all activated extensions", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b"); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(2); + expect(exts.map((e) => e.id)).toContain("ext-a"); + expect(exts.map((e) => e.id)).toContain("ext-b"); + }); + + it("returns manifests in activation order", async () => { + const a = createExtension("a"); + const b = createExtension("b", { dependsOn: ["a"] }); + const c = createExtension("c", { dependsOn: ["b"] }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); + }); + + it("excludes extensions that failed to activate", async () => { + const a = createExtension("good"); + const b = createExtension("bad", { + activate: () => { + throw new Error("boom"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("excludes extensions disabled by apiVersion incompatibility", async () => { + const good = createExtension("good"); + const bad = createExtension("bad", { apiVersion: "^99.0.0" }); + + const host = createHost([good, bad], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("returns a frozen array", async () => { + const ext = createExtension("ext"); + const host = createHost([ext], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(Object.isFrozen(exts)).toBe(true); + }); + + it("HostAPI getExtensions reflects activated extensions after full activation", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b", { + dependsOn: ["ext-a"], + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // Use getHostAPI() to verify the post-activation view + const api = host.getHostAPI(); + const capturedExtsAfter = api.getExtensions(); + + expect(capturedExtsAfter).toHaveLength(2); + expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); + }); + + it("HostAPI getExtensions during activation sees only previously activated", async () => { + const seenDuringActivation: string[][] = []; + + const a = createExtension("a", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + const b = createExtension("b", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // When a activates, activated[] is empty (a hasn't been pushed yet) + // When b activates, activated[] has [a] (b hasn't been pushed yet) + expect(seenDuringActivation).toEqual([[], ["a"]]); + }); + }); + + describe("DAG errors", () => { + it("throws on missing dependency", () => { + const ext = createExtension("a", { dependsOn: ["missing"] }); + expect(() => createHost([ext], deps)).toThrow(/not available/); + }); + + it("throws on dependency cycle", () => { + const a = createExtension("a", { dependsOn: ["b"] }); + const b = createExtension("b", { dependsOn: ["a"] }); + expect(() => createHost([a, b], deps)).toThrow(/cycle/i); + }); + }); + + describe("empty host", () => { + it("works with no extensions", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getTools().size).toBe(0); + expect(host.getProviders().size).toBe(0); + expect(host.getAuthProviders().size).toBe(0); + expect(host.getScheduledJobs()).toHaveLength(0); + expect(host.getMigrations()).toHaveLength(0); + expect(host.getDisabled()).toHaveLength(0); + }); + }); + + describe("getHostAPI", () => { + it("returns a HostAPI whose read-views reflect registrations from activation", async () => { + const tool = createFakeTool("read-file"); + const provider = createFakeProvider("anthropic"); + const auth = createFakeAuth("apikey"); + + const ext = createExtension("multi-ext", { + activate: (host) => { + host.defineTool(tool); + host.defineProvider(provider); + host.defineAuth(auth); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + + expect(api.getTools().size).toBe(1); + expect(api.getTools().get("read-file")).toBe(tool); + + expect(api.getProviders().size).toBe(1); + expect(api.getProviders().get("anthropic")).toBe(provider); + + expect(api.getAuthProviders().size).toBe(1); + expect(api.getAuthProvider("apikey")).toBe(auth); + }); + + it("throws on defineTool after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineTool(createFakeTool("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineProvider after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineProvider(createFakeProvider("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("throws on defineAuth after activation", async () => { + const ext = createExtension("ext", { activate: () => {} }); + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + expect(() => api.defineAuth(createFakeAuth("late"))).toThrow( + "Registration not available after activation", + ); + }); + + it("applyFilters is available on registration-closed HostAPI", async () => { + const hook = defineFilter("test/closed-filter"); + + const ext = createExtension("filter-ext", { + activate: (host) => { + host.addFilter(hook, (value) => `${value}-filtered`); + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const api = host.getHostAPI(); + const result = await api.applyFilters(hook, "input"); + expect(result).toBe("input-filtered"); + }); + }); + + describe("auto-scoped logger (D6)", () => { + it("each extension's logger stamps its own manifest.id as extensionId", async () => { + let extALogger: Logger | undefined; + let extBLogger: Logger | undefined; + + const a = createExtension("ext-a", { + activate: (host) => { + extALogger = host.logger; + }, + }); + const b = createExtension("ext-b", { + activate: (host) => { + extBLogger = host.logger; + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + extALogger?.info("from-a"); + extBLogger?.info("from-b"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(2); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("ext-a"); + expect(logRecords[0].msg).toBe("from-a"); + } + if (logRecords[1]?.kind === "log") { + expect(logRecords[1].extensionId).toBe("ext-b"); + expect(logRecords[1].msg).toBe("from-b"); + } + }); + + it("an extension cannot spoof extensionId — it is auto-stamped", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("real-id", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + // child() cannot override extensionId + const child = extLogger?.child({ extensionId: "spoofed" }); + child?.info("msg"); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].extensionId).toBe("real-id"); + } + }); + + it("host.logger.error uses structured { err } shape", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + extLogger?.error("something broke", { err: new Error("boom") }); + + const logRecords = logSink.records.filter((r) => r.kind === "log"); + expect(logRecords).toHaveLength(1); + if (logRecords[0]?.kind === "log") { + expect(logRecords[0].level).toBe("error"); + expect(logRecords[0].msg).toBe("something broke"); + expect(logRecords[0].attributes?.["error.message"]).toBe("boom"); + } + }); + + it("a throwing sink does NOT break the caller", async () => { + const brokenSink: LogSink = { + emit() { + throw new Error("sink down"); + }, + }; + const brokenDeps: HostDeps = { + ...deps, + logSink: brokenSink, + }; + + let extLogger: Logger | undefined; + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], brokenDeps); + await host.activate(); + + // Should not throw + expect(() => extLogger?.info("msg")).not.toThrow(); + }); + + it("span() + end() emit incremental span-open and span-close records", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("my-span", { key: "value" }); + span?.setAttributes({ extra: "attr" }); + span?.end({ attrs: { result: "ok" } }); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + + expect(spanOpens).toHaveLength(1); + expect(spanCloses).toHaveLength(1); + + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].name).toBe("my-span"); + expect(spanOpens[0].extensionId).toBe("ext"); + expect(spanOpens[0].attributes?.key).toBe("value"); + } + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].name).toBe("my-span"); + expect(spanCloses[0].status).toBe("ok"); + expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0); + expect(spanCloses[0].attributes?.extra).toBe("attr"); + expect(spanCloses[0].attributes?.result).toBe("ok"); + } + }); + + it("span() with body emits body on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}'); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBe('{"payload":"hello"}'); + } + }); + + it("span() without body omits body field on span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-body"); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + expect(spanOpens).toHaveLength(1); + if (spanOpens[0]?.kind === "span-open") { + expect(spanOpens[0].body).toBeUndefined(); + } + }); + + it("child() with body emits body on child span-open record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("parent"); + const child = span?.child("child-name", { k: "v" }, '{"child":"body"}'); + child?.end(); + span?.end(); + + const spanOpens = logSink.records.filter((r) => r.kind === "span-open"); + const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name"); + expect(childOpen).toBeDefined(); + if (childOpen?.kind === "span-open") { + expect(childOpen.body).toBe('{"child":"body"}'); + } + }); + + it("end() with body emits body on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("close-body"); + span?.end({ body: '{"result":"data"}' }); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBe('{"result":"data"}'); + } + }); + + it("end() without body omits body field on span-close record", async () => { + let extLogger: Logger | undefined; + + const ext = createExtension("ext", { + activate: (host) => { + extLogger = host.logger; + }, + }); + + const host = createHost([ext], deps); + await host.activate(); + + const span = extLogger?.span("no-close-body"); + span?.end(); + + const spanCloses = logSink.records.filter((r) => r.kind === "span-close"); + expect(spanCloses).toHaveLength(1); + if (spanCloses[0]?.kind === "span-close") { + expect(spanCloses[0].body).toBeUndefined(); + } + }); + }); }); diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index 2a262be..f74881f 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -1,22 +1,22 @@ import type { Bus } from "../bus/bus.js"; import type { AuthContract } from "../contracts/auth.js"; import type { - ConfigAccess, - EventsEmitter, - Extension, - HostAPI, - Manifest, - PermissionGate, - ScheduledJob, - SecretsAccess, - StorageNamespace, + ConfigAccess, + EventsEmitter, + Extension, + HostAPI, + Manifest, + PermissionGate, + ScheduledJob, + SecretsAccess, + StorageNamespace, } from "../contracts/extension.js"; import type { - EventHandler, - EventHookDescriptor, - FilterDescriptor, - FilterHandler, - ServiceHandle, + EventHandler, + EventHookDescriptor, + FilterDescriptor, + FilterHandler, + ServiceHandle, } from "../contracts/hooks.js"; import type { LogDeps, Logger, LogSink } from "../contracts/logging.js"; import type { ProviderContract } from "../contracts/provider.js"; @@ -28,210 +28,210 @@ import { isApiVersionCompatible } from "./version.js"; export const KERNEL_API_VERSION = "0.1.0"; export interface DisabledExtension { - readonly manifest: Manifest; - readonly reason: string; + readonly manifest: Manifest; + readonly reason: string; } export interface HostDeps { - readonly logger: Logger; - readonly config: ConfigAccess; - readonly storageFactory: (namespace: string) => StorageNamespace; - readonly secrets: SecretsAccess; - readonly permissions: PermissionGate; - readonly scheduler: { readonly register: (job: ScheduledJob) => void }; - readonly bus: Bus; - readonly events: EventsEmitter; - readonly logSink: LogSink; - readonly logDeps: LogDeps; + readonly logger: Logger; + readonly config: ConfigAccess; + readonly storageFactory: (namespace: string) => StorageNamespace; + readonly secrets: SecretsAccess; + readonly permissions: PermissionGate; + readonly scheduler: { readonly register: (job: ScheduledJob) => void }; + readonly bus: Bus; + readonly events: EventsEmitter; + readonly logSink: LogSink; + readonly logDeps: LogDeps; } export interface Host { - readonly activate: () => Promise; - readonly deactivate: () => Promise; - readonly getTools: () => ReadonlyMap; - readonly getTool: (name: string) => ToolContract | undefined; - readonly getProviders: () => ReadonlyMap; - readonly getProvider: (id: string) => ProviderContract | undefined; - readonly getAuthProviders: () => ReadonlyMap; - readonly getAuthProvider: (id: string) => AuthContract | undefined; - readonly getScheduledJobs: () => readonly ScheduledJob[]; - readonly getMigrations: () => readonly string[]; - readonly getDisabled: () => readonly DisabledExtension[]; - readonly getExtensions: () => readonly Manifest[]; - readonly getHostAPI: () => HostAPI; + readonly activate: () => Promise; + readonly deactivate: () => Promise; + readonly getTools: () => ReadonlyMap; + readonly getTool: (name: string) => ToolContract | undefined; + readonly getProviders: () => ReadonlyMap; + readonly getProvider: (id: string) => ProviderContract | undefined; + readonly getAuthProviders: () => ReadonlyMap; + readonly getAuthProvider: (id: string) => AuthContract | undefined; + readonly getScheduledJobs: () => readonly ScheduledJob[]; + readonly getMigrations: () => readonly string[]; + readonly getDisabled: () => readonly DisabledExtension[]; + readonly getExtensions: () => readonly Manifest[]; + readonly getHostAPI: () => HostAPI; } export function createHost(extensions: readonly Extension[], deps: HostDeps): Host { - const tools = new Map(); - const providers = new Map(); - const authProviders = new Map(); - const scheduledJobs: ScheduledJob[] = []; - const migrations: string[] = []; - const disabled: DisabledExtension[] = []; - const activated: Extension[] = []; + const tools = new Map(); + const providers = new Map(); + const authProviders = new Map(); + const scheduledJobs: ScheduledJob[] = []; + const migrations: string[] = []; + const disabled: DisabledExtension[] = []; + const activated: Extension[] = []; - const ordered = resolveActivationOrder(extensions.map((e) => e.manifest)); - const extById = new Map(); - for (const ext of extensions) { - extById.set(ext.manifest.id, ext); - } + const ordered = resolveActivationOrder(extensions.map((e) => e.manifest)); + const extById = new Map(); + for (const ext of extensions) { + extById.set(ext.manifest.id, ext); + } - const compatible: Extension[] = []; - for (const m of ordered) { - const ext = extById.get(m.id); - if (ext === undefined) continue; - if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) { - compatible.push(ext); - } else { - disabled.push({ - manifest: m, - reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`, - }); - deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`); - } - } + const compatible: Extension[] = []; + for (const m of ordered) { + const ext = extById.get(m.id); + if (ext === undefined) continue; + if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) { + compatible.push(ext); + } else { + disabled.push({ + manifest: m, + reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`, + }); + deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`); + } + } - for (const ext of compatible) { - const extMigrations = ext.manifest.contributes?.migrations; - if (extMigrations) { - for (const migration of extMigrations) { - migrations.push(migration); - } - } - } + for (const ext of compatible) { + const extMigrations = ext.manifest.contributes?.migrations; + if (extMigrations) { + for (const migration of extMigrations) { + migrations.push(migration); + } + } + } - function buildHostAPI( - extensionId: string, - opts?: { readonly registrationClosed?: boolean }, - ): HostAPI { - const closed = opts?.registrationClosed ?? false; - const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps); - return { - defineTool(tool: ToolContract) { - if (closed) throw new Error("Registration not available after activation"); - tools.set(tool.name, tool); - }, - defineProvider(provider: ProviderContract) { - if (closed) throw new Error("Registration not available after activation"); - providers.set(provider.id, provider); - }, - defineAuth(auth: AuthContract) { - if (closed) throw new Error("Registration not available after activation"); - authProviders.set(auth.id, auth); - }, - on(hook: EventHookDescriptor, handler: EventHandler) { - return deps.bus.on(hook, handler); - }, - emit(hook: EventHookDescriptor, payload: TPayload) { - deps.bus.emit(hook, payload); - }, - addFilter(hook: FilterDescriptor, fn: FilterHandler) { - return deps.bus.addFilter(hook, fn); - }, - async applyFilters( - hook: FilterDescriptor, - value: TValue, - opts?: { readonly failClosed?: boolean }, - ): Promise { - return deps.bus.applyFilters(hook, value, opts); - }, - provideService(handle: ServiceHandle, impl: T) { - deps.bus.provideService(handle, impl); - }, - getService(handle: ServiceHandle): T { - return deps.bus.getService(handle); - }, - storage(namespace: string): StorageNamespace { - return deps.storageFactory(namespace); - }, - config: deps.config, - secrets: deps.secrets, - permissions: deps.permissions, - events: deps.events, - logger: extLogger, - getProviders() { - return providers; - }, - getTools() { - return tools; - }, - getAuthProviders() { - return authProviders; - }, - getAuthProvider(id: string) { - return authProviders.get(id); - }, - getExtensions() { - return Object.freeze(activated.map((e) => e.manifest)); - }, - scheduler: { - register(job: ScheduledJob) { - scheduledJobs.push(job); - deps.scheduler.register(job); - }, - }, - }; - } + function buildHostAPI( + extensionId: string, + opts?: { readonly registrationClosed?: boolean }, + ): HostAPI { + const closed = opts?.registrationClosed ?? false; + const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps); + return { + defineTool(tool: ToolContract) { + if (closed) throw new Error("Registration not available after activation"); + tools.set(tool.name, tool); + }, + defineProvider(provider: ProviderContract) { + if (closed) throw new Error("Registration not available after activation"); + providers.set(provider.id, provider); + }, + defineAuth(auth: AuthContract) { + if (closed) throw new Error("Registration not available after activation"); + authProviders.set(auth.id, auth); + }, + on(hook: EventHookDescriptor, handler: EventHandler) { + return deps.bus.on(hook, handler); + }, + emit(hook: EventHookDescriptor, payload: TPayload) { + deps.bus.emit(hook, payload); + }, + addFilter(hook: FilterDescriptor, fn: FilterHandler) { + return deps.bus.addFilter(hook, fn); + }, + async applyFilters( + hook: FilterDescriptor, + value: TValue, + opts?: { readonly failClosed?: boolean }, + ): Promise { + return deps.bus.applyFilters(hook, value, opts); + }, + provideService(handle: ServiceHandle, impl: T) { + deps.bus.provideService(handle, impl); + }, + getService(handle: ServiceHandle): T { + return deps.bus.getService(handle); + }, + storage(namespace: string): StorageNamespace { + return deps.storageFactory(namespace); + }, + config: deps.config, + secrets: deps.secrets, + permissions: deps.permissions, + events: deps.events, + logger: extLogger, + getProviders() { + return providers; + }, + getTools() { + return tools; + }, + getAuthProviders() { + return authProviders; + }, + getAuthProvider(id: string) { + return authProviders.get(id); + }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, + scheduler: { + register(job: ScheduledJob) { + scheduledJobs.push(job); + deps.scheduler.register(job); + }, + }, + }; + } - return { - async activate() { - for (const ext of compatible) { - try { - await ext.activate(buildHostAPI(ext.manifest.id)); - activated.push(ext); - deps.logger.info(`Extension "${ext.manifest.id}" activated`); - } catch (err) { - disabled.push({ - manifest: ext.manifest, - reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`, - }); - deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err }); - } - } - }, - async deactivate() { - for (let i = activated.length - 1; i >= 0; i--) { - const ext = activated[i]; - if (ext === undefined || ext.deactivate === undefined) continue; - try { - await ext.deactivate(); - } catch (err) { - deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err }); - } - } - }, - getTools() { - return tools; - }, - getTool(name: string) { - return tools.get(name); - }, - getProviders() { - return providers; - }, - getProvider(id: string) { - return providers.get(id); - }, - getAuthProviders() { - return authProviders; - }, - getAuthProvider(id: string) { - return authProviders.get(id); - }, - getScheduledJobs() { - return scheduledJobs; - }, - getMigrations() { - return migrations; - }, - getDisabled() { - return disabled; - }, - getExtensions() { - return Object.freeze(activated.map((e) => e.manifest)); - }, - getHostAPI() { - return buildHostAPI("__host__", { registrationClosed: true }); - }, - }; + return { + async activate() { + for (const ext of compatible) { + try { + await ext.activate(buildHostAPI(ext.manifest.id)); + activated.push(ext); + deps.logger.info(`Extension "${ext.manifest.id}" activated`); + } catch (err) { + disabled.push({ + manifest: ext.manifest, + reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`, + }); + deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err }); + } + } + }, + async deactivate() { + for (let i = activated.length - 1; i >= 0; i--) { + const ext = activated[i]; + if (ext === undefined || ext.deactivate === undefined) continue; + try { + await ext.deactivate(); + } catch (err) { + deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err }); + } + } + }, + getTools() { + return tools; + }, + getTool(name: string) { + return tools.get(name); + }, + getProviders() { + return providers; + }, + getProvider(id: string) { + return providers.get(id); + }, + getAuthProviders() { + return authProviders; + }, + getAuthProvider(id: string) { + return authProviders.get(id); + }, + getScheduledJobs() { + return scheduledJobs; + }, + getMigrations() { + return migrations; + }, + getDisabled() { + return disabled; + }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, + getHostAPI() { + return buildHostAPI("__host__", { registrationClosed: true }); + }, + }; } diff --git a/packages/kernel/src/host/version.test.ts b/packages/kernel/src/host/version.test.ts index 85002b6..8325c5e 100644 --- a/packages/kernel/src/host/version.test.ts +++ b/packages/kernel/src/host/version.test.ts @@ -2,99 +2,99 @@ import { describe, expect, it } from "vitest"; import { isApiVersionCompatible } from "./version.js"; describe("isApiVersionCompatible", () => { - describe("wildcard", () => { - it("matches any version", () => { - expect(isApiVersionCompatible("*", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("*", "1.0.0")).toBe(true); - expect(isApiVersionCompatible("*", "99.99.99")).toBe(true); - }); - }); - - describe("exact match", () => { - it("matches identical version", () => { - expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true); - }); - - it("rejects different patch", () => { - expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false); - }); - - it("rejects different minor", () => { - expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false); - }); - - it("rejects different major", () => { - expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false); - }); - }); - - describe("caret range (^)", () => { - it("0.x: allows same minor, higher patch", () => { - expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true); - expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true); - }); - - it("0.x: rejects different minor", () => { - expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false); - expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false); - }); - - it("0.x: rejects different major", () => { - expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false); - }); - - it("1.x+: allows same major, higher minor/patch", () => { - expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true); - expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true); - expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true); - }); - - it("1.x+: rejects next major", () => { - expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false); - expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false); - }); - }); - - describe("tilde range (~)", () => { - it("allows same major.minor, higher patch", () => { - expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true); - }); - - it("rejects different minor", () => { - expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false); - }); - }); - - describe(">= range", () => { - it("allows equal or higher", () => { - expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true); - expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true); - expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true); - }); - - it("rejects below minimum", () => { - expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false); - expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false); - }); - }); - - describe("invalid input", () => { - it("throws on invalid kernel version", () => { - expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i); - }); - - it("throws on invalid range version", () => { - expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i); - }); - }); + describe("wildcard", () => { + it("matches any version", () => { + expect(isApiVersionCompatible("*", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("*", "1.0.0")).toBe(true); + expect(isApiVersionCompatible("*", "99.99.99")).toBe(true); + }); + }); + + describe("exact match", () => { + it("matches identical version", () => { + expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true); + }); + + it("rejects different patch", () => { + expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false); + }); + + it("rejects different minor", () => { + expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false); + }); + + it("rejects different major", () => { + expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false); + }); + }); + + describe("caret range (^)", () => { + it("0.x: allows same minor, higher patch", () => { + expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true); + expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true); + }); + + it("0.x: rejects different minor", () => { + expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false); + expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false); + }); + + it("0.x: rejects different major", () => { + expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false); + }); + + it("1.x+: allows same major, higher minor/patch", () => { + expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true); + expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true); + expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true); + }); + + it("1.x+: rejects next major", () => { + expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false); + expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false); + }); + }); + + describe("tilde range (~)", () => { + it("allows same major.minor, higher patch", () => { + expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true); + }); + + it("rejects different minor", () => { + expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false); + }); + }); + + describe(">= range", () => { + it("allows equal or higher", () => { + expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true); + expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true); + expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true); + }); + + it("rejects below minimum", () => { + expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false); + expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false); + }); + }); + + describe("invalid input", () => { + it("throws on invalid kernel version", () => { + expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i); + }); + + it("throws on invalid range version", () => { + expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i); + }); + }); }); diff --git a/packages/kernel/src/host/version.ts b/packages/kernel/src/host/version.ts index 0d62a3b..eb9ac16 100644 --- a/packages/kernel/src/host/version.ts +++ b/packages/kernel/src/host/version.ts @@ -1,52 +1,52 @@ interface SemVer { - readonly major: number; - readonly minor: number; - readonly patch: number; + readonly major: number; + readonly minor: number; + readonly patch: number; } function parseSemVer(version: string): SemVer { - const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!match) throw new Error(`Invalid semver: "${version}"`); - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) throw new Error(`Invalid semver: "${version}"`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; } function gte(a: SemVer, b: SemVer): boolean { - if (a.major !== b.major) return a.major > b.major; - if (a.minor !== b.minor) return a.minor > b.minor; - return a.patch >= b.patch; + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch >= b.patch; } export function isApiVersionCompatible(range: string, kernelVersion: string): boolean { - if (range === "*") return true; - - const kernel = parseSemVer(kernelVersion); - - if (range.startsWith("^")) { - const min = parseSemVer(range.slice(1)); - if (!gte(kernel, min)) return false; - if (min.major === 0) { - return kernel.major === 0 && kernel.minor === min.minor; - } - return kernel.major === min.major; - } - - if (range.startsWith("~")) { - const min = parseSemVer(range.slice(1)); - if (!gte(kernel, min)) return false; - return kernel.major === min.major && kernel.minor === min.minor; - } - - if (range.startsWith(">=")) { - const min = parseSemVer(range.slice(2)); - return gte(kernel, min); - } - - const exact = parseSemVer(range); - return ( - kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch - ); + if (range === "*") return true; + + const kernel = parseSemVer(kernelVersion); + + if (range.startsWith("^")) { + const min = parseSemVer(range.slice(1)); + if (!gte(kernel, min)) return false; + if (min.major === 0) { + return kernel.major === 0 && kernel.minor === min.minor; + } + return kernel.major === min.major; + } + + if (range.startsWith("~")) { + const min = parseSemVer(range.slice(1)); + if (!gte(kernel, min)) return false; + return kernel.major === min.major && kernel.minor === min.minor; + } + + if (range.startsWith(">=")) { + const min = parseSemVer(range.slice(2)); + return gte(kernel, min); + } + + const exact = parseSemVer(range); + return ( + kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch + ); } diff --git a/packages/kernel/src/logging/logger.test.ts b/packages/kernel/src/logging/logger.test.ts index 5d7bf45..783d5af 100644 --- a/packages/kernel/src/logging/logger.test.ts +++ b/packages/kernel/src/logging/logger.test.ts @@ -3,43 +3,43 @@ import type { LogDeps, LogRecord, LogSink } from "../contracts/logging.js"; import { createLogger } from "./logger.js"; function harness() { - let idCounter = 0; - const deps: LogDeps = { - now: () => 1000 + idCounter * 10, - newId: () => `span-${++idCounter}`, - }; - const records: LogRecord[] = []; - const sink: LogSink = { emit: (r) => records.push(r) }; - return { logger: createLogger({ extensionId: "test" }, sink, deps), records }; + let idCounter = 0; + const deps: LogDeps = { + now: () => 1000 + idCounter * 10, + newId: () => `span-${++idCounter}`, + }; + const records: LogRecord[] = []; + const sink: LogSink = { emit: (r) => records.push(r) }; + return { logger: createLogger({ extensionId: "test" }, sink, deps), records }; } describe("createLogger child-bound attributes", () => { - it("merges child-bound attrs into BOTH span-open and span-close records", () => { - const { logger, records } = harness(); - // Bind `warm: true` via child() — mirrors the cache-warming capture path. - const warmLogger = logger.child({ conversationId: "c1", attrs: { warm: true } }); + it("merges child-bound attrs into BOTH span-open and span-close records", () => { + const { logger, records } = harness(); + // Bind `warm: true` via child() — mirrors the cache-warming capture path. + const warmLogger = logger.child({ conversationId: "c1", attrs: { warm: true } }); - const span = warmLogger.span("provider.request", { model: "x" }); - span.end({ attrs: { "usage.cacheReadTokens": 0 } }); + const span = warmLogger.span("provider.request", { model: "x" }); + span.end({ attrs: { "usage.cacheReadTokens": 0 } }); - const open = records.find((r) => r.kind === "span-open"); - const close = records.find((r) => r.kind === "span-close"); + const open = records.find((r) => r.kind === "span-open"); + const close = records.find((r) => r.kind === "span-close"); - // Open carries the bound attr (pre-existing behavior). - expect(open?.attributes?.warm).toBe(true); - // Close MUST carry it too, so a `warm = true` query finds the closed span - // (with its usage/status) — not just the open record. - expect(close?.attributes?.warm).toBe(true); - // Span-specific attrs from span()/end() are still present on close. - expect(close?.attributes?.model).toBe("x"); - expect(close?.attributes?.["usage.cacheReadTokens"]).toBe(0); - }); + // Open carries the bound attr (pre-existing behavior). + expect(open?.attributes?.warm).toBe(true); + // Close MUST carry it too, so a `warm = true` query finds the closed span + // (with its usage/status) — not just the open record. + expect(close?.attributes?.warm).toBe(true); + // Span-specific attrs from span()/end() are still present on close. + expect(close?.attributes?.model).toBe("x"); + expect(close?.attributes?.["usage.cacheReadTokens"]).toBe(0); + }); - it("omits attributes entirely when neither bound nor span attrs exist", () => { - const { logger, records } = harness(); - const span = logger.span("bare"); - span.end(); - const close = records.find((r) => r.kind === "span-close"); - expect(close?.attributes).toBeUndefined(); - }); + it("omits attributes entirely when neither bound nor span attrs exist", () => { + const { logger, records } = harness(); + const span = logger.span("bare"); + span.end(); + const close = records.find((r) => r.kind === "span-close"); + expect(close?.attributes).toBeUndefined(); + }); }); diff --git a/packages/kernel/src/logging/logger.ts b/packages/kernel/src/logging/logger.ts index 4d2a609..341348d 100644 --- a/packages/kernel/src/logging/logger.ts +++ b/packages/kernel/src/logging/logger.ts @@ -6,112 +6,112 @@ */ import type { - Attributes, - ErrorAttributes, - Level, - LogContext, - LogDeps, - Logger, - LogLineRecord, - LogSink, - Span, - SpanCloseRecord, - SpanLink, - SpanOpenRecord, - SpanStatus, + Attributes, + ErrorAttributes, + Level, + LogContext, + LogDeps, + Logger, + LogLineRecord, + LogSink, + Span, + SpanCloseRecord, + SpanLink, + SpanOpenRecord, + SpanStatus, } from "../contracts/logging.js"; interface LoggerState { - readonly ctx: LogContext; - readonly attrs: Attributes | undefined; - readonly deps: LogDeps; - readonly sink: LogSink; + readonly ctx: LogContext; + readonly attrs: Attributes | undefined; + readonly deps: LogDeps; + readonly sink: LogSink; } function mergeAttributes( - base: Attributes | undefined, - extra: Attributes | undefined, + base: Attributes | undefined, + extra: Attributes | undefined, ): Attributes | undefined { - if (base === undefined && extra === undefined) return undefined; - if (base === undefined) return extra; - if (extra === undefined) return base; - return { ...base, ...extra }; + if (base === undefined && extra === undefined) return undefined; + if (base === undefined) return extra; + if (extra === undefined) return base; + return { ...base, ...extra }; } function isScalarAttr(value: unknown): value is string | number | boolean | null { - const t = typeof value; - return t === "string" || t === "number" || t === "boolean" || value === null; + const t = typeof value; + return t === "string" || t === "number" || t === "boolean" || value === null; } function emitLog(state: LoggerState, level: Level, msg: string, attrs?: Attributes): void { - const merged = mergeAttributes(state.attrs, attrs); - const base = { - kind: "log" as const, - level, - msg, - timestamp: state.deps.now(), - extensionId: state.ctx.extensionId, - }; - const record: LogLineRecord = - state.ctx.conversationId !== undefined || - state.ctx.turnId !== undefined || - state.ctx.spanId !== undefined || - state.ctx.parentSpanId !== undefined || - merged !== undefined - ? { - ...base, - ...(state.ctx.conversationId !== undefined - ? { conversationId: state.ctx.conversationId } - : {}), - ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), - ...(state.ctx.spanId !== undefined ? { spanId: state.ctx.spanId } : {}), - ...(state.ctx.parentSpanId !== undefined ? { parentSpanId: state.ctx.parentSpanId } : {}), - ...(merged !== undefined ? { attributes: merged } : {}), - } - : base; - try { - state.sink.emit(record); - } catch { - // Swallow — D7: the turn is sovereign (never break the caller). - } + const merged = mergeAttributes(state.attrs, attrs); + const base = { + kind: "log" as const, + level, + msg, + timestamp: state.deps.now(), + extensionId: state.ctx.extensionId, + }; + const record: LogLineRecord = + state.ctx.conversationId !== undefined || + state.ctx.turnId !== undefined || + state.ctx.spanId !== undefined || + state.ctx.parentSpanId !== undefined || + merged !== undefined + ? { + ...base, + ...(state.ctx.conversationId !== undefined + ? { conversationId: state.ctx.conversationId } + : {}), + ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), + ...(state.ctx.spanId !== undefined ? { spanId: state.ctx.spanId } : {}), + ...(state.ctx.parentSpanId !== undefined ? { parentSpanId: state.ctx.parentSpanId } : {}), + ...(merged !== undefined ? { attributes: merged } : {}), + } + : base; + try { + state.sink.emit(record); + } catch { + // Swallow — D7: the turn is sovereign (never break the caller). + } } function buildSpanOpen( - state: LoggerState, - name: string, - spanId: string, - attrs?: Attributes, - body?: string, - parentSpanId?: string, + state: LoggerState, + name: string, + spanId: string, + attrs?: Attributes, + body?: string, + parentSpanId?: string, ): SpanOpenRecord { - const base = { - kind: "span-open" as const, - spanId, - name, - timestamp: state.deps.now(), - extensionId: state.ctx.extensionId, - }; - const merged = mergeAttributes(state.attrs, attrs); - const effectiveParent = parentSpanId ?? state.ctx.parentSpanId; - return { - ...base, - ...(state.ctx.conversationId !== undefined ? { conversationId: state.ctx.conversationId } : {}), - ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), - ...(effectiveParent !== undefined ? { parentSpanId: effectiveParent } : {}), - ...(merged !== undefined ? { attributes: merged } : {}), - ...(body !== undefined ? { body } : {}), - }; + const base = { + kind: "span-open" as const, + spanId, + name, + timestamp: state.deps.now(), + extensionId: state.ctx.extensionId, + }; + const merged = mergeAttributes(state.attrs, attrs); + const effectiveParent = parentSpanId ?? state.ctx.parentSpanId; + return { + ...base, + ...(state.ctx.conversationId !== undefined ? { conversationId: state.ctx.conversationId } : {}), + ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), + ...(effectiveParent !== undefined ? { parentSpanId: effectiveParent } : {}), + ...(merged !== undefined ? { attributes: merged } : {}), + ...(body !== undefined ? { body } : {}), + }; } function buildSpanLink( - target: { readonly spanId: string; readonly turnId?: string }, - reason?: string, + target: { readonly spanId: string; readonly turnId?: string }, + reason?: string, ): SpanLink { - return { - spanId: target.spanId, - ...(target.turnId !== undefined ? { turnId: target.turnId } : {}), - ...(reason !== undefined ? { reason } : {}), - }; + return { + spanId: target.spanId, + ...(target.turnId !== undefined ? { turnId: target.turnId } : {}), + ...(reason !== undefined ? { reason } : {}), + }; } /** @@ -124,187 +124,187 @@ function buildSpanLink( * @param attrs Optional default attributes (from child()). */ export function createLogger( - ctx: LogContext, - sink: LogSink, - deps: LogDeps, - attrs?: Attributes, + ctx: LogContext, + sink: LogSink, + deps: LogDeps, + attrs?: Attributes, ): Logger { - const state: LoggerState = { ctx, attrs, deps, sink }; + const state: LoggerState = { ctx, attrs, deps, sink }; - function makeSpan( - name: string, - spanAttrs?: Attributes, - parentSpanId?: string, - body?: string, - ): Span { - const spanId = deps.newId(); - const mergedParent = parentSpanId ?? state.ctx.spanId; - const spanCtx: LogContext = { - extensionId: ctx.extensionId, - ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), - ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), - spanId, - ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), - }; + function makeSpan( + name: string, + spanAttrs?: Attributes, + parentSpanId?: string, + body?: string, + ): Span { + const spanId = deps.newId(); + const mergedParent = parentSpanId ?? state.ctx.spanId; + const spanCtx: LogContext = { + extensionId: ctx.extensionId, + ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), + ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), + spanId, + ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), + }; - const openRecord = buildSpanOpen(state, name, spanId, spanAttrs, body, mergedParent); - const spanAttrsMutable: Record = - spanAttrs !== undefined ? { ...spanAttrs } : {}; - const links: SpanLink[] = []; - const openedAt = deps.now(); + const openRecord = buildSpanOpen(state, name, spanId, spanAttrs, body, mergedParent); + const spanAttrsMutable: Record = + spanAttrs !== undefined ? { ...spanAttrs } : {}; + const links: SpanLink[] = []; + const openedAt = deps.now(); - try { - sink.emit(openRecord); - } catch { - // Swallow — D7. - } + try { + sink.emit(openRecord); + } catch { + // Swallow — D7. + } - const spanLogger = createLogger(spanCtx, sink, deps, state.attrs); + const spanLogger = createLogger(spanCtx, sink, deps, state.attrs); - const span: Span = { - id: spanId, - log: spanLogger, - setAttributes(newAttrs: Attributes): void { - for (const [key, value] of Object.entries(newAttrs)) { - spanAttrsMutable[key] = value; - } - }, - addLink(target, reason): void { - links.push(buildSpanLink(target, reason)); - }, - child(childName: string, childAttrs?: Attributes, childBody?: string): Span { - return makeSpan(childName, childAttrs, spanId, childBody); - }, - end(outcome?): void { - const closedAt = deps.now(); - const err = outcome?.err; - let status: SpanStatus = "ok"; - if (err !== undefined && err !== null) { - status = "error"; - const errMsg = err instanceof Error ? err.message : String(err); - spanAttrsMutable["error.message"] = errMsg; - if (err instanceof Error && err.stack !== undefined) { - spanAttrsMutable["error.stack"] = err.stack; - } - } - if (outcome?.attrs !== undefined) { - for (const [key, value] of Object.entries(outcome.attrs)) { - spanAttrsMutable[key] = value; - } - } + const span: Span = { + id: spanId, + log: spanLogger, + setAttributes(newAttrs: Attributes): void { + for (const [key, value] of Object.entries(newAttrs)) { + spanAttrsMutable[key] = value; + } + }, + addLink(target, reason): void { + links.push(buildSpanLink(target, reason)); + }, + child(childName: string, childAttrs?: Attributes, childBody?: string): Span { + return makeSpan(childName, childAttrs, spanId, childBody); + }, + end(outcome?): void { + const closedAt = deps.now(); + const err = outcome?.err; + let status: SpanStatus = "ok"; + if (err !== undefined && err !== null) { + status = "error"; + const errMsg = err instanceof Error ? err.message : String(err); + spanAttrsMutable["error.message"] = errMsg; + if (err instanceof Error && err.stack !== undefined) { + spanAttrsMutable["error.stack"] = err.stack; + } + } + if (outcome?.attrs !== undefined) { + for (const [key, value] of Object.entries(outcome.attrs)) { + spanAttrsMutable[key] = value; + } + } - const hasAttrs = Object.keys(spanAttrsMutable).length > 0; - // Merge child-bound default attrs (state.attrs) the SAME way span-open - // does (buildSpanOpen). Without this, an attribute bound via - // `logger.child({ attrs })` appears on the span-open record but NOT the - // span-close record — so a query like `warm = true` can't find the - // closed span (with its usage/status). Open and close must agree. - const mergedCloseAttrs = mergeAttributes( - state.attrs, - hasAttrs ? spanAttrsMutable : undefined, - ); - const hasLinks = links.length > 0; - const base = { - kind: "span-close" as const, - spanId, - name, - timestamp: closedAt, - durationMs: closedAt - openedAt, - status, - extensionId: ctx.extensionId, - }; - const closeRecord: SpanCloseRecord = { - ...base, - ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), - ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), - ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), - ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}), - ...(hasLinks ? { links: [...links] } : {}), - ...(outcome?.body !== undefined ? { body: outcome.body } : {}), - }; - try { - sink.emit(closeRecord); - } catch { - // Swallow — D7. - } - }, - }; + const hasAttrs = Object.keys(spanAttrsMutable).length > 0; + // Merge child-bound default attrs (state.attrs) the SAME way span-open + // does (buildSpanOpen). Without this, an attribute bound via + // `logger.child({ attrs })` appears on the span-open record but NOT the + // span-close record — so a query like `warm = true` can't find the + // closed span (with its usage/status). Open and close must agree. + const mergedCloseAttrs = mergeAttributes( + state.attrs, + hasAttrs ? spanAttrsMutable : undefined, + ); + const hasLinks = links.length > 0; + const base = { + kind: "span-close" as const, + spanId, + name, + timestamp: closedAt, + durationMs: closedAt - openedAt, + status, + extensionId: ctx.extensionId, + }; + const closeRecord: SpanCloseRecord = { + ...base, + ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), + ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), + ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), + ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}), + ...(hasLinks ? { links: [...links] } : {}), + ...(outcome?.body !== undefined ? { body: outcome.body } : {}), + }; + try { + sink.emit(closeRecord); + } catch { + // Swallow — D7. + } + }, + }; - return span; - } + return span; + } - const logger: Logger = { - debug(msg: string, attrs?: Attributes): void { - emitLog(state, "debug", msg, attrs); - }, - info(msg: string, attrs?: Attributes): void { - emitLog(state, "info", msg, attrs); - }, - warn(msg: string, attrs?: Attributes): void { - emitLog(state, "warn", msg, attrs); - }, - error(msg: string, attrs?: ErrorAttributes): void { - const err = attrs?.err; - if (err !== undefined && err !== null) { - // Extract scalar attributes (everything except err). - const scalarAttrs: Record = {}; - if (attrs !== undefined) { - for (const [key, value] of Object.entries(attrs)) { - if (key !== "err" && isScalarAttr(value)) { - scalarAttrs[key] = value; - } - } - } - const merged = mergeAttributes( - state.attrs, - Object.keys(scalarAttrs).length > 0 ? scalarAttrs : undefined, - ); - const errMsg = err instanceof Error ? err.message : String(err); - const errorAttrs: Record = { - ...(merged ?? {}), - "error.message": errMsg, - }; - if (err instanceof Error && err.stack !== undefined) { - errorAttrs["error.stack"] = err.stack; - } - emitLog(state, "error", msg, errorAttrs as Attributes); - } else { - // No err field — filter to scalar attributes only. - const scalarAttrs: Record = {}; - if (attrs !== undefined) { - for (const [key, value] of Object.entries(attrs)) { - if (isScalarAttr(value)) { - scalarAttrs[key] = value; - } - } - } - emitLog( - state, - "error", - msg, - Object.keys(scalarAttrs).length > 0 ? (scalarAttrs as Attributes) : undefined, - ); - } - }, - child(childCtx: Partial & { readonly attrs?: Attributes }): Logger { - const convId = childCtx.conversationId ?? ctx.conversationId; - const tId = childCtx.turnId ?? ctx.turnId; - const sId = childCtx.spanId ?? ctx.spanId; - const pId = childCtx.parentSpanId ?? ctx.parentSpanId; - const newCtx: LogContext = { - extensionId: ctx.extensionId, - ...(convId !== undefined ? { conversationId: convId } : {}), - ...(tId !== undefined ? { turnId: tId } : {}), - ...(sId !== undefined ? { spanId: sId } : {}), - ...(pId !== undefined ? { parentSpanId: pId } : {}), - }; - const newAttrs = mergeAttributes(state.attrs, childCtx.attrs); - return createLogger(newCtx, sink, deps, newAttrs); - }, - span(name: string, attrs?: Attributes, body?: string): Span { - return makeSpan(name, attrs, undefined, body); - }, - }; + const logger: Logger = { + debug(msg: string, attrs?: Attributes): void { + emitLog(state, "debug", msg, attrs); + }, + info(msg: string, attrs?: Attributes): void { + emitLog(state, "info", msg, attrs); + }, + warn(msg: string, attrs?: Attributes): void { + emitLog(state, "warn", msg, attrs); + }, + error(msg: string, attrs?: ErrorAttributes): void { + const err = attrs?.err; + if (err !== undefined && err !== null) { + // Extract scalar attributes (everything except err). + const scalarAttrs: Record = {}; + if (attrs !== undefined) { + for (const [key, value] of Object.entries(attrs)) { + if (key !== "err" && isScalarAttr(value)) { + scalarAttrs[key] = value; + } + } + } + const merged = mergeAttributes( + state.attrs, + Object.keys(scalarAttrs).length > 0 ? scalarAttrs : undefined, + ); + const errMsg = err instanceof Error ? err.message : String(err); + const errorAttrs: Record = { + ...(merged ?? {}), + "error.message": errMsg, + }; + if (err instanceof Error && err.stack !== undefined) { + errorAttrs["error.stack"] = err.stack; + } + emitLog(state, "error", msg, errorAttrs as Attributes); + } else { + // No err field — filter to scalar attributes only. + const scalarAttrs: Record = {}; + if (attrs !== undefined) { + for (const [key, value] of Object.entries(attrs)) { + if (isScalarAttr(value)) { + scalarAttrs[key] = value; + } + } + } + emitLog( + state, + "error", + msg, + Object.keys(scalarAttrs).length > 0 ? (scalarAttrs as Attributes) : undefined, + ); + } + }, + child(childCtx: Partial & { readonly attrs?: Attributes }): Logger { + const convId = childCtx.conversationId ?? ctx.conversationId; + const tId = childCtx.turnId ?? ctx.turnId; + const sId = childCtx.spanId ?? ctx.spanId; + const pId = childCtx.parentSpanId ?? ctx.parentSpanId; + const newCtx: LogContext = { + extensionId: ctx.extensionId, + ...(convId !== undefined ? { conversationId: convId } : {}), + ...(tId !== undefined ? { turnId: tId } : {}), + ...(sId !== undefined ? { spanId: sId } : {}), + ...(pId !== undefined ? { parentSpanId: pId } : {}), + }; + const newAttrs = mergeAttributes(state.attrs, childCtx.attrs); + return createLogger(newCtx, sink, deps, newAttrs); + }, + span(name: string, attrs?: Attributes, body?: string): Span { + return makeSpan(name, attrs, undefined, body); + }, + }; - return logger; + return logger; } diff --git a/packages/kernel/src/runtime/dispatch.test.ts b/packages/kernel/src/runtime/dispatch.test.ts index afbfb39..dfe2ac7 100644 --- a/packages/kernel/src/runtime/dispatch.test.ts +++ b/packages/kernel/src/runtime/dispatch.test.ts @@ -11,51 +11,51 @@ import { runTurn } from "./run-turn.js"; // --------------------------------------------------------------------------- function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } function createFakeProvider(script: ProviderEvent[][]): ProviderContract { - let callIndex = 0; - return { - id: "fake", - stream() { - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; + let callIndex = 0; + return { + id: "fake", + stream() { + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; } function createFakeTool( - name: string, - handler?: (input: unknown, ctx: ToolExecuteContext) => Promise, - opts?: { concurrencySafe?: boolean }, + name: string, + handler?: (input: unknown, ctx: ToolExecuteContext) => Promise, + opts?: { concurrencySafe?: boolean }, ): ToolContract { - return { - name, - description: `Fake tool: ${name}`, - parameters: { type: "object" }, - ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}), - execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })), - }; + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}), + execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })), + }; } function createCollectingEmit(): { events: AgentEvent[]; emit: (event: AgentEvent) => void } { - const events: AgentEvent[] = []; - return { events, emit: (event) => events.push(event) }; + const events: AgentEvent[] = []; + return { events, emit: (event) => events.push(event) }; } const noopEmit = () => {}; const userMessage: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "hello" }], + role: "user", + chunks: [{ type: "text", text: "hello" }], }; const ABORTED_RESULT: ToolResult = { content: "Aborted", isError: true }; @@ -65,158 +65,158 @@ const ABORTED_RESULT: ToolResult = { content: "Aborted", isError: true }; // =========================================================================== describe("executeToolCall", () => { - it("returns the tool's result when the tool resolves before abort", async () => { - const ac = new AbortController(); - const tool = createFakeTool("echo", async (input) => ({ - content: `echo: ${JSON.stringify(input)}`, - })); - - const result = await executeToolCall( - { id: "tc1", name: "echo", input: { x: 1 } }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - expect(result).toEqual({ content: 'echo: {"x":1}' }); - }); - - it("returns Aborted immediately when signal is already aborted at call time", async () => { - const ac = new AbortController(); - ac.abort(); - const tool = createFakeTool("echo", async () => ({ content: "should not run" })); - - const result = await executeToolCall( - { id: "tc1", name: "echo", input: {} }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - expect(result).toEqual(ABORTED_RESULT); - }); - - it("returns Aborted when a hanging tool is raced against an abort signal", async () => { - const ac = new AbortController(); - // A tool that never resolves and ignores ctx.signal - const tool = createFakeTool("hang", () => new Promise(() => {})); - - const promise = executeToolCall( - { id: "tc1", name: "hang", input: {} }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - // Abort after the tool has started - await delay(10); - ac.abort(); - - const result = await promise; - expect(result).toEqual(ABORTED_RESULT); - }); - - it("returns the tool's own result when a signal-aware tool resolves on abort", async () => { - const ac = new AbortController(); - const toolResult: ToolResult = { content: "aborted by tool", isError: true }; - const tool = createFakeTool("aware", (_input, ctx) => { - return new Promise((resolve) => { - ctx.signal.addEventListener("abort", () => resolve(toolResult), { once: true }); - }); - }); - - const promise = executeToolCall( - { id: "tc1", name: "aware", input: {} }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - await delay(10); - ac.abort(); - - const result = await promise; - // The tool listens to the signal and resolves its own result. Whether - // the tool's result or the race's "Aborted" wins is timing-dependent; - // both are isError and let the turn seal with finishReason "aborted". - expect(result.isError).toBe(true); - expect(result.content).toBe("aborted by tool"); - }); - - it("swallows a late rejection from the orphaned tool promise after abort wins the race", async () => { - const ac = new AbortController(); - let rejectTool: ((err: Error) => void) | undefined; - const tool = createFakeTool("late-reject", () => { - return new Promise((_resolve, reject) => { - rejectTool = reject; - }); - }); - - const promise = executeToolCall( - { id: "tc1", name: "late-reject", input: {} }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - await delay(10); - ac.abort(); - - const result = await promise; - expect(result).toEqual(ABORTED_RESULT); - - // The tool rejects AFTER the race already resolved with "Aborted". - // The no-op catch must swallow this — no unhandled rejection. - rejectTool?.(new Error("late boom")); - // Give the microtask queue a tick to flush - await delay(5); - // If we reach here without an unhandledRejection crashing the process, - // the test passes. (vitest surfaces unhandled rejections as failures.) - }); - - it("returns an error result when the tool rejects before abort", async () => { - const ac = new AbortController(); - const tool = createFakeTool("boom", async () => { - throw new Error("tool exploded"); - }); - - const result = await executeToolCall( - { id: "tc1", name: "boom", input: {} }, - tool, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("tool exploded"); - }); - - it("returns Unknown tool when the tool is undefined", async () => { - const ac = new AbortController(); - const result = await executeToolCall( - { id: "tc1", name: "nonexistent", input: {} }, - undefined, - ac.signal, - noopEmit, - "conv-1", - "turn-1", - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Unknown tool"); - }); + it("returns the tool's result when the tool resolves before abort", async () => { + const ac = new AbortController(); + const tool = createFakeTool("echo", async (input) => ({ + content: `echo: ${JSON.stringify(input)}`, + })); + + const result = await executeToolCall( + { id: "tc1", name: "echo", input: { x: 1 } }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + expect(result).toEqual({ content: 'echo: {"x":1}' }); + }); + + it("returns Aborted immediately when signal is already aborted at call time", async () => { + const ac = new AbortController(); + ac.abort(); + const tool = createFakeTool("echo", async () => ({ content: "should not run" })); + + const result = await executeToolCall( + { id: "tc1", name: "echo", input: {} }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + expect(result).toEqual(ABORTED_RESULT); + }); + + it("returns Aborted when a hanging tool is raced against an abort signal", async () => { + const ac = new AbortController(); + // A tool that never resolves and ignores ctx.signal + const tool = createFakeTool("hang", () => new Promise(() => {})); + + const promise = executeToolCall( + { id: "tc1", name: "hang", input: {} }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + // Abort after the tool has started + await delay(10); + ac.abort(); + + const result = await promise; + expect(result).toEqual(ABORTED_RESULT); + }); + + it("returns the tool's own result when a signal-aware tool resolves on abort", async () => { + const ac = new AbortController(); + const toolResult: ToolResult = { content: "aborted by tool", isError: true }; + const tool = createFakeTool("aware", (_input, ctx) => { + return new Promise((resolve) => { + ctx.signal.addEventListener("abort", () => resolve(toolResult), { once: true }); + }); + }); + + const promise = executeToolCall( + { id: "tc1", name: "aware", input: {} }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + await delay(10); + ac.abort(); + + const result = await promise; + // The tool listens to the signal and resolves its own result. Whether + // the tool's result or the race's "Aborted" wins is timing-dependent; + // both are isError and let the turn seal with finishReason "aborted". + expect(result.isError).toBe(true); + expect(result.content).toBe("aborted by tool"); + }); + + it("swallows a late rejection from the orphaned tool promise after abort wins the race", async () => { + const ac = new AbortController(); + let rejectTool: ((err: Error) => void) | undefined; + const tool = createFakeTool("late-reject", () => { + return new Promise((_resolve, reject) => { + rejectTool = reject; + }); + }); + + const promise = executeToolCall( + { id: "tc1", name: "late-reject", input: {} }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + await delay(10); + ac.abort(); + + const result = await promise; + expect(result).toEqual(ABORTED_RESULT); + + // The tool rejects AFTER the race already resolved with "Aborted". + // The no-op catch must swallow this — no unhandled rejection. + rejectTool?.(new Error("late boom")); + // Give the microtask queue a tick to flush + await delay(5); + // If we reach here without an unhandledRejection crashing the process, + // the test passes. (vitest surfaces unhandled rejections as failures.) + }); + + it("returns an error result when the tool rejects before abort", async () => { + const ac = new AbortController(); + const tool = createFakeTool("boom", async () => { + throw new Error("tool exploded"); + }); + + const result = await executeToolCall( + { id: "tc1", name: "boom", input: {} }, + tool, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("tool exploded"); + }); + + it("returns Unknown tool when the tool is undefined", async () => { + const ac = new AbortController(); + const result = await executeToolCall( + { id: "tc1", name: "nonexistent", input: {} }, + undefined, + ac.signal, + noopEmit, + "conv-1", + "turn-1", + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Unknown tool"); + }); }); // =========================================================================== @@ -224,312 +224,312 @@ describe("executeToolCall", () => { // =========================================================================== describe("runTurn abort-race durability", () => { - // Required test 1: A hanging tool (never resolves, ignores ctx.signal) - // must not keep runTurn from returning when the signal aborts. - it("hanging tool + abort → runTurn returns with finishReason aborted and emits done", async () => { - const ac = new AbortController(); - - // A tool whose execute returns a promise that NEVER resolves and - // ignores ctx.signal entirely. - const tool = createFakeTool("hang", () => new Promise(() => {})); - - // Use eager: true so the tool starts BEFORE the signal aborts. - // This exercises the race (not the early signal.aborted return). - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "hang", - input: {}, - } as ProviderEvent; - ac.abort(); - await delay(10); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: ac.signal, - }); - - // runTurn returned (didn't hang) → the race worked. - expect(result.finishReason).toBe("aborted"); - - // A done event was emitted with reason "aborted". - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - if (doneEvents[0]?.type === "done") { - expect(doneEvents[0].reason).toBe("aborted"); - } - }); - - // Required test 2: A signal-aware tool that resolves its own result on - // abort must also let runTurn return with finishReason "aborted". - it("signal-aware tool + abort → runTurn returns with finishReason aborted", async () => { - const ac = new AbortController(); - - const tool = createFakeTool("aware", (_input, ctx) => { - return new Promise((resolve) => { - ctx.signal.addEventListener( - "abort", - () => resolve({ content: "aborted by tool", isError: true }), - { once: true }, - ); - }); - }); - - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "aware", - input: {}, - } as ProviderEvent; - ac.abort(); - await delay(10); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - if (doneEvents[0]?.type === "done") { - expect(doneEvents[0].reason).toBe("aborted"); - } - - // When the step is aborted, tool-result MESSAGES are omitted from the - // result (the tool-result EVENT is still emitted by executeStep for - // live UI updates, but the message is not persisted). This prevents - // orphaned `tool` messages from breaking the next turn's provider - // request. The assistant message has its tool-call chunks stripped. - const toolResultMsg = result.messages.find((m) => m.role === "tool"); - expect(toolResultMsg).toBeUndefined(); - - // The assistant message should NOT contain tool-call chunks. - const assistantMsg = result.messages.find( - (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), - ); - expect(assistantMsg).toBeUndefined(); - }); - - // Required test 3 (regression guard): Without abort, a normal tool runs - // and its result is used; finishReason reflects the model. - it("no abort → tool runs normally and its result is used (regression)", async () => { - const tool = createFakeTool("normal", async (input) => ({ - content: `result: ${JSON.stringify(input)}`, - })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "normal", input: { x: 1 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - // finishReason reflects the model (second step's "stop"). - expect(result.finishReason).toBe("stop"); - - // The tool's result was used (fed back, not "Aborted"). - const toolResultMsg = result.messages.find((m) => m.role === "tool"); - expect(toolResultMsg).toBeDefined(); - const trChunk = toolResultMsg?.chunks[0]; - expect(trChunk?.type).toBe("tool-result"); - if (trChunk?.type === "tool-result") { - expect(trChunk.content).toBe('result: {"x":1}'); - expect(trChunk.isError).toBe(false); - } - - // done event emitted with reason "stop". - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - if (doneEvents[0]?.type === "done") { - expect(doneEvents[0].reason).toBe("stop"); - } - }); - - // Bonus: multiple hanging tools + abort → all resolve via the race, - // drain() doesn't deadlock, and runTurn returns. Tool-result messages - // are omitted from the result (aborted step); the turn seals cleanly. - it("multiple hanging tools + abort → drain completes and runTurn returns", async () => { - const ac = new AbortController(); - - // Two tools that never resolve and ignore ctx.signal. - const toolA = createFakeTool("hangA", () => new Promise(() => {})); - const toolB = createFakeTool("hangB", () => new Promise(() => {})); - - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "hangA", - input: {}, - } as ProviderEvent; - yield { - type: "tool-call", - toolCallId: "tc2", - toolName: "hangB", - input: {}, - } as ProviderEvent; - ac.abort(); - await delay(10); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 2, eager: true }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - // tool-result EVENTS are still emitted by executeStep (for live UI), - // but tool-result MESSAGES are omitted from the result (not persisted). - const toolResultEvents = events.filter((e) => e.type === "tool-result"); - expect(toolResultEvents).toHaveLength(2); - for (const tr of toolResultEvents) { - if (tr.type === "tool-result") { - expect(tr.isError).toBe(true); - } - } - - // No tool messages in the result (they would orphan on the next turn). - const toolMessages = result.messages.filter((m) => m.role === "tool"); - expect(toolMessages).toHaveLength(0); - - // Assistant message has no tool-call chunks. - const assistantMsgs = result.messages.filter((m) => m.role === "assistant"); - for (const msg of assistantMsgs) { - expect(msg.chunks.some((c) => c.type === "tool-call")).toBe(false); - } - - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - if (doneEvents[0]?.type === "done") { - expect(doneEvents[0].reason).toBe("aborted"); - } - }); - - // Critical regression: after an aborted tool call, the result messages - // must NOT contain orphaned tool messages. If they did, the next turn - // would send a `tool` role message to the provider without a preceding - // `assistant` message carrying `tool_calls` → 400 error. - it("aborted step produces no tool messages and no tool-call chunks in result", async () => { - const ac = new AbortController(); - - // Tool that hangs forever - const tool = createFakeTool("hang", () => new Promise(() => {})); - - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "Let me run that for you" } as ProviderEvent; - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "hang", - input: {}, - } as ProviderEvent; - ac.abort(); - await delay(10); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - })(); - }, - }; - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "conv-1", - turnId: "turn-1", - emit: noopEmit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - // No tool messages in the result - const toolMessages = result.messages.filter((m) => m.role === "tool"); - expect(toolMessages).toHaveLength(0); - - // The assistant message should preserve text but NOT tool-call chunks - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - expect(assistantMsg).toBeDefined(); - if (assistantMsg !== undefined) { - const hasToolCall = assistantMsg.chunks.some((c) => c.type === "tool-call"); - expect(hasToolCall).toBe(false); - // Text content should be preserved - const hasText = assistantMsg.chunks.some((c) => c.type === "text"); - expect(hasText).toBe(true); - } - - // Simulate what the next turn would see: the result messages are the - // conversation history (minus the user message). If we feed these to - // a simple converter, there should be NO `tool` role messages. - const toolRoleCount = result.messages.filter((m) => m.role === "tool").length; - expect(toolRoleCount).toBe(0); - }); + // Required test 1: A hanging tool (never resolves, ignores ctx.signal) + // must not keep runTurn from returning when the signal aborts. + it("hanging tool + abort → runTurn returns with finishReason aborted and emits done", async () => { + const ac = new AbortController(); + + // A tool whose execute returns a promise that NEVER resolves and + // ignores ctx.signal entirely. + const tool = createFakeTool("hang", () => new Promise(() => {})); + + // Use eager: true so the tool starts BEFORE the signal aborts. + // This exercises the race (not the early signal.aborted return). + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "hang", + input: {}, + } as ProviderEvent; + ac.abort(); + await delay(10); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: ac.signal, + }); + + // runTurn returned (didn't hang) → the race worked. + expect(result.finishReason).toBe("aborted"); + + // A done event was emitted with reason "aborted". + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + if (doneEvents[0]?.type === "done") { + expect(doneEvents[0].reason).toBe("aborted"); + } + }); + + // Required test 2: A signal-aware tool that resolves its own result on + // abort must also let runTurn return with finishReason "aborted". + it("signal-aware tool + abort → runTurn returns with finishReason aborted", async () => { + const ac = new AbortController(); + + const tool = createFakeTool("aware", (_input, ctx) => { + return new Promise((resolve) => { + ctx.signal.addEventListener( + "abort", + () => resolve({ content: "aborted by tool", isError: true }), + { once: true }, + ); + }); + }); + + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "aware", + input: {}, + } as ProviderEvent; + ac.abort(); + await delay(10); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + if (doneEvents[0]?.type === "done") { + expect(doneEvents[0].reason).toBe("aborted"); + } + + // When the step is aborted, tool-result MESSAGES are omitted from the + // result (the tool-result EVENT is still emitted by executeStep for + // live UI updates, but the message is not persisted). This prevents + // orphaned `tool` messages from breaking the next turn's provider + // request. The assistant message has its tool-call chunks stripped. + const toolResultMsg = result.messages.find((m) => m.role === "tool"); + expect(toolResultMsg).toBeUndefined(); + + // The assistant message should NOT contain tool-call chunks. + const assistantMsg = result.messages.find( + (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), + ); + expect(assistantMsg).toBeUndefined(); + }); + + // Required test 3 (regression guard): Without abort, a normal tool runs + // and its result is used; finishReason reflects the model. + it("no abort → tool runs normally and its result is used (regression)", async () => { + const tool = createFakeTool("normal", async (input) => ({ + content: `result: ${JSON.stringify(input)}`, + })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "normal", input: { x: 1 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + // finishReason reflects the model (second step's "stop"). + expect(result.finishReason).toBe("stop"); + + // The tool's result was used (fed back, not "Aborted"). + const toolResultMsg = result.messages.find((m) => m.role === "tool"); + expect(toolResultMsg).toBeDefined(); + const trChunk = toolResultMsg?.chunks[0]; + expect(trChunk?.type).toBe("tool-result"); + if (trChunk?.type === "tool-result") { + expect(trChunk.content).toBe('result: {"x":1}'); + expect(trChunk.isError).toBe(false); + } + + // done event emitted with reason "stop". + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + if (doneEvents[0]?.type === "done") { + expect(doneEvents[0].reason).toBe("stop"); + } + }); + + // Bonus: multiple hanging tools + abort → all resolve via the race, + // drain() doesn't deadlock, and runTurn returns. Tool-result messages + // are omitted from the result (aborted step); the turn seals cleanly. + it("multiple hanging tools + abort → drain completes and runTurn returns", async () => { + const ac = new AbortController(); + + // Two tools that never resolve and ignore ctx.signal. + const toolA = createFakeTool("hangA", () => new Promise(() => {})); + const toolB = createFakeTool("hangB", () => new Promise(() => {})); + + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "hangA", + input: {}, + } as ProviderEvent; + yield { + type: "tool-call", + toolCallId: "tc2", + toolName: "hangB", + input: {}, + } as ProviderEvent; + ac.abort(); + await delay(10); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 2, eager: true }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + // tool-result EVENTS are still emitted by executeStep (for live UI), + // but tool-result MESSAGES are omitted from the result (not persisted). + const toolResultEvents = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvents).toHaveLength(2); + for (const tr of toolResultEvents) { + if (tr.type === "tool-result") { + expect(tr.isError).toBe(true); + } + } + + // No tool messages in the result (they would orphan on the next turn). + const toolMessages = result.messages.filter((m) => m.role === "tool"); + expect(toolMessages).toHaveLength(0); + + // Assistant message has no tool-call chunks. + const assistantMsgs = result.messages.filter((m) => m.role === "assistant"); + for (const msg of assistantMsgs) { + expect(msg.chunks.some((c) => c.type === "tool-call")).toBe(false); + } + + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + if (doneEvents[0]?.type === "done") { + expect(doneEvents[0].reason).toBe("aborted"); + } + }); + + // Critical regression: after an aborted tool call, the result messages + // must NOT contain orphaned tool messages. If they did, the next turn + // would send a `tool` role message to the provider without a preceding + // `assistant` message carrying `tool_calls` → 400 error. + it("aborted step produces no tool messages and no tool-call chunks in result", async () => { + const ac = new AbortController(); + + // Tool that hangs forever + const tool = createFakeTool("hang", () => new Promise(() => {})); + + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "Let me run that for you" } as ProviderEvent; + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "hang", + input: {}, + } as ProviderEvent; + ac.abort(); + await delay(10); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + })(); + }, + }; + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "conv-1", + turnId: "turn-1", + emit: noopEmit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + // No tool messages in the result + const toolMessages = result.messages.filter((m) => m.role === "tool"); + expect(toolMessages).toHaveLength(0); + + // The assistant message should preserve text but NOT tool-call chunks + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + expect(assistantMsg).toBeDefined(); + if (assistantMsg !== undefined) { + const hasToolCall = assistantMsg.chunks.some((c) => c.type === "tool-call"); + expect(hasToolCall).toBe(false); + // Text content should be preserved + const hasText = assistantMsg.chunks.some((c) => c.type === "text"); + expect(hasText).toBe(true); + } + + // Simulate what the next turn would see: the result messages are the + // conversation history (minus the user message). If we feed these to + // a simple converter, there should be NO `tool` role messages. + const toolRoleCount = result.messages.filter((m) => m.role === "tool").length; + expect(toolRoleCount).toBe(0); + }); }); diff --git a/packages/kernel/src/runtime/dispatch.ts b/packages/kernel/src/runtime/dispatch.ts index 01f0043..d09db3b 100644 --- a/packages/kernel/src/runtime/dispatch.ts +++ b/packages/kernel/src/runtime/dispatch.ts @@ -5,182 +5,182 @@ import type { ToolCall, ToolContract, ToolExecuteContext, ToolResult } from "../ import { toolOutputEvent } from "./events.js"; export interface StepDispatcher { - submit(call: ToolCall): void; - drain(): Promise>; + submit(call: ToolCall): void; + drain(): Promise>; } export async function executeToolCall( - call: ToolCall, - tool: ToolContract | undefined, - signal: AbortSignal, - emit: EventEmitter, - conversationId: string, - turnId: string, - toolSpan?: Span, - cwd?: string, - computerId?: string, + call: ToolCall, + tool: ToolContract | undefined, + signal: AbortSignal, + emit: EventEmitter, + conversationId: string, + turnId: string, + toolSpan?: Span, + cwd?: string, + computerId?: string, ): Promise { - if (tool === undefined) { - return { content: `Unknown tool: ${call.name}`, isError: true }; - } - if (signal.aborted) { - return { content: "Aborted", isError: true }; - } - const ctx: ToolExecuteContext = { - toolCallId: call.id, - signal, - onOutput: (data, stream) => { - emit(toolOutputEvent(conversationId, turnId, call.id, data, stream)); - }, - log: toolSpan?.log ?? createNoopLogger(), - conversationId, - ...(cwd !== undefined ? { cwd } : {}), - ...(computerId !== undefined ? { computerId } : {}), - }; - // Race the tool's execute promise against the abort signal so a tool - // that hangs (ignores ctx.signal, or blocks on something the signal - // can't interrupt) can't keep runTurn from returning. When the signal - // fires we RESOLVE (not reject) with an "Aborted" result so the step - // completes normally and the existing signal.aborted → finishReason = - // "aborted" path seals the turn cleanly (done event), letting the - // caller's finally clear active state and the FE clear its spinner. - try { - const toolPromise = tool.execute(call.input, ctx); - const abortPromise = new Promise((resolve) => { - signal.addEventListener("abort", () => resolve({ content: "Aborted", isError: true }), { - once: true, - }); - }); - // Swallow late rejections from the orphaned tool promise: the tool - // may reject after the race already resolved with "Aborted". - void toolPromise.catch(() => {}); - return await Promise.race([toolPromise, abortPromise]); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { content: `Tool execution error: ${message}`, isError: true }; - } + if (tool === undefined) { + return { content: `Unknown tool: ${call.name}`, isError: true }; + } + if (signal.aborted) { + return { content: "Aborted", isError: true }; + } + const ctx: ToolExecuteContext = { + toolCallId: call.id, + signal, + onOutput: (data, stream) => { + emit(toolOutputEvent(conversationId, turnId, call.id, data, stream)); + }, + log: toolSpan?.log ?? createNoopLogger(), + conversationId, + ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), + }; + // Race the tool's execute promise against the abort signal so a tool + // that hangs (ignores ctx.signal, or blocks on something the signal + // can't interrupt) can't keep runTurn from returning. When the signal + // fires we RESOLVE (not reject) with an "Aborted" result so the step + // completes normally and the existing signal.aborted → finishReason = + // "aborted" path seals the turn cleanly (done event), letting the + // caller's finally clear active state and the FE clear its spinner. + try { + const toolPromise = tool.execute(call.input, ctx); + const abortPromise = new Promise((resolve) => { + signal.addEventListener("abort", () => resolve({ content: "Aborted", isError: true }), { + once: true, + }); + }); + // Swallow late rejections from the orphaned tool promise: the tool + // may reject after the race already resolved with "Aborted". + void toolPromise.catch(() => {}); + return await Promise.race([toolPromise, abortPromise]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { content: `Tool execution error: ${message}`, isError: true }; + } } interface QueueEntry { - readonly call: ToolCall; - readonly tool: ToolContract | undefined; - readonly resolve: (result: ToolResult) => void; + readonly call: ToolCall; + readonly tool: ToolContract | undefined; + readonly resolve: (result: ToolResult) => void; } export function createStepDispatcher( - toolMap: Map, - policy: ToolDispatchPolicy, - signal: AbortSignal, - emit: EventEmitter, - conversationId: string, - turnId: string, - toolSpans: Map, - cwd?: string, - computerId?: string, + toolMap: Map, + policy: ToolDispatchPolicy, + signal: AbortSignal, + emit: EventEmitter, + conversationId: string, + turnId: string, + toolSpans: Map, + cwd?: string, + computerId?: string, ): StepDispatcher { - let activeCount = 0; - let unsafeRunning = false; - const queue: QueueEntry[] = []; - const allPromises: Array<{ id: string; promise: Promise }> = []; - const dedupMap = new Map>(); + let activeCount = 0; + let unsafeRunning = false; + const queue: QueueEntry[] = []; + const allPromises: Array<{ id: string; promise: Promise }> = []; + const dedupMap = new Map>(); - function canStart(isConcurrencySafe: boolean): boolean { - if (unsafeRunning) return false; - if (!isConcurrencySafe && activeCount > 0) return false; - if (policy.maxConcurrent === 0) return true; - return activeCount < policy.maxConcurrent; - } + function canStart(isConcurrencySafe: boolean): boolean { + if (unsafeRunning) return false; + if (!isConcurrencySafe && activeCount > 0) return false; + if (policy.maxConcurrent === 0) return true; + return activeCount < policy.maxConcurrent; + } - function tryStartNext(): void { - while (queue.length > 0) { - const next = queue[0]; - if (next === undefined) break; - const isSafe = next.tool?.concurrencySafe !== false; - if (!canStart(isSafe)) break; - queue.shift(); - activeCount++; - if (!isSafe) unsafeRunning = true; - void runAndResolve(next); - } - } + function tryStartNext(): void { + while (queue.length > 0) { + const next = queue[0]; + if (next === undefined) break; + const isSafe = next.tool?.concurrencySafe !== false; + if (!canStart(isSafe)) break; + queue.shift(); + activeCount++; + if (!isSafe) unsafeRunning = true; + void runAndResolve(next); + } + } - async function runAndResolve(entry: QueueEntry): Promise { - const tcSpan = toolSpans.get(entry.call.id); - const result = await executeToolCall( - entry.call, - entry.tool, - signal, - emit, - conversationId, - turnId, - tcSpan, - cwd, - computerId, - ); - activeCount--; - if (entry.tool?.concurrencySafe === false) unsafeRunning = false; - entry.resolve(result); - tryStartNext(); - } + async function runAndResolve(entry: QueueEntry): Promise { + const tcSpan = toolSpans.get(entry.call.id); + const result = await executeToolCall( + entry.call, + entry.tool, + signal, + emit, + conversationId, + turnId, + tcSpan, + cwd, + computerId, + ); + activeCount--; + if (entry.tool?.concurrencySafe === false) unsafeRunning = false; + entry.resolve(result); + tryStartNext(); + } - function submit(call: ToolCall): void { - const tool = toolMap.get(call.name); - const key = `${call.name}:${JSON.stringify(call.input)}`; + function submit(call: ToolCall): void { + const tool = toolMap.get(call.name); + const key = `${call.name}:${JSON.stringify(call.input)}`; - const existing = dedupMap.get(key); - if (existing !== undefined) { - allPromises.push({ id: call.id, promise: existing }); - return; - } + const existing = dedupMap.get(key); + if (existing !== undefined) { + allPromises.push({ id: call.id, promise: existing }); + return; + } - const promise = new Promise((resolve) => { - queue.push({ call, tool, resolve }); - tryStartNext(); - }); + const promise = new Promise((resolve) => { + queue.push({ call, tool, resolve }); + tryStartNext(); + }); - dedupMap.set(key, promise); - allPromises.push({ id: call.id, promise }); - } + dedupMap.set(key, promise); + allPromises.push({ id: call.id, promise }); + } - async function drain(): Promise> { - if (signal.aborted) { - for (const item of queue) { - item.resolve({ content: "Aborted", isError: true }); - } - queue.length = 0; - } + async function drain(): Promise> { + if (signal.aborted) { + for (const item of queue) { + item.resolve({ content: "Aborted", isError: true }); + } + queue.length = 0; + } - const results = new Map(); - for (const entry of allPromises) { - const result = await entry.promise; - results.set(entry.id, result); - } - return results; - } + const results = new Map(); + for (const entry of allPromises) { + const result = await entry.promise; + results.set(entry.id, result); + } + return results; + } - return { submit, drain }; + return { submit, drain }; } function createNoopLogger(): Logger { - return { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return createNoopLogger(); - }, - span() { - return { - id: "noop", - log: createNoopLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return createNoopLogger(); + }, + span() { + return { + id: "noop", + log: createNoopLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } diff --git a/packages/kernel/src/runtime/events.ts b/packages/kernel/src/runtime/events.ts index 5805e28..353b6ca 100644 --- a/packages/kernel/src/runtime/events.ts +++ b/packages/kernel/src/runtime/events.ts @@ -3,178 +3,178 @@ import type { AgentEvent } from "../contracts/events.js"; import type { Usage } from "../contracts/provider.js"; export function textDeltaEvent(conversationId: string, turnId: string, delta: string): AgentEvent { - return { type: "text-delta", conversationId, turnId, delta }; + return { type: "text-delta", conversationId, turnId, delta }; } export function reasoningDeltaEvent( - conversationId: string, - turnId: string, - delta: string, + conversationId: string, + turnId: string, + delta: string, ): AgentEvent { - return { type: "reasoning-delta", conversationId, turnId, delta }; + return { type: "reasoning-delta", conversationId, turnId, delta }; } export function toolCallEvent( - conversationId: string, - turnId: string, - stepId: StepId, - toolCallId: string, - toolName: string, - input: unknown, + conversationId: string, + turnId: string, + stepId: StepId, + toolCallId: string, + toolName: string, + input: unknown, ): AgentEvent { - return { type: "tool-call", conversationId, turnId, stepId, toolCallId, toolName, input }; + return { type: "tool-call", conversationId, turnId, stepId, toolCallId, toolName, input }; } export function toolResultEvent( - conversationId: string, - turnId: string, - stepId: StepId, - toolCallId: string, - toolName: string, - content: string, - isError: boolean, - durationMs?: number, + conversationId: string, + turnId: string, + stepId: StepId, + toolCallId: string, + toolName: string, + content: string, + isError: boolean, + durationMs?: number, ): AgentEvent { - const base = { - type: "tool-result" as const, - conversationId, - turnId, - stepId, - toolCallId, - toolName, - content, - isError, - }; - if (durationMs !== undefined) { - return { ...base, durationMs }; - } - return base; + const base = { + type: "tool-result" as const, + conversationId, + turnId, + stepId, + toolCallId, + toolName, + content, + isError, + }; + if (durationMs !== undefined) { + return { ...base, durationMs }; + } + return base; } export function toolOutputEvent( - conversationId: string, - turnId: string, - toolCallId: string, - data: string, - stream: "stdout" | "stderr", + conversationId: string, + turnId: string, + toolCallId: string, + data: string, + stream: "stdout" | "stderr", ): AgentEvent { - return { type: "tool-output", conversationId, turnId, toolCallId, data, stream }; + return { type: "tool-output", conversationId, turnId, toolCallId, data, stream }; } export function usageEvent( - conversationId: string, - turnId: string, - usage: Usage, - stepId?: StepId, + conversationId: string, + turnId: string, + usage: Usage, + stepId?: StepId, ): AgentEvent { - if (stepId !== undefined) { - return { type: "usage", conversationId, turnId, usage, stepId }; - } - return { type: "usage", conversationId, turnId, usage }; + if (stepId !== undefined) { + return { type: "usage", conversationId, turnId, usage, stepId }; + } + return { type: "usage", conversationId, turnId, usage }; } export function turnStartEvent(conversationId: string, turnId: string): AgentEvent { - return { type: "turn-start", conversationId, turnId }; + return { type: "turn-start", conversationId, turnId }; } export function stepCompleteEvent( - conversationId: string, - turnId: string, - stepId: StepId, - timing?: { ttftMs?: number; decodeMs?: number; genTotalMs?: number }, + conversationId: string, + turnId: string, + stepId: StepId, + timing?: { ttftMs?: number; decodeMs?: number; genTotalMs?: number }, ): AgentEvent { - if (timing !== undefined) { - if (timing.ttftMs !== undefined) { - if (timing.decodeMs !== undefined && timing.genTotalMs !== undefined) { - return { - type: "step-complete", - conversationId, - turnId, - stepId, - ttftMs: timing.ttftMs, - decodeMs: timing.decodeMs, - genTotalMs: timing.genTotalMs, - }; - } - if (timing.genTotalMs !== undefined) { - return { - type: "step-complete", - conversationId, - turnId, - stepId, - ttftMs: timing.ttftMs, - genTotalMs: timing.genTotalMs, - }; - } - return { type: "step-complete", conversationId, turnId, stepId, ttftMs: timing.ttftMs }; - } - if (timing.genTotalMs !== undefined) { - return { - type: "step-complete", - conversationId, - turnId, - stepId, - genTotalMs: timing.genTotalMs, - }; - } - } - return { type: "step-complete", conversationId, turnId, stepId }; + if (timing !== undefined) { + if (timing.ttftMs !== undefined) { + if (timing.decodeMs !== undefined && timing.genTotalMs !== undefined) { + return { + type: "step-complete", + conversationId, + turnId, + stepId, + ttftMs: timing.ttftMs, + decodeMs: timing.decodeMs, + genTotalMs: timing.genTotalMs, + }; + } + if (timing.genTotalMs !== undefined) { + return { + type: "step-complete", + conversationId, + turnId, + stepId, + ttftMs: timing.ttftMs, + genTotalMs: timing.genTotalMs, + }; + } + return { type: "step-complete", conversationId, turnId, stepId, ttftMs: timing.ttftMs }; + } + if (timing.genTotalMs !== undefined) { + return { + type: "step-complete", + conversationId, + turnId, + stepId, + genTotalMs: timing.genTotalMs, + }; + } + } + return { type: "step-complete", conversationId, turnId, stepId }; } export function doneEvent( - conversationId: string, - turnId: string, - reason: string, - durationMs?: number, - usage?: Usage, - contextSize?: number, + conversationId: string, + turnId: string, + reason: string, + durationMs?: number, + usage?: Usage, + contextSize?: number, ): AgentEvent { - if (durationMs !== undefined && usage !== undefined && contextSize !== undefined) { - return { type: "done", conversationId, turnId, reason, durationMs, usage, contextSize }; - } - if (durationMs !== undefined && usage !== undefined) { - return { type: "done", conversationId, turnId, reason, durationMs, usage }; - } - if (durationMs !== undefined && contextSize !== undefined) { - return { type: "done", conversationId, turnId, reason, durationMs, contextSize }; - } - if (usage !== undefined && contextSize !== undefined) { - return { type: "done", conversationId, turnId, reason, usage, contextSize }; - } - if (durationMs !== undefined) { - return { type: "done", conversationId, turnId, reason, durationMs }; - } - if (usage !== undefined) { - return { type: "done", conversationId, turnId, reason, usage }; - } - if (contextSize !== undefined) { - return { type: "done", conversationId, turnId, reason, contextSize }; - } - return { type: "done", conversationId, turnId, reason }; + if (durationMs !== undefined && usage !== undefined && contextSize !== undefined) { + return { type: "done", conversationId, turnId, reason, durationMs, usage, contextSize }; + } + if (durationMs !== undefined && usage !== undefined) { + return { type: "done", conversationId, turnId, reason, durationMs, usage }; + } + if (durationMs !== undefined && contextSize !== undefined) { + return { type: "done", conversationId, turnId, reason, durationMs, contextSize }; + } + if (usage !== undefined && contextSize !== undefined) { + return { type: "done", conversationId, turnId, reason, usage, contextSize }; + } + if (durationMs !== undefined) { + return { type: "done", conversationId, turnId, reason, durationMs }; + } + if (usage !== undefined) { + return { type: "done", conversationId, turnId, reason, usage }; + } + if (contextSize !== undefined) { + return { type: "done", conversationId, turnId, reason, contextSize }; + } + return { type: "done", conversationId, turnId, reason }; } export function errorEvent( - conversationId: string, - turnId: string, - message: string, - code?: string, + conversationId: string, + turnId: string, + message: string, + code?: string, ): AgentEvent { - if (code !== undefined) { - return { type: "error", conversationId, turnId, message, code }; - } - return { type: "error", conversationId, turnId, message }; + if (code !== undefined) { + return { type: "error", conversationId, turnId, message, code }; + } + return { type: "error", conversationId, turnId, message }; } export function providerRetryEvent( - conversationId: string, - turnId: string, - attempt: number, - delayMs: number, - message: string, - code?: string, + conversationId: string, + turnId: string, + attempt: number, + delayMs: number, + message: string, + code?: string, ): AgentEvent { - if (code !== undefined) { - return { type: "provider-retry", conversationId, turnId, attempt, delayMs, message, code }; - } - return { type: "provider-retry", conversationId, turnId, attempt, delayMs, message }; + if (code !== undefined) { + return { type: "provider-retry", conversationId, turnId, attempt, delayMs, message, code }; + } + return { type: "provider-retry", conversationId, turnId, attempt, delayMs, message }; } diff --git a/packages/kernel/src/runtime/index.ts b/packages/kernel/src/runtime/index.ts index e0dd656..ecb802e 100644 --- a/packages/kernel/src/runtime/index.ts +++ b/packages/kernel/src/runtime/index.ts @@ -1,13 +1,13 @@ export type { StepDispatcher } from "./dispatch.js"; export { createStepDispatcher, executeToolCall } from "./dispatch.js"; export { - errorEvent, - providerRetryEvent, - reasoningDeltaEvent, - textDeltaEvent, - toolCallEvent, - toolOutputEvent, - toolResultEvent, - usageEvent, + errorEvent, + providerRetryEvent, + reasoningDeltaEvent, + textDeltaEvent, + toolCallEvent, + toolOutputEvent, + toolResultEvent, + usageEvent, } from "./events.js"; export { MAX_STEPS, runTurn } from "./run-turn.js"; diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index 59e7fab..8d20975 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -8,3429 +8,3429 @@ import { createLogger } from "../logging/logger.js"; import { runTurn } from "./run-turn.js"; function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } function createFakeProvider(script: ProviderEvent[][]): ProviderContract { - let callIndex = 0; - return { - id: "fake", - stream(_messages, _tools) { - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; + let callIndex = 0; + return { + id: "fake", + stream(_messages, _tools) { + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; } function createCapturingProvider(script: ProviderEvent[][]): { - provider: ProviderContract; - capturedMessages: ChatMessage[][]; + provider: ProviderContract; + capturedMessages: ChatMessage[][]; } { - const capturedMessages: ChatMessage[][] = []; - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream(messages, _tools) { - capturedMessages.push([...messages]); - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; - return { provider, capturedMessages }; + const capturedMessages: ChatMessage[][] = []; + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream(messages, _tools) { + capturedMessages.push([...messages]); + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; + return { provider, capturedMessages }; } function createFakeTool( - name: string, - handler?: (input: unknown, ctx: ToolExecuteContext) => Promise, - opts?: { concurrencySafe?: boolean }, + name: string, + handler?: (input: unknown, ctx: ToolExecuteContext) => Promise, + opts?: { concurrencySafe?: boolean }, ): ToolContract { - return { - name, - description: `Fake tool: ${name}`, - parameters: { type: "object" }, - ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}), - execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })), - }; + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + ...(opts?.concurrencySafe !== undefined ? { concurrencySafe: opts.concurrencySafe } : {}), + execute: handler ?? (async (input) => ({ content: `${name}: ${JSON.stringify(input)}` })), + }; } function createCollectingEmit(): { events: AgentEvent[]; emit: (event: AgentEvent) => void } { - const events: AgentEvent[] = []; - return { events, emit: (event) => events.push(event) }; + const events: AgentEvent[] = []; + return { events, emit: (event) => events.push(event) }; } const userMessage: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "hello" }], + role: "user", + chunks: [{ type: "text", text: "hello" }], }; describe("runTurn", () => { - it("emits events with the conversationId and turnId from input", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-42", - turnId: "turn-99", - emit, - }); - - expect(events.length).toBeGreaterThan(0); - for (const event of events) { - expect(event.conversationId).toBe("conv-42"); - if (event.type !== "status") { - expect(event.turnId).toBe("turn-99"); - } - } - }); - - it("text-only turn emits correct events and returns correct result", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "reasoning-delta", delta: "thinking..." }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("stop"); - expect(result.messages).toHaveLength(1); - expect(result.messages[0]?.role).toBe("assistant"); - - const chunks = result.messages[0]?.chunks ?? []; - expect(chunks).toHaveLength(2); - expect(chunks[0]).toEqual({ type: "text", text: "Hello world" }); - expect(chunks[1]).toEqual({ type: "thinking", text: "thinking..." }); - - expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); - - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toEqual([ - "turn-start", - "text-delta", - "text-delta", - "reasoning-delta", - "usage", - "step-complete", - "done", - ]); - }); - - it("turn with one tool call executes tool, feeds result back, then finishes", async () => { - const tool = createFakeTool("greet", async (input) => ({ - content: `Hello, ${(input as { name: string }).name}!`, - })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "greet", input: { name: "World" } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "Done." }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("stop"); - expect(result.messages).toHaveLength(3); - expect(result.messages[0]?.role).toBe("assistant"); - expect(result.messages[1]?.role).toBe("tool"); - expect(result.messages[2]?.role).toBe("assistant"); - - const toolResultChunk = result.messages[1]?.chunks[0]; - expect(toolResultChunk?.type).toBe("tool-result"); - if (toolResultChunk?.type === "tool-result") { - expect(toolResultChunk.content).toBe("Hello, World!"); - expect(toolResultChunk.toolCallId).toBe("tc1"); - expect(toolResultChunk.isError).toBe(false); - } - - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toContain("tool-call"); - expect(eventTypes).toContain("tool-result"); - expect(eventTypes).toContain("text-delta"); - }); - - it("passes updated messages to subsequent provider calls", async () => { - const capturedMessages: ChatMessage[][] = []; - let callIndex = 0; - const script: ProviderEvent[][] = [ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]; - - const provider: ProviderContract = { - id: "fake", - stream(messages, _tools) { - capturedMessages.push([...messages]); - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) yield event; - })(); - }, - }; - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedMessages).toHaveLength(2); - expect(capturedMessages[0] ?? []).toHaveLength(1); - expect(capturedMessages[0]?.[0]?.role).toBe("user"); - - expect(capturedMessages[1] ?? []).toHaveLength(3); - expect(capturedMessages[1]?.[0]?.role).toBe("user"); - expect(capturedMessages[1]?.[1]?.role).toBe("assistant"); - expect(capturedMessages[1]?.[2]?.role).toBe("tool"); - }); - - it("maxConcurrent: 1 runs tools sequentially", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(10); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(10); - log.push("b:end"); - return { content: "b" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aEndIdx = log.indexOf("a:end"); - const bStartIdx = log.indexOf("b:start"); - expect(aEndIdx).toBeLessThan(bStartIdx); - }); - - it("maxConcurrent: 2 runs tools in parallel", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(20); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(20); - log.push("b:end"); - return { content: "b" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 2, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aStartIdx = log.indexOf("a:start"); - const bStartIdx = log.indexOf("b:start"); - const aEndIdx = log.indexOf("a:end"); - const bEndIdx = log.indexOf("b:end"); - - expect(aStartIdx).toBeLessThan(aEndIdx); - expect(bStartIdx).toBeLessThan(bEndIdx); - expect(aStartIdx).toBeLessThan(bEndIdx); - expect(bStartIdx).toBeLessThan(aEndIdx); - }); - - it("maxConcurrent: 0 runs all tools in parallel (unlimited)", async () => { - const log: string[] = []; - - const toolA = createFakeTool("a", async () => { - log.push("a:start"); - await delay(20); - log.push("a:end"); - return { content: "a" }; - }); - - const toolB = createFakeTool("b", async () => { - log.push("b:start"); - await delay(20); - log.push("b:end"); - return { content: "b" }; - }); - - const toolC = createFakeTool("c", async () => { - log.push("c:start"); - await delay(20); - log.push("c:end"); - return { content: "c" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "tool-call", toolCallId: "tc3", toolName: "c", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB, toolC], - dispatch: { maxConcurrent: 0, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const aStartIdx = log.indexOf("a:start"); - const bStartIdx = log.indexOf("b:start"); - const cStartIdx = log.indexOf("c:start"); - const aEndIdx = log.indexOf("a:end"); - const bEndIdx = log.indexOf("b:end"); - const cEndIdx = log.indexOf("c:end"); - - expect(aStartIdx).toBeLessThan(aEndIdx); - expect(bStartIdx).toBeLessThan(bEndIdx); - expect(cStartIdx).toBeLessThan(cEndIdx); - expect(aStartIdx).toBeLessThan(bEndIdx); - expect(bStartIdx).toBeLessThan(aEndIdx); - expect(cStartIdx).toBeLessThan(aEndIdx); - }); - - it("eager: true launches tool before step finish", async () => { - const log: string[] = []; - - const tool = createFakeTool("test", async () => { - log.push("tool:start"); - await delay(5); - log.push("tool:end"); - return { content: "done" }; - }); - - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = callCount++; - if (idx === 0) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - } as ProviderEvent; - log.push("provider:after-tool-call"); - await delay(50); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - log.push("provider:finish"); - })(); - } - return (async function* () { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: true }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const toolStartIdx = log.indexOf("tool:start"); - const finishIdx = log.indexOf("provider:finish"); - expect(toolStartIdx).toBeLessThan(finishIdx); - }); - - it("eager: false does not launch tool before step finish", async () => { - const log: string[] = []; - - const tool = createFakeTool("test", async () => { - log.push("tool:start"); - await delay(5); - log.push("tool:end"); - return { content: "done" }; - }); - - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = callCount++; - if (idx === 0) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - } as ProviderEvent; - log.push("provider:after-tool-call"); - await delay(50); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - log.push("provider:finish"); - })(); - } - return (async function* () { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const toolStartIdx = log.indexOf("tool:start"); - const finishIdx = log.indexOf("provider:finish"); - expect(toolStartIdx).toBeGreaterThan(finishIdx); - }); - - it("abort mid-turn synthesizes error results for unresolved tool calls", async () => { - const ac = new AbortController(); - - const tool = createFakeTool("slow", async (_input, ctx) => { - await delay(200); - if (ctx.signal.aborted) return { content: "Aborted", isError: true }; - return { content: "done" }; - }); - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - return (async function* () { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "slow", - input: {}, - } as ProviderEvent; - yield { - type: "tool-call", - toolCallId: "tc2", - toolName: "slow", - input: { x: 1 }, - } as ProviderEvent; - ac.abort(); - await delay(10); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - const toolResults = events.filter((e) => e.type === "tool-result"); - for (const tr of toolResults) { - if (tr.type === "tool-result") { - expect(tr.isError).toBe(true); - } - } - }); - - it("abort before any step returns aborted immediately", async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - expect(result.messages).toHaveLength(0); - }); - - it("de-duplicates identical tool calls in a batch", async () => { - let execCount = 0; - - const tool = createFakeTool("dedup", async (_input) => { - execCount++; - return { content: `result-${execCount}` }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "dedup", input: { x: 1 } }, - { type: "tool-call", toolCallId: "tc2", toolName: "dedup", input: { x: 1 } }, - { type: "tool-call", toolCallId: "tc3", toolName: "dedup", input: { x: 2 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(execCount).toBe(2); - - const toolResults = events.filter((e) => e.type === "tool-result"); - expect(toolResults).toHaveLength(3); - - const tc1Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc1"); - const tc2Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc2"); - const tc3Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc3"); - - expect(tc1Result).toBeDefined(); - expect(tc2Result).toBeDefined(); - expect(tc3Result).toBeDefined(); - - if (tc1Result?.type === "tool-result" && tc2Result?.type === "tool-result") { - expect(tc1Result.content).toBe(tc2Result.content); - expect(tc1Result.content).toBe("result-1"); - } - if (tc3Result?.type === "tool-result") { - expect(tc3Result.content).toBe("result-2"); - } - - expect(result.finishReason).toBe("stop"); - }); - - it("serializes non-concurrency-safe tools even with maxConcurrent > 1", async () => { - const log: string[] = []; - - const unsafeTool: ToolContract = { - name: "unsafe", - description: "Unsafe tool", - parameters: { type: "object" }, - concurrencySafe: false, - execute: async () => { - log.push("unsafe:start"); - await delay(10); - log.push("unsafe:end"); - return { content: "done" }; - }, - }; - - const safeTool: ToolContract = { - name: "safe", - description: "Safe tool", - parameters: { type: "object" }, - execute: async () => { - log.push("safe:start"); - await delay(10); - log.push("safe:end"); - return { content: "done" }; - }, - }; - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "unsafe", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "safe", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [unsafeTool, safeTool], - dispatch: { maxConcurrent: 5, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - const unsafeEndIdx = log.indexOf("unsafe:end"); - const safeStartIdx = log.indexOf("safe:start"); - expect(unsafeEndIdx).toBeLessThan(safeStartIdx); - }); - - it("handles unknown tool name gracefully", async () => { - const provider = createFakeProvider([ - [ - { - type: "tool-call", - toolCallId: "tc1", - toolName: "nonexistent", - input: {}, - }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - const toolResults = events.filter((e) => e.type === "tool-result"); - expect(toolResults).toHaveLength(1); - if (toolResults[0]?.type === "tool-result") { - expect(toolResults[0]?.isError).toBe(true); - expect(toolResults[0]?.content).toContain("Unknown tool"); - } - - expect(result.finishReason).toBe("stop"); - }); - - it("handles provider error gracefully", async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider crashed"); - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - expect(result.finishReason).toBe("error"); - - const errorEvents = events.filter((e) => e.type === "error"); - expect(errorEvents).toHaveLength(1); - if (errorEvents[0]?.type === "error") { - expect(errorEvents[0]?.message).toContain("provider crashed"); - } - }); - - it("forwards cwd from RunTurnInput to ToolExecuteContext", async () => { - let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("cwdcheck", async (_input, ctx) => { - capturedCwd = ctx.cwd; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - cwd: "/some/dir", - }); - - expect(capturedCwd).toBe("/some/dir"); - }); - - it("forwards undefined cwd when RunTurnInput has no cwd", async () => { - let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("cwdcheck", async (_input, ctx) => { - capturedCwd = ctx.cwd; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedCwd).toBeUndefined(); - }); - - it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { - let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("computercheck", async (_input, ctx) => { - capturedComputerId = ctx.computerId; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - computerId: "ssh-host-alias", - }); - - expect(capturedComputerId).toBe("ssh-host-alias"); - }); - - it("forwards undefined computerId when RunTurnInput has no computerId", async () => { - let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; - - const tool = createFakeTool("computercheck", async (_input, ctx) => { - capturedComputerId = ctx.computerId; - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(capturedComputerId).toBeUndefined(); - }); - - it("aggregates usage across multiple steps", async () => { - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 20, outputTokens: 10 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit: () => {}, - }); - - expect(result.usage).toEqual({ inputTokens: 30, outputTokens: 15 }); - }); - - it("emits tool-output events from tool ctx.onOutput", async () => { - const tool: ToolContract = { - name: "streaming", - description: "A tool that streams output", - parameters: { type: "object" }, - execute: async (_input, ctx) => { - ctx.onOutput("line 1\n", "stdout"); - ctx.onOutput("err 1\n", "stderr"); - return { content: "done" }; - }, - }; - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "streaming", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "tab-test", - turnId: "turn-test", - emit, - }); - - const outputs = events.filter((e) => e.type === "tool-output"); - expect(outputs).toHaveLength(2); - if (outputs[0]?.type === "tool-output") { - expect(outputs[0]?.data).toBe("line 1\n"); - expect(outputs[0]?.stream).toBe("stdout"); - expect(outputs[0]?.toolCallId).toBe("tc1"); - } - if (outputs[1]?.type === "tool-output") { - expect(outputs[1]?.data).toBe("err 1\n"); - expect(outputs[1]?.stream).toBe("stderr"); - } - }); - - function createTestLogger(): { - logger: Logger; - sink: LogSink & { records: LogRecord[] }; - deps: LogDeps; - } { - let idCounter = 0; - const deps: LogDeps = { - now: () => 1000 + idCounter * 100, - newId: () => `span-${++idCounter}`, - }; - const records: LogRecord[] = []; - const sink: LogSink & { records: LogRecord[] } = { - records, - emit: (record) => records.push(record), - }; - const logger = createLogger({ extensionId: "test" }, sink, deps); - return { logger, sink, deps }; - } - - describe("span instrumentation", () => { - it("emits turn + step span open/close in order", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const spanOpens = sink.records.filter((r) => r.kind === "span-open"); - const spanCloses = sink.records.filter((r) => r.kind === "span-close"); - - expect(spanOpens.length).toBeGreaterThanOrEqual(2); // turn + step - expect(spanCloses.length).toBeGreaterThanOrEqual(2); - - const turnOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "turn"); - const stepOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "step"); - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - - if (turnOpen?.kind === "span-open") { - expect(turnOpen.extensionId).toBe("test"); - expect(turnOpen.attributes?.conversationId).toBe("conv-1"); - expect(turnOpen.attributes?.turnId).toBe("turn-1"); - } - - const turnClose = spanCloses.find((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.status).toBe("ok"); - expect(turnClose.durationMs).toBeGreaterThanOrEqual(0); - } - }); - - it("emits tool-call spans for dispatched tools", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const toolCallSpans = sink.records.filter( - (r) => r.kind === "span-open" && r.name === "tool-call", - ); - expect(toolCallSpans).toHaveLength(1); - if (toolCallSpans[0]?.kind === "span-open") { - expect(toolCallSpans[0].attributes?.name).toBe("echo"); - expect(toolCallSpans[0].attributes?.toolCallId).toBe("tc1"); - } - - const toolCallCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "tool-call", - ); - expect(toolCallCloses).toHaveLength(1); - if (toolCallCloses[0]?.kind === "span-close") { - expect(toolCallCloses[0].status).toBe("ok"); - } - }); - - it("tools receive ctx.log (correlated logger)", async () => { - let capturedLog: Logger | undefined; - - const tool = createFakeTool("logtest", async (_input, ctx) => { - capturedLog = ctx.log; - ctx.log.info("tool ran", { key: "value" }); - return { content: "ok" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "logtest", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedLog).toBeDefined(); - - const toolLogs = sink.records.filter( - (r) => r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "tool ran", - ); - expect(toolLogs).toHaveLength(1); - if (toolLogs[0]?.kind === "log") { - expect(toolLogs[0].attributes?.key).toBe("value"); - expect(toolLogs[0].extensionId).toBe("test"); - } - }); - - it("an aborted turn still closes its turn span", async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - signal: ac.signal, - logger, - }); - - const turnCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnCloses).toHaveLength(1); - if (turnCloses[0]?.kind === "span-close") { - expect(turnCloses[0].attributes?.finishReason).toBe("aborted"); - } - }); - - it("a provider error closes the step span with error status", async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider exploded"); - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(result.finishReason).toBe("error"); - - const stepCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "step"); - expect(stepCloses).toHaveLength(1); - if (stepCloses[0]?.kind === "span-close") { - expect(stepCloses[0].status).toBe("error"); - expect(stepCloses[0].attributes?.["error.message"]).toContain("provider exploded"); - } - }); - - it("emits a prompt span with verbatim body and small scalar attributes", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const promptOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "prompt"); - expect(promptOpens).toHaveLength(1); - - const promptOpen = promptOpens[0]; - if (promptOpen?.kind === "span-open") { - expect(promptOpen.body).toBeDefined(); - const parsed = JSON.parse(promptOpen.body as string); - expect(parsed.messages).toEqual([userMessage]); - expect(parsed.tools).toHaveLength(1); - expect(parsed.tools[0].name).toBe("echo"); - - expect(promptOpen.attributes?.messageCount).toBe(1); - expect(promptOpen.attributes?.toolCount).toBe(1); - } - - const promptCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "prompt", - ); - expect(promptCloses).toHaveLength(1); - - const logRecords = sink.records.filter( - (r) => - r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "prompt:before", - ); - expect(logRecords).toHaveLength(0); - }); - - it("emits ttft and decode spans for a generating step", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); - const decodeCloses = sink.records.filter( - (r) => r.kind === "span-close" && r.name === "decode", - ); - - expect(ttftOpens).toHaveLength(1); - expect(ttftCloses).toHaveLength(1); - expect(decodeOpens).toHaveLength(1); - expect(decodeCloses).toHaveLength(1); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - expect(stepOpen).toBeDefined(); - - if ( - ttftOpens[0]?.kind === "span-open" && - ttftCloses[0]?.kind === "span-close" && - decodeOpens[0]?.kind === "span-open" && - decodeCloses[0]?.kind === "span-close" && - stepOpen?.kind === "span-open" - ) { - // ttft and decode are children of step - expect(ttftOpens[0].parentSpanId).toBe(stepOpen.spanId); - expect(decodeOpens[0].parentSpanId).toBe(stepOpen.spanId); - - // ttft closes before decode opens (in order) - const ttftCloseIdx = sink.records.indexOf(ttftCloses[0]); - const decodeOpenIdx = sink.records.indexOf(decodeOpens[0]); - expect(ttftCloseIdx).toBeLessThan(decodeOpenIdx); - - // ttft has firstToken: true - expect(ttftCloses[0].attributes?.firstToken).toBe(true); - - // durations from fake clock - expect(ttftCloses[0].durationMs).toBeGreaterThanOrEqual(0); - expect(decodeCloses[0].durationMs).toBeGreaterThanOrEqual(0); - } - }); - - it("first token counts a reasoning delta", async () => { - const provider = createFakeProvider([ - [ - { type: "reasoning-delta", delta: "thinking..." }, - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - expect(ttftCloses).toHaveLength(1); - - // The ttft span should close at the reasoning delta, not at the text delta - if (ttftCloses[0]?.kind === "span-close") { - expect(ttftCloses[0].attributes?.firstToken).toBe(true); - } - }); - - it("a step with no content token does not emit a misleading decode", async () => { - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - // First step (tool-call-only) should have ttft with firstToken: false and no decode - const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); - const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); - const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); - - // There should be 2 ttft opens (one per step) and 2 ttft closes - expect(ttftOpens).toHaveLength(2); - expect(ttftCloses).toHaveLength(2); - - // First step: tool-call-only, no first token - if (ttftCloses[0]?.kind === "span-close") { - expect(ttftCloses[0].attributes?.firstToken).toBe(false); - } - - // Second step: has text-delta, should have firstToken: true and decode span - if (ttftCloses[1]?.kind === "span-close") { - expect(ttftCloses[1].attributes?.firstToken).toBe(true); - } - - // Only one decode span (for the second step) - expect(decodeOpens).toHaveLength(1); - }); - - it("turn span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.usage_inputTokens).toBeUndefined(); - expect(turnClose.attributes?.usage_outputTokens).toBeUndefined(); - } - }); - - it("step span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 7, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(7); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(3); - expect(stepClose.attributes?.usage_inputTokens).toBeUndefined(); - expect(stepClose.attributes?.usage_outputTokens).toBeUndefined(); - } - }); - - it("turn + step spans stamp usage.cacheReadTokens / usage.cacheWriteTokens when the provider Usage carries them", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 }, - }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBe(3); - expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); - } - - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBe(3); - expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); - } - }); - - it("turn + step spans OMIT the cache-token attrs when the provider Usage lacks them", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); - const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); - - expect(turnClose).toBeDefined(); - if (turnClose?.kind === "span-close") { - expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); - expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); - } - - expect(stepClose).toBeDefined(); - if (stepClose?.kind === "span-close") { - expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); - expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); - expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); - expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); - } - }); - }); - - describe("provider logger threading", () => { - it("passes step span logger to provider.stream opts when logger provided", async () => { - let capturedOpts: Record | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { logger } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedOpts).toBeDefined(); - expect(capturedOpts?.logger).toBeDefined(); - expect(typeof (capturedOpts?.logger as Record).info).toBe("function"); - expect(typeof (capturedOpts?.logger as Record).span).toBe("function"); - }); - - it("passes undefined for opts.logger when no logger provided", async () => { - let capturedOpts: Record | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - }); - - expect(capturedOpts).toBeDefined(); - expect(capturedOpts?.logger).toBeUndefined(); - }); - - it("threads providerOpts.model through to provider.stream opts", async () => { - let capturedOpts: Record | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedOpts = opts !== undefined ? { ...opts } : undefined; - return (async function* () { - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - providerOpts: { model: "some-model-id" }, - }); - - expect(capturedOpts?.model).toBe("some-model-id"); - }); - }); - - describe("span tree nesting", () => { - it("turn span is root (parentSpanId undefined)", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); - expect(turnOpen).toBeDefined(); - if (turnOpen?.kind === "span-open") { - expect(turnOpen.parentSpanId).toBeUndefined(); - } - }); - - it("step span is a child of turn span", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - if (turnOpen?.kind === "span-open" && stepOpen?.kind === "span-open") { - expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); - } - }); - - it("prompt span is a child of step span", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const promptOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "prompt"); - expect(stepOpen).toBeDefined(); - expect(promptOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && promptOpen?.kind === "span-open") { - expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - - it("provider logger creates spans nested under step", async () => { - let capturedLogger: Logger | undefined; - let providerReqSpanId: string | undefined; - - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedLogger = opts?.logger; - return (async function* () { - // Open provider.request span inside the stream (like a real provider) - if (capturedLogger !== undefined) { - const span = capturedLogger.span("provider.request"); - providerReqSpanId = span.id; - span.end(); - } - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - expect(capturedLogger).toBeDefined(); - expect(providerReqSpanId).toBeDefined(); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const provReqOpen = sink.records.find( - (r) => r.kind === "span-open" && r.name === "provider.request", - ); - expect(stepOpen).toBeDefined(); - expect(provReqOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && provReqOpen?.kind === "span-open") { - expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); - expect(provReqOpen.spanId).toBe(providerReqSpanId); - } - }); - - it("tool-call spans are children of step span", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); - const tcOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "tool-call"); - expect(stepOpen).toBeDefined(); - expect(tcOpen).toBeDefined(); - if (stepOpen?.kind === "span-open" && tcOpen?.kind === "span-open") { - expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - - it("full parent chain: turn → step → {prompt, provider.request, tool-call}", async () => { - let capturedLogger: Logger | undefined; - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let streamCallCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(_messages, _tools, opts) { - capturedLogger = opts?.logger; - streamCallCount++; - return (async function* () { - // Simulate provider opening a provider.request span - // INSIDE the stream on the first call only (like a real provider) - if (streamCallCount === 1 && capturedLogger !== undefined) { - const span = capturedLogger.span("provider.request"); - span.end(); - } - if (streamCallCount === 1) { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - yield { type: "text-delta", delta: "done" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { logger, sink } = createTestLogger(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - logger, - }); - - const spanOpens = sink.records.filter((r) => r.kind === "span-open") as Array< - Extract - >; - - const turnOpen = spanOpens.find((r) => r.name === "turn"); - const stepOpen = spanOpens.find((r) => r.name === "step"); - const promptOpen = spanOpens.find((r) => r.name === "prompt"); - const provReqOpen = spanOpens.find((r) => r.name === "provider.request"); - const tcOpen = spanOpens.find((r) => r.name === "tool-call"); - - expect(turnOpen).toBeDefined(); - expect(stepOpen).toBeDefined(); - expect(promptOpen).toBeDefined(); - expect(provReqOpen).toBeDefined(); - expect(tcOpen).toBeDefined(); - - if ( - turnOpen?.kind === "span-open" && - stepOpen?.kind === "span-open" && - promptOpen?.kind === "span-open" && - provReqOpen?.kind === "span-open" && - tcOpen?.kind === "span-open" - ) { - // turn = root - expect(turnOpen.parentSpanId).toBeUndefined(); - - // step = child of turn - expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); - - // prompt = child of step - expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); - - // provider.request = child of step - expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); - - // tool-call = child of step - expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); - } - }); - }); - - describe("lifecycle events", () => { - it("emits turn-start as the first event with conversation + turn ids", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-42", - turnId: "turn-99", - emit, - }); - - expect(events[0]?.type).toBe("turn-start"); - if (events[0]?.type === "turn-start") { - expect(events[0].conversationId).toBe("conv-42"); - expect(events[0].turnId).toBe("turn-99"); - } - }); - - it("emits a single done event last, carrying the finishReason", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe(result.finishReason); - expect(lastEvent.conversationId).toBe("conv-1"); - expect(lastEvent.turnId).toBe("turn-1"); - } - - const doneEvents = events.filter((e) => e.type === "done"); - expect(doneEvents).toHaveLength(1); - }); - - it("emits done after a tool-call turn", async () => { - const tool = createFakeTool("echo", async (input) => ({ - content: `echo: ${JSON.stringify(input)}`, - })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: { x: 1 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe(result.finishReason); - } - }); - - it('still emits done with reason "aborted" when the turn is aborted via signal', async () => { - const ac = new AbortController(); - ac.abort(); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "should not appear" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: ac.signal, - }); - - expect(result.finishReason).toBe("aborted"); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe("aborted"); - } - }); - - it('still emits done with reason "error" when the provider errors', async () => { - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("provider crashed"); - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - expect(result.finishReason).toBe("error"); - - const lastEvent = events[events.length - 1]; - expect(lastEvent?.type).toBe("done"); - if (lastEvent?.type === "done") { - expect(lastEvent.reason).toBe("error"); - } - }); - - it("turn-start precedes every delta and done follows every delta", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "reasoning-delta", delta: "thinking..." }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const turnStartIdx = events.findIndex((e) => e.type === "turn-start"); - const doneIdx = events.findIndex((e) => e.type === "done"); - - expect(turnStartIdx).toBe(0); - expect(doneIdx).toBe(events.length - 1); - - for (let i = 0; i < events.length; i++) { - const e = events[i]; - if (e?.type === "text-delta" || e?.type === "reasoning-delta") { - expect(i).toBeGreaterThan(turnStartIdx); - expect(i).toBeLessThan(doneIdx); - } - } - }); - }); - - describe("stepId", () => { - it("tool-call and tool-result events carry stepId", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const toolCallEvt = events.find((e) => e.type === "tool-call"); - const toolResultEvt = events.find((e) => e.type === "tool-result"); - - expect(toolCallEvt).toBeDefined(); - expect(toolResultEvt).toBeDefined(); - - if (toolCallEvt?.type === "tool-call" && toolResultEvt?.type === "tool-result") { - expect(toolCallEvt.stepId).toBeDefined(); - expect(toolResultEvt.stepId).toBeDefined(); - expect(toolCallEvt.stepId).toBe(toolResultEvt.stepId); - } - }); - - it("tool calls in the SAME step share one stepId; a later step gets a different one", async () => { - const toolA = createFakeTool("a", async () => ({ content: "a-result" })); - const toolB = createFakeTool("b", async () => ({ content: "b-result" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, - { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "tool-call", toolCallId: "tc3", toolName: "a", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [toolA, toolB], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const toolCallEvts = events.filter((e) => e.type === "tool-call"); - expect(toolCallEvts.length).toBeGreaterThanOrEqual(2); - - const step0Calls = toolCallEvts.filter( - (e) => e.type === "tool-call" && (e.toolCallId === "tc1" || e.toolCallId === "tc2"), - ); - const step1Call = toolCallEvts.find((e) => e.type === "tool-call" && e.toolCallId === "tc3"); - - expect(step0Calls).toHaveLength(2); - if (step0Calls[0]?.type === "tool-call" && step0Calls[1]?.type === "tool-call") { - expect(step0Calls[0].stepId).toBe(step0Calls[1].stepId); - } - - if (step1Call?.type === "tool-call" && step0Calls[0]?.type === "tool-call") { - expect(step1Call.stepId).not.toBe(step0Calls[0].stepId); - } - }); - - it("tool chunks in the result carry stepId", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - }); - - const toolCallMsg = result.messages.find( - (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), - ); - const toolResultMsg = result.messages.find((m) => m.role === "tool"); - - expect(toolCallMsg).toBeDefined(); - expect(toolResultMsg).toBeDefined(); - - const tcChunk = toolCallMsg?.chunks.find((c) => c.type === "tool-call"); - const trChunk = toolResultMsg?.chunks[0]; - - expect(tcChunk?.type).toBe("tool-call"); - expect(trChunk?.type).toBe("tool-result"); - - if (tcChunk?.type === "tool-call" && trChunk?.type === "tool-result") { - expect(tcChunk.stepId).toBeDefined(); - expect(trChunk.stepId).toBeDefined(); - expect(tcChunk.stepId).toBe(trChunk.stepId); - } - }); - }); - - describe("timing events (now provided)", () => { - function createCounterNow(): { now: () => number; tick: (ms: number) => void } { - let current = 0; - return { - now: () => current, - tick: (ms: number) => { - current += ms; - }, - }; - } - - it("emits step-complete per step with timing when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const { events, emit } = createCollectingEmit(); - - // Advance clock during stream: first token at +50ms, stream ends at +200ms - let streamCallCount = 0; - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = streamCallCount++; - return (async function* () { - if (idx === 0) { - clock.tick(50); // stream starts - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - // first token seen at 150 (100+50) - clock.tick(100); - yield { type: "text-delta", delta: " world" } as ProviderEvent; - clock.tick(50); - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(1); - - const sc = stepCompleteEvts[0]; - if (sc?.type === "step-complete") { - expect(sc.conversationId).toBe("conv-1"); - expect(sc.turnId).toBe("turn-1"); - expect(sc.stepId).toBeDefined(); - expect(sc.genTotalMs).toBe(200); // 50+100+50 - expect(sc.ttftMs).toBe(50); // stream start → first text-delta - expect(sc.decodeMs).toBe(150); // first token → stream end - const ttft = sc.ttftMs; - const decode = sc.decodeMs; - const genTotal = sc.genTotalMs; - if (ttft !== undefined && decode !== undefined && genTotal !== undefined) { - expect(genTotal).toBe(ttft + decode); - } - } - }); - - it("step-complete omits ttft/decode but keeps genTotalMs for a no-content step", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let streamCallCount = 0; - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - const idx = streamCallCount++; - return (async function* () { - if (idx === 0) { - clock.tick(80); // stream starts at 180 - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - clock.tick(20); - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - clock.tick(50); - yield { type: "text-delta", delta: "done" } as ProviderEvent; - clock.tick(50); - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(2); - - // First step: tool-call-only, no content token - const sc0 = stepCompleteEvts[0]; - if (sc0?.type === "step-complete") { - expect(sc0.stepId).toBeDefined(); - expect(sc0.genTotalMs).toBe(100); // 80+20 - expect(sc0.ttftMs).toBeUndefined(); - expect(sc0.decodeMs).toBeUndefined(); - } - - // Second step: has text-delta - const sc1 = stepCompleteEvts[1]; - if (sc1?.type === "step-complete") { - expect(sc1.stepId).toBeDefined(); - expect(sc1.genTotalMs).toBe(100); // 50+50 - expect(sc1.ttftMs).toBe(50); - expect(sc1.decodeMs).toBe(50); - } - }); - - it("usage event carries stepId", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const usageEvts = events.filter((e) => e.type === "usage"); - expect(usageEvts).toHaveLength(1); - const ue = usageEvts[0]; - if (ue?.type === "usage") { - expect(ue.stepId).toBeDefined(); - } - }); - - it("tool-result carries durationMs (execution time) when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const tool = createFakeTool("slow", async () => { - clock.tick(200); // tool takes 200ms to execute - return { content: "done" }; - }); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "slow", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const toolResultEvts = events.filter((e) => e.type === "tool-result"); - expect(toolResultEvts).toHaveLength(1); - const tr = toolResultEvts[0]; - if (tr?.type === "tool-result") { - expect(tr.durationMs).toBeDefined(); - expect(tr.durationMs).toBe(200); - } - }); - - it("done carries durationMs and aggregate usage when now provided", async () => { - const clock = createCounterNow(); - clock.tick(100); // turn starts at 100 - - const wrappedProvider: ProviderContract = { - id: "fake", - stream(_messages, _tools) { - return (async function* () { - clock.tick(80); // stream duration - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider: wrappedProvider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - now: clock.now, - }); - - const doneEvts = events.filter((e) => e.type === "done"); - expect(doneEvts).toHaveLength(1); - const d = doneEvts[0]; - if (d?.type === "done") { - expect(d.durationMs).toBeDefined(); - expect(d.durationMs).toBeGreaterThan(0); - expect(d.usage).toBeDefined(); - if (d.usage !== undefined) { - expect(d.usage.inputTokens).toBe(10); - expect(d.usage.outputTokens).toBe(5); - } - } - }); - - it("no now → timing fields absent", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hi" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - // no now - }); - - // step-complete still emitted (with stepId, no timing) - const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); - expect(stepCompleteEvts).toHaveLength(2); - for (const sc of stepCompleteEvts) { - if (sc?.type === "step-complete") { - expect(sc.stepId).toBeDefined(); - expect(sc.ttftMs).toBeUndefined(); - expect(sc.decodeMs).toBeUndefined(); - expect(sc.genTotalMs).toBeUndefined(); - } - } - - // usage still carries stepId - const usageEvts = events.filter((e) => e.type === "usage"); - for (const ue of usageEvts) { - if (ue?.type === "usage") { - expect(ue.stepId).toBeDefined(); - } - } - - // no durationMs on tool-result - const toolResultEvts = events.filter((e) => e.type === "tool-result"); - for (const tr of toolResultEvts) { - if (tr?.type === "tool-result") { - expect(tr.durationMs).toBeUndefined(); - } - } - - // no durationMs on done, but usage is present (independent of now) - const doneEvts = events.filter((e) => e.type === "done"); - expect(doneEvts).toHaveLength(1); - const d = doneEvts[0]; - if (d?.type === "done") { - expect(d.durationMs).toBeUndefined(); - expect(d.usage).toBeDefined(); - if (d.usage !== undefined) { - expect(d.usage.inputTokens).toBe(15); - expect(d.usage.outputTokens).toBe(8); - } - } - }); - }); - - describe("contextSize", () => { - it("single-step turn: contextSize equals step inputTokens + outputTokens", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 100, outputTokens: 50 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBe(150); - } - }); - - it("multi-step turn: contextSize equals ONLY the last step's inputTokens + outputTokens", async () => { - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "usage", usage: { inputTokens: 100, outputTokens: 20 } }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "usage", usage: { inputTokens: 300, outputTokens: 80 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBe(380); - expect(doneEvt.usage).toBeDefined(); - if (doneEvt.usage !== undefined) { - expect(doneEvt.contextSize).not.toBe(doneEvt.usage.inputTokens); - } - } - }); - - it("no usage reported: contextSize is undefined", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - }); - - const doneEvt = events.find((e) => e.type === "done"); - expect(doneEvt).toBeDefined(); - if (doneEvt?.type === "done") { - expect(doneEvt.contextSize).toBeUndefined(); - expect(doneEvt.usage).toBeUndefined(); - } - }); - }); - - describe("drainSteering", () => { - it("drainSteering called once at the tool-result boundary; returned messages appended to the next step's provider input (after tool results)", async () => { - let drainCallCount = 0; - const steeringMessage: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "steer!" }], - }; - - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return [steeringMessage]; - }, - }); - - expect(drainCallCount).toBe(1); - // The provider was called twice (tool-call step, then text step). - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - // user, assistant(tool-call), tool-result, steering(user) — in order, - // steering appended AFTER the tool results, before the next call. - expect(secondStepMessages).toHaveLength(4); - expect(secondStepMessages[0]?.role).toBe("user"); - expect(secondStepMessages[1]?.role).toBe("assistant"); - expect(secondStepMessages[2]?.role).toBe("tool"); - expect(secondStepMessages[3]).toEqual(steeringMessage); - expect(secondStepMessages[3]?.role).toBe("user"); - // Steering is fed to the next provider call, NOT surfaced in the - // turn result — the caller owns the steering messages' lifecycle. - expect(result.messages).toHaveLength(3); - }); - - it("drainSteering omitted → no injection; turn byte-identical to before", async () => { - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - // drainSteering omitted — must be a strict no-op. - }); - - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - // user, assistant(tool-call), tool-result — NO steering injected. - expect(secondStepMessages).toHaveLength(3); - expect(secondStepMessages[0]?.role).toBe("user"); - expect(secondStepMessages[1]?.role).toBe("assistant"); - expect(secondStepMessages[2]?.role).toBe("tool"); - expect(result.messages).toHaveLength(3); - }); - - it("drainSteering returns [] → no injection", async () => { - let drainCallCount = 0; - const { provider, capturedMessages } = createCapturingProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Called at the boundary, but returned nothing → no injection. - expect(drainCallCount).toBe(1); - expect(capturedMessages).toHaveLength(2); - const secondStepMessages = capturedMessages[1] ?? []; - expect(secondStepMessages).toHaveLength(3); - expect(secondStepMessages[2]?.role).toBe("tool"); - }); - - it("drainSteering NOT called when a step has no tool calls (text-only turn)", async () => { - let drainCallCount = 0; - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "hello" }, - { type: "finish", reason: "stop" }, - ], - ]); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - expect(drainCallCount).toBe(0); - }); - - it("multiple tool-call steps → drainSteering called once per tool-call step", async () => { - let drainCallCount = 0; - const provider = createFakeProvider([ - [ - { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ], - [ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Steps 0 and 1 each produced tool calls → drained once each. - // Step 2 (text-only) → no boundary → no drain. Total = 2. - expect(drainCallCount).toBe(2); - }); - - it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => { - let drainCallCount = 0; - // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step - // to end the turn naturally. - const STEPS_WITH_TOOLS = 100; - const script: ProviderEvent[][] = []; - for (let i = 0; i < STEPS_WITH_TOOLS; i++) { - script.push([ - { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, - { type: "finish", reason: "tool-calls" }, - ]); - } - // Final step: text only, no tool calls → natural end. - script.push([ - { type: "text-delta", delta: "done" }, - { type: "finish", reason: "stop" }, - ]); - const provider = createFakeProvider(script); - - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [tool], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit: () => {}, - drainSteering: () => { - drainCallCount++; - return []; - }, - }); - - // Turn ended naturally, NOT via max-steps. - expect(result.finishReason).toBe("stop"); - // Every tool-call step (0..99) is followed by a next step → each - // triggers a drain. The text-only step breaks before draining. - expect(drainCallCount).toBe(STEPS_WITH_TOOLS); - // All 101 steps produced messages (100 tool steps with assistant + - // tool messages, 1 text-only step with an assistant message). - expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1); - }); - }); - - // ── Retry with backoff ────────────────────────────────────────────────── - // - // PURE tests: a fake `sleep` (records calls, resolves instantly, can abort - // on a chosen call) + a pure `delayFor` (the canonical schedule + 8h budget). - // A stub `ProviderContract` whose `stream` yields a retryable error N times - // then a finish. ZERO mocks of `@dispatch/*` modules — effects injected. - - /** The canonical backoff schedule (matches the orchestrator's concrete strategy). */ - const RETRY_SCHEDULE_MS = [5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000]; - const RETRY_TAIL_MS = 1_800_000; // 30m - const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000; // 8h - - /** Cumulative scheduled sleep through `attempt` (sum of delay[0..attempt]). */ - function cumulativeSleepMs(attempt: number): number { - let sum = 0; - for (let i = 0; i <= attempt; i++) { - sum += i < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[i] : RETRY_TAIL_MS; - } - return sum; - } - - /** Pure, deterministic delay decision (no I/O, no clock). */ - function delayFor(attempt: number): number | undefined { - const delay = attempt < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[attempt] : RETRY_TAIL_MS; - if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop - return delay; - } - - /** The full schedule delayFor would emit (until budget exhausted). */ - function fullSchedule(): number[] { - const result: number[] = []; - let attempt = 0; - while (true) { - const delay = delayFor(attempt); - if (delay === undefined) break; - result.push(delay); - attempt++; - } - return result; - } - - /** - * Fake, controllable `sleep`: records every call's delay, resolves - * instantly (no real waiting), and can abort the controller on a chosen - * 1-based call index to simulate "abort during sleep". - */ - function createFakeSleep(controller: AbortController): { - sleep: (ms: number, signal: AbortSignal) => Promise; - calls: number[]; - abortOnCall: (n: number) => void; - } { - const calls: number[] = []; - let abortAt: number | undefined; - const sleep = async (ms: number, _signal: AbortSignal): Promise => { - calls.push(ms); - if (abortAt !== undefined && calls.length === abortAt) { - controller.abort(); - throw new Error("aborted"); - } - // Otherwise resolve instantly (no real waiting). - }; - return { - sleep, - calls, - abortOnCall: (n: number) => { - abortAt = n; - }, - }; - } - - /** A provider that yields a retryable error `errorCount` times, then success. */ - function createRetryingProvider(opts: { - errorCount: number; - error?: { message: string; code?: string; retryable?: boolean }; - success?: ProviderEvent[]; - }): { provider: ProviderContract; streamCalls: { value: number } } { - const streamCalls = { value: 0 }; - const error: ProviderEvent = { - type: "error", - message: opts.error?.message ?? "overloaded", - ...(opts.error?.code !== undefined ? { code: opts.error.code } : {}), - ...(opts.error?.retryable !== undefined ? { retryable: opts.error.retryable } : {}), - }; - const success = opts.success ?? [ - { type: "text-delta", delta: "hi" }, - { type: "finish", reason: "stop" }, - ]; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = streamCalls.value++; - return (async function* () { - if (idx < opts.errorCount) { - yield error; - return; - } - for (const event of success) yield event; - })(); - }, - }; - return { provider, streamCalls }; - } - - describe("retry with backoff", () => { - it("retries a retryable emitted error on schedule then succeeds", async () => { - const { provider } = createRetryingProvider({ - errorCount: 3, - error: { message: "HTTP 429: overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("stop"); - // 3 retries: 5s, 10s, 30s. - expect(fake.calls).toEqual([5_000, 10_000, 30_000]); - // 3 provider-retry events (one per sleep), then the successful text. - const retryEvents = events.filter((e) => e.type === "provider-retry"); - expect(retryEvents).toHaveLength(3); - if (retryEvents[0]?.type === "provider-retry") { - expect(retryEvents[0].attempt).toBe(0); - expect(retryEvents[0].delayMs).toBe(5_000); - expect(retryEvents[0].message).toBe("HTTP 429: overloaded"); - expect(retryEvents[0].code).toBe("429"); - expect(retryEvents[0].conversationId).toBe("conv-1"); - expect(retryEvents[0].turnId).toBe("turn-1"); - } - if (retryEvents[1]?.type === "provider-retry") { - expect(retryEvents[1].attempt).toBe(1); - expect(retryEvents[1].delayMs).toBe(10_000); - } - if (retryEvents[2]?.type === "provider-retry") { - expect(retryEvents[2].attempt).toBe(2); - expect(retryEvents[2].delayMs).toBe(30_000); - } - // The error was suppressed (no error event emitted — retry succeeded). - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - // The successful content still streams. - const deltas = events.filter((e) => e.type === "text-delta"); - expect(deltas).toHaveLength(1); - }); - - it("sleep is called with the full schedule [5s,10s,30s,60s,5m,10m,15m,30m,30m…]", async () => { - // Provider errors forever → retries until budget exhausted → gives up. - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - // Budget exhausted → give up → error. - expect(result.finishReason).toBe("error"); - - // The sleep schedule matches the pure delayFor output exactly. - expect(fake.calls).toEqual(fullSchedule()); - - // Head of the schedule (the 8 stepped delays). - expect(fake.calls.slice(0, 8)).toEqual([ - 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, - ]); - // Tail repeats 30m. - expect(fake.calls[8]).toBe(1_800_000); - expect(fake.calls.at(-1)).toBe(1_800_000); - - // 8h cumulative budget cap: head (3705s) + 13×30m = ~7h31m, then stop. - // 21 retries (attempts 0..20), then delayFor(21) → undefined → give up. - expect(fake.calls).toHaveLength(21); - const totalSlept = fake.calls.reduce((a, b) => a + b, 0); - expect(totalSlept).toBeLessThanOrEqual(RETRY_BUDGET_MS); - expect(totalSlept).toBe(3_705_000 + 13 * 1_800_000); // 27_105_000 - - // One provider-retry per sleep, plus a final error (give-up). - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(21); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - const errEvt = events.find((e) => e.type === "error"); - if (errEvt?.type === "error") { - expect(errEvt.message).toBe("overloaded"); - expect(errEvt.code).toBe("429"); - } - }); - - it("does NOT retry after content was emitted (safety invariant)", async () => { - // Provider yields text (content) THEN a retryable error. Because content - // was emitted, retrying is unsafe (would duplicate partial output). - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - yield { - type: "error", - message: "overloaded", - code: "429", - retryable: true, - } as ProviderEvent; - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - // No retries: stream called exactly once. - expect(callCount).toBe(1); - expect(fake.calls).toHaveLength(0); - // The error is emitted (give-up) and partial content preserved. - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); - }); - - it("does NOT retry a non-retryable emitted error (retryable: false)", async () => { - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "bad request", code: "400", retryable: false }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - }); - - it("does NOT retry a non-retryable emitted error (retryable absent)", async () => { - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "bad request", code: "400" }, // no retryable field - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - }); - - it("give-up emits the final error when budget is exhausted", async () => { - // Custom delayFor that allows exactly 1 retry then stops. - const shortDelayFor = (attempt: number): number | undefined => - attempt === 0 ? 100 : undefined; - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor: shortDelayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("error"); - expect(fake.calls).toEqual([100]); // one retry, then give up - // One provider-retry (attempt 0), then the final error. - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(1); - const errs = events.filter((e) => e.type === "error"); - expect(errs).toHaveLength(1); - if (errs[0]?.type === "error") { - expect(errs[0].message).toBe("overloaded"); - expect(errs[0].code).toBe("429"); - } - }); - - it("abort during sleep seals the turn aborted", async () => { - const { provider } = createRetryingProvider({ - errorCount: Number.POSITIVE_INFINITY, - error: { message: "overloaded", code: "429", retryable: true }, - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - fake.abortOnCall(2); // abort on the 2nd sleep - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(result.finishReason).toBe("aborted"); - // Two sleeps attempted; the 2nd aborted. - expect(fake.calls).toHaveLength(2); - // No terminal error emitted (it was an abort, not a give-up). - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - // One provider-retry before the aborted sleep (attempt 0). - const retries = events.filter((e) => e.type === "provider-retry"); - expect(retries).toHaveLength(2); - // The done event carries reason "aborted". - const done = events.find((e) => e.type === "done"); - if (done?.type === "done") { - expect(done.reason).toBe("aborted"); - } - }); - - it("omitting retry keeps the pre-retry behavior (backward-compatible)", async () => { - // A retryable error with no retry configured → ends the step as today. - const { provider, streamCalls } = createRetryingProvider({ - errorCount: 1, - error: { message: "overloaded", code: "429", retryable: true }, - }); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - // no retry field - }); - - expect(streamCalls.value).toBe(1); // no retry - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); - }); - - it("retries a THROWN error (retryable-by-default when pre-content)", async () => { - // A thrown error (no retryable flag) before content is retried. - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - if (callCount <= 2) { - throw new Error("network blip"); - } - yield { type: "text-delta", delta: "hi" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(callCount).toBe(3); // 2 throws retried, 3rd succeeds - expect(fake.calls).toEqual([5_000, 10_000]); - expect(result.finishReason).toBe("stop"); - expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(2); - // Thrown errors have no code. - if (events[0]?.type === "provider-retry") { - expect(events[0].code).toBeUndefined(); - expect(events[0].message).toBe("network blip"); - } - expect(events.filter((e) => e.type === "error")).toHaveLength(0); - }); - - it("does NOT retry a thrown error after content was emitted", async () => { - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - callCount++; - return (async function* () { - yield { type: "text-delta", delta: "partial" } as ProviderEvent; - throw new Error("network blip"); - })(); - }, - }; - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - const result = await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - expect(callCount).toBe(1); - expect(fake.calls).toHaveLength(0); - expect(result.finishReason).toBe("error"); - expect(events.filter((e) => e.type === "error")).toHaveLength(1); - expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); - }); - - it("provider-retry events interleave correctly: error → retry-event → sleep → retry", async () => { - // Verify ordering: each provider-retry event comes BEFORE its sleep, - // and the successful content comes only after the last retry. - const { provider } = createRetryingProvider({ - errorCount: 2, - error: { message: "overloaded", code: "429", retryable: true }, - success: [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - }); - const controller = new AbortController(); - const fake = createFakeSleep(controller); - - const { events, emit } = createCollectingEmit(); - - await runTurn({ - provider, - messages: [userMessage], - tools: [], - dispatch: { maxConcurrent: 1, eager: false }, - conversationId: "conv-1", - turnId: "turn-1", - emit, - signal: controller.signal, - retry: { delayFor, sleep: fake.sleep }, - }); - - const types = events.map((e) => e.type); - // turn-start, provider-retry(0), provider-retry(1), text-delta, step-complete, done - expect(types[0]).toBe("turn-start"); - const firstRetryIdx = types.indexOf("provider-retry"); - const textIdx = types.indexOf("text-delta"); - expect(firstRetryIdx).toBeGreaterThan(0); - expect(textIdx).toBeGreaterThan(firstRetryIdx); - // Both retries precede the text. - const retryCount = types.filter((t) => t === "provider-retry").length; - expect(retryCount).toBe(2); - }); - }); + it("emits events with the conversationId and turnId from input", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-42", + turnId: "turn-99", + emit, + }); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.conversationId).toBe("conv-42"); + if (event.type !== "status") { + expect(event.turnId).toBe("turn-99"); + } + } + }); + + it("text-only turn emits correct events and returns correct result", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "reasoning-delta", delta: "thinking..." }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("stop"); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.role).toBe("assistant"); + + const chunks = result.messages[0]?.chunks ?? []; + expect(chunks).toHaveLength(2); + expect(chunks[0]).toEqual({ type: "text", text: "Hello world" }); + expect(chunks[1]).toEqual({ type: "thinking", text: "thinking..." }); + + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); + + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toEqual([ + "turn-start", + "text-delta", + "text-delta", + "reasoning-delta", + "usage", + "step-complete", + "done", + ]); + }); + + it("turn with one tool call executes tool, feeds result back, then finishes", async () => { + const tool = createFakeTool("greet", async (input) => ({ + content: `Hello, ${(input as { name: string }).name}!`, + })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "greet", input: { name: "World" } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "Done." }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("stop"); + expect(result.messages).toHaveLength(3); + expect(result.messages[0]?.role).toBe("assistant"); + expect(result.messages[1]?.role).toBe("tool"); + expect(result.messages[2]?.role).toBe("assistant"); + + const toolResultChunk = result.messages[1]?.chunks[0]; + expect(toolResultChunk?.type).toBe("tool-result"); + if (toolResultChunk?.type === "tool-result") { + expect(toolResultChunk.content).toBe("Hello, World!"); + expect(toolResultChunk.toolCallId).toBe("tc1"); + expect(toolResultChunk.isError).toBe(false); + } + + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toContain("tool-call"); + expect(eventTypes).toContain("tool-result"); + expect(eventTypes).toContain("text-delta"); + }); + + it("passes updated messages to subsequent provider calls", async () => { + const capturedMessages: ChatMessage[][] = []; + let callIndex = 0; + const script: ProviderEvent[][] = [ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]; + + const provider: ProviderContract = { + id: "fake", + stream(messages, _tools) { + capturedMessages.push([...messages]); + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) yield event; + })(); + }, + }; + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedMessages).toHaveLength(2); + expect(capturedMessages[0] ?? []).toHaveLength(1); + expect(capturedMessages[0]?.[0]?.role).toBe("user"); + + expect(capturedMessages[1] ?? []).toHaveLength(3); + expect(capturedMessages[1]?.[0]?.role).toBe("user"); + expect(capturedMessages[1]?.[1]?.role).toBe("assistant"); + expect(capturedMessages[1]?.[2]?.role).toBe("tool"); + }); + + it("maxConcurrent: 1 runs tools sequentially", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(10); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(10); + log.push("b:end"); + return { content: "b" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aEndIdx = log.indexOf("a:end"); + const bStartIdx = log.indexOf("b:start"); + expect(aEndIdx).toBeLessThan(bStartIdx); + }); + + it("maxConcurrent: 2 runs tools in parallel", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(20); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(20); + log.push("b:end"); + return { content: "b" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 2, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aStartIdx = log.indexOf("a:start"); + const bStartIdx = log.indexOf("b:start"); + const aEndIdx = log.indexOf("a:end"); + const bEndIdx = log.indexOf("b:end"); + + expect(aStartIdx).toBeLessThan(aEndIdx); + expect(bStartIdx).toBeLessThan(bEndIdx); + expect(aStartIdx).toBeLessThan(bEndIdx); + expect(bStartIdx).toBeLessThan(aEndIdx); + }); + + it("maxConcurrent: 0 runs all tools in parallel (unlimited)", async () => { + const log: string[] = []; + + const toolA = createFakeTool("a", async () => { + log.push("a:start"); + await delay(20); + log.push("a:end"); + return { content: "a" }; + }); + + const toolB = createFakeTool("b", async () => { + log.push("b:start"); + await delay(20); + log.push("b:end"); + return { content: "b" }; + }); + + const toolC = createFakeTool("c", async () => { + log.push("c:start"); + await delay(20); + log.push("c:end"); + return { content: "c" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "tool-call", toolCallId: "tc3", toolName: "c", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB, toolC], + dispatch: { maxConcurrent: 0, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const aStartIdx = log.indexOf("a:start"); + const bStartIdx = log.indexOf("b:start"); + const cStartIdx = log.indexOf("c:start"); + const aEndIdx = log.indexOf("a:end"); + const bEndIdx = log.indexOf("b:end"); + const cEndIdx = log.indexOf("c:end"); + + expect(aStartIdx).toBeLessThan(aEndIdx); + expect(bStartIdx).toBeLessThan(bEndIdx); + expect(cStartIdx).toBeLessThan(cEndIdx); + expect(aStartIdx).toBeLessThan(bEndIdx); + expect(bStartIdx).toBeLessThan(aEndIdx); + expect(cStartIdx).toBeLessThan(aEndIdx); + }); + + it("eager: true launches tool before step finish", async () => { + const log: string[] = []; + + const tool = createFakeTool("test", async () => { + log.push("tool:start"); + await delay(5); + log.push("tool:end"); + return { content: "done" }; + }); + + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = callCount++; + if (idx === 0) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + } as ProviderEvent; + log.push("provider:after-tool-call"); + await delay(50); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + log.push("provider:finish"); + })(); + } + return (async function* () { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: true }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const toolStartIdx = log.indexOf("tool:start"); + const finishIdx = log.indexOf("provider:finish"); + expect(toolStartIdx).toBeLessThan(finishIdx); + }); + + it("eager: false does not launch tool before step finish", async () => { + const log: string[] = []; + + const tool = createFakeTool("test", async () => { + log.push("tool:start"); + await delay(5); + log.push("tool:end"); + return { content: "done" }; + }); + + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = callCount++; + if (idx === 0) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + } as ProviderEvent; + log.push("provider:after-tool-call"); + await delay(50); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + log.push("provider:finish"); + })(); + } + return (async function* () { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const toolStartIdx = log.indexOf("tool:start"); + const finishIdx = log.indexOf("provider:finish"); + expect(toolStartIdx).toBeGreaterThan(finishIdx); + }); + + it("abort mid-turn synthesizes error results for unresolved tool calls", async () => { + const ac = new AbortController(); + + const tool = createFakeTool("slow", async (_input, ctx) => { + await delay(200); + if (ctx.signal.aborted) return { content: "Aborted", isError: true }; + return { content: "done" }; + }); + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + return (async function* () { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "slow", + input: {}, + } as ProviderEvent; + yield { + type: "tool-call", + toolCallId: "tc2", + toolName: "slow", + input: { x: 1 }, + } as ProviderEvent; + ac.abort(); + await delay(10); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + const toolResults = events.filter((e) => e.type === "tool-result"); + for (const tr of toolResults) { + if (tr.type === "tool-result") { + expect(tr.isError).toBe(true); + } + } + }); + + it("abort before any step returns aborted immediately", async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + expect(result.messages).toHaveLength(0); + }); + + it("de-duplicates identical tool calls in a batch", async () => { + let execCount = 0; + + const tool = createFakeTool("dedup", async (_input) => { + execCount++; + return { content: `result-${execCount}` }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "dedup", input: { x: 1 } }, + { type: "tool-call", toolCallId: "tc2", toolName: "dedup", input: { x: 1 } }, + { type: "tool-call", toolCallId: "tc3", toolName: "dedup", input: { x: 2 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(execCount).toBe(2); + + const toolResults = events.filter((e) => e.type === "tool-result"); + expect(toolResults).toHaveLength(3); + + const tc1Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc1"); + const tc2Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc2"); + const tc3Result = toolResults.find((e) => e.type === "tool-result" && e.toolCallId === "tc3"); + + expect(tc1Result).toBeDefined(); + expect(tc2Result).toBeDefined(); + expect(tc3Result).toBeDefined(); + + if (tc1Result?.type === "tool-result" && tc2Result?.type === "tool-result") { + expect(tc1Result.content).toBe(tc2Result.content); + expect(tc1Result.content).toBe("result-1"); + } + if (tc3Result?.type === "tool-result") { + expect(tc3Result.content).toBe("result-2"); + } + + expect(result.finishReason).toBe("stop"); + }); + + it("serializes non-concurrency-safe tools even with maxConcurrent > 1", async () => { + const log: string[] = []; + + const unsafeTool: ToolContract = { + name: "unsafe", + description: "Unsafe tool", + parameters: { type: "object" }, + concurrencySafe: false, + execute: async () => { + log.push("unsafe:start"); + await delay(10); + log.push("unsafe:end"); + return { content: "done" }; + }, + }; + + const safeTool: ToolContract = { + name: "safe", + description: "Safe tool", + parameters: { type: "object" }, + execute: async () => { + log.push("safe:start"); + await delay(10); + log.push("safe:end"); + return { content: "done" }; + }, + }; + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "unsafe", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "safe", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [unsafeTool, safeTool], + dispatch: { maxConcurrent: 5, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + const unsafeEndIdx = log.indexOf("unsafe:end"); + const safeStartIdx = log.indexOf("safe:start"); + expect(unsafeEndIdx).toBeLessThan(safeStartIdx); + }); + + it("handles unknown tool name gracefully", async () => { + const provider = createFakeProvider([ + [ + { + type: "tool-call", + toolCallId: "tc1", + toolName: "nonexistent", + input: {}, + }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + const toolResults = events.filter((e) => e.type === "tool-result"); + expect(toolResults).toHaveLength(1); + if (toolResults[0]?.type === "tool-result") { + expect(toolResults[0]?.isError).toBe(true); + expect(toolResults[0]?.content).toContain("Unknown tool"); + } + + expect(result.finishReason).toBe("stop"); + }); + + it("handles provider error gracefully", async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider crashed"); + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + expect(result.finishReason).toBe("error"); + + const errorEvents = events.filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + if (errorEvents[0]?.type === "error") { + expect(errorEvents[0]?.message).toContain("provider crashed"); + } + }); + + it("forwards cwd from RunTurnInput to ToolExecuteContext", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + cwd: "/some/dir", + }); + + expect(capturedCwd).toBe("/some/dir"); + }); + + it("forwards undefined cwd when RunTurnInput has no cwd", async () => { + let capturedCwd: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("cwdcheck", async (_input, ctx) => { + capturedCwd = ctx.cwd; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "cwdcheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedCwd).toBeUndefined(); + }); + + it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + computerId: "ssh-host-alias", + }); + + expect(capturedComputerId).toBe("ssh-host-alias"); + }); + + it("forwards undefined computerId when RunTurnInput has no computerId", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedComputerId).toBeUndefined(); + }); + + it("aggregates usage across multiple steps", async () => { + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 20, outputTokens: 10 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(result.usage).toEqual({ inputTokens: 30, outputTokens: 15 }); + }); + + it("emits tool-output events from tool ctx.onOutput", async () => { + const tool: ToolContract = { + name: "streaming", + description: "A tool that streams output", + parameters: { type: "object" }, + execute: async (_input, ctx) => { + ctx.onOutput("line 1\n", "stdout"); + ctx.onOutput("err 1\n", "stderr"); + return { content: "done" }; + }, + }; + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "streaming", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit, + }); + + const outputs = events.filter((e) => e.type === "tool-output"); + expect(outputs).toHaveLength(2); + if (outputs[0]?.type === "tool-output") { + expect(outputs[0]?.data).toBe("line 1\n"); + expect(outputs[0]?.stream).toBe("stdout"); + expect(outputs[0]?.toolCallId).toBe("tc1"); + } + if (outputs[1]?.type === "tool-output") { + expect(outputs[1]?.data).toBe("err 1\n"); + expect(outputs[1]?.stream).toBe("stderr"); + } + }); + + function createTestLogger(): { + logger: Logger; + sink: LogSink & { records: LogRecord[] }; + deps: LogDeps; + } { + let idCounter = 0; + const deps: LogDeps = { + now: () => 1000 + idCounter * 100, + newId: () => `span-${++idCounter}`, + }; + const records: LogRecord[] = []; + const sink: LogSink & { records: LogRecord[] } = { + records, + emit: (record) => records.push(record), + }; + const logger = createLogger({ extensionId: "test" }, sink, deps); + return { logger, sink, deps }; + } + + describe("span instrumentation", () => { + it("emits turn + step span open/close in order", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const spanOpens = sink.records.filter((r) => r.kind === "span-open"); + const spanCloses = sink.records.filter((r) => r.kind === "span-close"); + + expect(spanOpens.length).toBeGreaterThanOrEqual(2); // turn + step + expect(spanCloses.length).toBeGreaterThanOrEqual(2); + + const turnOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "turn"); + const stepOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "step"); + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + + if (turnOpen?.kind === "span-open") { + expect(turnOpen.extensionId).toBe("test"); + expect(turnOpen.attributes?.conversationId).toBe("conv-1"); + expect(turnOpen.attributes?.turnId).toBe("turn-1"); + } + + const turnClose = spanCloses.find((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.status).toBe("ok"); + expect(turnClose.durationMs).toBeGreaterThanOrEqual(0); + } + }); + + it("emits tool-call spans for dispatched tools", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const toolCallSpans = sink.records.filter( + (r) => r.kind === "span-open" && r.name === "tool-call", + ); + expect(toolCallSpans).toHaveLength(1); + if (toolCallSpans[0]?.kind === "span-open") { + expect(toolCallSpans[0].attributes?.name).toBe("echo"); + expect(toolCallSpans[0].attributes?.toolCallId).toBe("tc1"); + } + + const toolCallCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "tool-call", + ); + expect(toolCallCloses).toHaveLength(1); + if (toolCallCloses[0]?.kind === "span-close") { + expect(toolCallCloses[0].status).toBe("ok"); + } + }); + + it("tools receive ctx.log (correlated logger)", async () => { + let capturedLog: Logger | undefined; + + const tool = createFakeTool("logtest", async (_input, ctx) => { + capturedLog = ctx.log; + ctx.log.info("tool ran", { key: "value" }); + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "logtest", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedLog).toBeDefined(); + + const toolLogs = sink.records.filter( + (r) => r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "tool ran", + ); + expect(toolLogs).toHaveLength(1); + if (toolLogs[0]?.kind === "log") { + expect(toolLogs[0].attributes?.key).toBe("value"); + expect(toolLogs[0].extensionId).toBe("test"); + } + }); + + it("an aborted turn still closes its turn span", async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + signal: ac.signal, + logger, + }); + + const turnCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnCloses).toHaveLength(1); + if (turnCloses[0]?.kind === "span-close") { + expect(turnCloses[0].attributes?.finishReason).toBe("aborted"); + } + }); + + it("a provider error closes the step span with error status", async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider exploded"); + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(result.finishReason).toBe("error"); + + const stepCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "step"); + expect(stepCloses).toHaveLength(1); + if (stepCloses[0]?.kind === "span-close") { + expect(stepCloses[0].status).toBe("error"); + expect(stepCloses[0].attributes?.["error.message"]).toContain("provider exploded"); + } + }); + + it("emits a prompt span with verbatim body and small scalar attributes", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const promptOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "prompt"); + expect(promptOpens).toHaveLength(1); + + const promptOpen = promptOpens[0]; + if (promptOpen?.kind === "span-open") { + expect(promptOpen.body).toBeDefined(); + const parsed = JSON.parse(promptOpen.body as string); + expect(parsed.messages).toEqual([userMessage]); + expect(parsed.tools).toHaveLength(1); + expect(parsed.tools[0].name).toBe("echo"); + + expect(promptOpen.attributes?.messageCount).toBe(1); + expect(promptOpen.attributes?.toolCount).toBe(1); + } + + const promptCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "prompt", + ); + expect(promptCloses).toHaveLength(1); + + const logRecords = sink.records.filter( + (r) => + r.kind === "log" && r.kind === "log" && (r as { msg: string }).msg === "prompt:before", + ); + expect(logRecords).toHaveLength(0); + }); + + it("emits ttft and decode spans for a generating step", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); + const decodeCloses = sink.records.filter( + (r) => r.kind === "span-close" && r.name === "decode", + ); + + expect(ttftOpens).toHaveLength(1); + expect(ttftCloses).toHaveLength(1); + expect(decodeOpens).toHaveLength(1); + expect(decodeCloses).toHaveLength(1); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + expect(stepOpen).toBeDefined(); + + if ( + ttftOpens[0]?.kind === "span-open" && + ttftCloses[0]?.kind === "span-close" && + decodeOpens[0]?.kind === "span-open" && + decodeCloses[0]?.kind === "span-close" && + stepOpen?.kind === "span-open" + ) { + // ttft and decode are children of step + expect(ttftOpens[0].parentSpanId).toBe(stepOpen.spanId); + expect(decodeOpens[0].parentSpanId).toBe(stepOpen.spanId); + + // ttft closes before decode opens (in order) + const ttftCloseIdx = sink.records.indexOf(ttftCloses[0]); + const decodeOpenIdx = sink.records.indexOf(decodeOpens[0]); + expect(ttftCloseIdx).toBeLessThan(decodeOpenIdx); + + // ttft has firstToken: true + expect(ttftCloses[0].attributes?.firstToken).toBe(true); + + // durations from fake clock + expect(ttftCloses[0].durationMs).toBeGreaterThanOrEqual(0); + expect(decodeCloses[0].durationMs).toBeGreaterThanOrEqual(0); + } + }); + + it("first token counts a reasoning delta", async () => { + const provider = createFakeProvider([ + [ + { type: "reasoning-delta", delta: "thinking..." }, + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + expect(ttftCloses).toHaveLength(1); + + // The ttft span should close at the reasoning delta, not at the text delta + if (ttftCloses[0]?.kind === "span-close") { + expect(ttftCloses[0].attributes?.firstToken).toBe(true); + } + }); + + it("a step with no content token does not emit a misleading decode", async () => { + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + // First step (tool-call-only) should have ttft with firstToken: false and no decode + const ttftOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "ttft"); + const ttftCloses = sink.records.filter((r) => r.kind === "span-close" && r.name === "ttft"); + const decodeOpens = sink.records.filter((r) => r.kind === "span-open" && r.name === "decode"); + + // There should be 2 ttft opens (one per step) and 2 ttft closes + expect(ttftOpens).toHaveLength(2); + expect(ttftCloses).toHaveLength(2); + + // First step: tool-call-only, no first token + if (ttftCloses[0]?.kind === "span-close") { + expect(ttftCloses[0].attributes?.firstToken).toBe(false); + } + + // Second step: has text-delta, should have firstToken: true and decode span + if (ttftCloses[1]?.kind === "span-close") { + expect(ttftCloses[1].attributes?.firstToken).toBe(true); + } + + // Only one decode span (for the second step) + expect(decodeOpens).toHaveLength(1); + }); + + it("turn span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.usage_inputTokens).toBeUndefined(); + expect(turnClose.attributes?.usage_outputTokens).toBeUndefined(); + } + }); + + it("step span close stamps usage.inputTokens / usage.outputTokens (dotted)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 7, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(7); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(3); + expect(stepClose.attributes?.usage_inputTokens).toBeUndefined(); + expect(stepClose.attributes?.usage_outputTokens).toBeUndefined(); + } + }); + + it("turn + step spans stamp usage.cacheReadTokens / usage.cacheWriteTokens when the provider Usage carries them", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 }, + }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBe(3); + expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); + } + + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBe(3); + expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBe(2); + } + }); + + it("turn + step spans OMIT the cache-token attrs when the provider Usage lacks them", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnClose = sink.records.find((r) => r.kind === "span-close" && r.name === "turn"); + const stepClose = sink.records.find((r) => r.kind === "span-close" && r.name === "step"); + + expect(turnClose).toBeDefined(); + if (turnClose?.kind === "span-close") { + expect(turnClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(turnClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(turnClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); + expect(turnClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); + } + + expect(stepClose).toBeDefined(); + if (stepClose?.kind === "span-close") { + expect(stepClose.attributes?.["usage.inputTokens"]).toBe(10); + expect(stepClose.attributes?.["usage.outputTokens"]).toBe(5); + expect(stepClose.attributes?.["usage.cacheReadTokens"]).toBeUndefined(); + expect(stepClose.attributes?.["usage.cacheWriteTokens"]).toBeUndefined(); + } + }); + }); + + describe("provider logger threading", () => { + it("passes step span logger to provider.stream opts when logger provided", async () => { + let capturedOpts: Record | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { logger } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts?.logger).toBeDefined(); + expect(typeof (capturedOpts?.logger as Record).info).toBe("function"); + expect(typeof (capturedOpts?.logger as Record).span).toBe("function"); + }); + + it("passes undefined for opts.logger when no logger provided", async () => { + let capturedOpts: Record | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + }); + + expect(capturedOpts).toBeDefined(); + expect(capturedOpts?.logger).toBeUndefined(); + }); + + it("threads providerOpts.model through to provider.stream opts", async () => { + let capturedOpts: Record | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedOpts = opts !== undefined ? { ...opts } : undefined; + return (async function* () { + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "usage", usage: { inputTokens: 1, outputTokens: 1 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + providerOpts: { model: "some-model-id" }, + }); + + expect(capturedOpts?.model).toBe("some-model-id"); + }); + }); + + describe("span tree nesting", () => { + it("turn span is root (parentSpanId undefined)", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); + expect(turnOpen).toBeDefined(); + if (turnOpen?.kind === "span-open") { + expect(turnOpen.parentSpanId).toBeUndefined(); + } + }); + + it("step span is a child of turn span", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const turnOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "turn"); + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + if (turnOpen?.kind === "span-open" && stepOpen?.kind === "span-open") { + expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); + } + }); + + it("prompt span is a child of step span", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const promptOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "prompt"); + expect(stepOpen).toBeDefined(); + expect(promptOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && promptOpen?.kind === "span-open") { + expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + + it("provider logger creates spans nested under step", async () => { + let capturedLogger: Logger | undefined; + let providerReqSpanId: string | undefined; + + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedLogger = opts?.logger; + return (async function* () { + // Open provider.request span inside the stream (like a real provider) + if (capturedLogger !== undefined) { + const span = capturedLogger.span("provider.request"); + providerReqSpanId = span.id; + span.end(); + } + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + expect(capturedLogger).toBeDefined(); + expect(providerReqSpanId).toBeDefined(); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const provReqOpen = sink.records.find( + (r) => r.kind === "span-open" && r.name === "provider.request", + ); + expect(stepOpen).toBeDefined(); + expect(provReqOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && provReqOpen?.kind === "span-open") { + expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); + expect(provReqOpen.spanId).toBe(providerReqSpanId); + } + }); + + it("tool-call spans are children of step span", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const stepOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "step"); + const tcOpen = sink.records.find((r) => r.kind === "span-open" && r.name === "tool-call"); + expect(stepOpen).toBeDefined(); + expect(tcOpen).toBeDefined(); + if (stepOpen?.kind === "span-open" && tcOpen?.kind === "span-open") { + expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + + it("full parent chain: turn → step → {prompt, provider.request, tool-call}", async () => { + let capturedLogger: Logger | undefined; + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let streamCallCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(_messages, _tools, opts) { + capturedLogger = opts?.logger; + streamCallCount++; + return (async function* () { + // Simulate provider opening a provider.request span + // INSIDE the stream on the first call only (like a real provider) + if (streamCallCount === 1 && capturedLogger !== undefined) { + const span = capturedLogger.span("provider.request"); + span.end(); + } + if (streamCallCount === 1) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "done" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { logger, sink } = createTestLogger(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + logger, + }); + + const spanOpens = sink.records.filter((r) => r.kind === "span-open") as Array< + Extract + >; + + const turnOpen = spanOpens.find((r) => r.name === "turn"); + const stepOpen = spanOpens.find((r) => r.name === "step"); + const promptOpen = spanOpens.find((r) => r.name === "prompt"); + const provReqOpen = spanOpens.find((r) => r.name === "provider.request"); + const tcOpen = spanOpens.find((r) => r.name === "tool-call"); + + expect(turnOpen).toBeDefined(); + expect(stepOpen).toBeDefined(); + expect(promptOpen).toBeDefined(); + expect(provReqOpen).toBeDefined(); + expect(tcOpen).toBeDefined(); + + if ( + turnOpen?.kind === "span-open" && + stepOpen?.kind === "span-open" && + promptOpen?.kind === "span-open" && + provReqOpen?.kind === "span-open" && + tcOpen?.kind === "span-open" + ) { + // turn = root + expect(turnOpen.parentSpanId).toBeUndefined(); + + // step = child of turn + expect(stepOpen.parentSpanId).toBe(turnOpen.spanId); + + // prompt = child of step + expect(promptOpen.parentSpanId).toBe(stepOpen.spanId); + + // provider.request = child of step + expect(provReqOpen.parentSpanId).toBe(stepOpen.spanId); + + // tool-call = child of step + expect(tcOpen.parentSpanId).toBe(stepOpen.spanId); + } + }); + }); + + describe("lifecycle events", () => { + it("emits turn-start as the first event with conversation + turn ids", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-42", + turnId: "turn-99", + emit, + }); + + expect(events[0]?.type).toBe("turn-start"); + if (events[0]?.type === "turn-start") { + expect(events[0].conversationId).toBe("conv-42"); + expect(events[0].turnId).toBe("turn-99"); + } + }); + + it("emits a single done event last, carrying the finishReason", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe(result.finishReason); + expect(lastEvent.conversationId).toBe("conv-1"); + expect(lastEvent.turnId).toBe("turn-1"); + } + + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + }); + + it("emits done after a tool-call turn", async () => { + const tool = createFakeTool("echo", async (input) => ({ + content: `echo: ${JSON.stringify(input)}`, + })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: { x: 1 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe(result.finishReason); + } + }); + + it('still emits done with reason "aborted" when the turn is aborted via signal', async () => { + const ac = new AbortController(); + ac.abort(); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "should not appear" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: ac.signal, + }); + + expect(result.finishReason).toBe("aborted"); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe("aborted"); + } + }); + + it('still emits done with reason "error" when the provider errors', async () => { + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("provider crashed"); + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + expect(result.finishReason).toBe("error"); + + const lastEvent = events[events.length - 1]; + expect(lastEvent?.type).toBe("done"); + if (lastEvent?.type === "done") { + expect(lastEvent.reason).toBe("error"); + } + }); + + it("turn-start precedes every delta and done follows every delta", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "reasoning-delta", delta: "thinking..." }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const turnStartIdx = events.findIndex((e) => e.type === "turn-start"); + const doneIdx = events.findIndex((e) => e.type === "done"); + + expect(turnStartIdx).toBe(0); + expect(doneIdx).toBe(events.length - 1); + + for (let i = 0; i < events.length; i++) { + const e = events[i]; + if (e?.type === "text-delta" || e?.type === "reasoning-delta") { + expect(i).toBeGreaterThan(turnStartIdx); + expect(i).toBeLessThan(doneIdx); + } + } + }); + }); + + describe("stepId", () => { + it("tool-call and tool-result events carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvt = events.find((e) => e.type === "tool-call"); + const toolResultEvt = events.find((e) => e.type === "tool-result"); + + expect(toolCallEvt).toBeDefined(); + expect(toolResultEvt).toBeDefined(); + + if (toolCallEvt?.type === "tool-call" && toolResultEvt?.type === "tool-result") { + expect(toolCallEvt.stepId).toBeDefined(); + expect(toolResultEvt.stepId).toBeDefined(); + expect(toolCallEvt.stepId).toBe(toolResultEvt.stepId); + } + }); + + it("tool calls in the SAME step share one stepId; a later step gets a different one", async () => { + const toolA = createFakeTool("a", async () => ({ content: "a-result" })); + const toolB = createFakeTool("b", async () => ({ content: "b-result" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "tool-call", toolCallId: "tc3", toolName: "a", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvts = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvts.length).toBeGreaterThanOrEqual(2); + + const step0Calls = toolCallEvts.filter( + (e) => e.type === "tool-call" && (e.toolCallId === "tc1" || e.toolCallId === "tc2"), + ); + const step1Call = toolCallEvts.find((e) => e.type === "tool-call" && e.toolCallId === "tc3"); + + expect(step0Calls).toHaveLength(2); + if (step0Calls[0]?.type === "tool-call" && step0Calls[1]?.type === "tool-call") { + expect(step0Calls[0].stepId).toBe(step0Calls[1].stepId); + } + + if (step1Call?.type === "tool-call" && step0Calls[0]?.type === "tool-call") { + expect(step1Call.stepId).not.toBe(step0Calls[0].stepId); + } + }); + + it("tool chunks in the result carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + }); + + const toolCallMsg = result.messages.find( + (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), + ); + const toolResultMsg = result.messages.find((m) => m.role === "tool"); + + expect(toolCallMsg).toBeDefined(); + expect(toolResultMsg).toBeDefined(); + + const tcChunk = toolCallMsg?.chunks.find((c) => c.type === "tool-call"); + const trChunk = toolResultMsg?.chunks[0]; + + expect(tcChunk?.type).toBe("tool-call"); + expect(trChunk?.type).toBe("tool-result"); + + if (tcChunk?.type === "tool-call" && trChunk?.type === "tool-result") { + expect(tcChunk.stepId).toBeDefined(); + expect(trChunk.stepId).toBeDefined(); + expect(tcChunk.stepId).toBe(trChunk.stepId); + } + }); + }); + + describe("timing events (now provided)", () => { + function createCounterNow(): { now: () => number; tick: (ms: number) => void } { + let current = 0; + return { + now: () => current, + tick: (ms: number) => { + current += ms; + }, + }; + } + + it("emits step-complete per step with timing when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const { events, emit } = createCollectingEmit(); + + // Advance clock during stream: first token at +50ms, stream ends at +200ms + let streamCallCount = 0; + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = streamCallCount++; + return (async function* () { + if (idx === 0) { + clock.tick(50); // stream starts + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + // first token seen at 150 (100+50) + clock.tick(100); + yield { type: "text-delta", delta: " world" } as ProviderEvent; + clock.tick(50); + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(1); + + const sc = stepCompleteEvts[0]; + if (sc?.type === "step-complete") { + expect(sc.conversationId).toBe("conv-1"); + expect(sc.turnId).toBe("turn-1"); + expect(sc.stepId).toBeDefined(); + expect(sc.genTotalMs).toBe(200); // 50+100+50 + expect(sc.ttftMs).toBe(50); // stream start → first text-delta + expect(sc.decodeMs).toBe(150); // first token → stream end + const ttft = sc.ttftMs; + const decode = sc.decodeMs; + const genTotal = sc.genTotalMs; + if (ttft !== undefined && decode !== undefined && genTotal !== undefined) { + expect(genTotal).toBe(ttft + decode); + } + } + }); + + it("step-complete omits ttft/decode but keeps genTotalMs for a no-content step", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let streamCallCount = 0; + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + const idx = streamCallCount++; + return (async function* () { + if (idx === 0) { + clock.tick(80); // stream starts at 180 + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + clock.tick(20); + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + clock.tick(50); + yield { type: "text-delta", delta: "done" } as ProviderEvent; + clock.tick(50); + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(2); + + // First step: tool-call-only, no content token + const sc0 = stepCompleteEvts[0]; + if (sc0?.type === "step-complete") { + expect(sc0.stepId).toBeDefined(); + expect(sc0.genTotalMs).toBe(100); // 80+20 + expect(sc0.ttftMs).toBeUndefined(); + expect(sc0.decodeMs).toBeUndefined(); + } + + // Second step: has text-delta + const sc1 = stepCompleteEvts[1]; + if (sc1?.type === "step-complete") { + expect(sc1.stepId).toBeDefined(); + expect(sc1.genTotalMs).toBe(100); // 50+50 + expect(sc1.ttftMs).toBe(50); + expect(sc1.decodeMs).toBe(50); + } + }); + + it("usage event carries stepId", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const usageEvts = events.filter((e) => e.type === "usage"); + expect(usageEvts).toHaveLength(1); + const ue = usageEvts[0]; + if (ue?.type === "usage") { + expect(ue.stepId).toBeDefined(); + } + }); + + it("tool-result carries durationMs (execution time) when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const tool = createFakeTool("slow", async () => { + clock.tick(200); // tool takes 200ms to execute + return { content: "done" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "slow", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const toolResultEvts = events.filter((e) => e.type === "tool-result"); + expect(toolResultEvts).toHaveLength(1); + const tr = toolResultEvts[0]; + if (tr?.type === "tool-result") { + expect(tr.durationMs).toBeDefined(); + expect(tr.durationMs).toBe(200); + } + }); + + it("done carries durationMs and aggregate usage when now provided", async () => { + const clock = createCounterNow(); + clock.tick(100); // turn starts at 100 + + const wrappedProvider: ProviderContract = { + id: "fake", + stream(_messages, _tools) { + return (async function* () { + clock.tick(80); // stream duration + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider: wrappedProvider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + now: clock.now, + }); + + const doneEvts = events.filter((e) => e.type === "done"); + expect(doneEvts).toHaveLength(1); + const d = doneEvts[0]; + if (d?.type === "done") { + expect(d.durationMs).toBeDefined(); + expect(d.durationMs).toBeGreaterThan(0); + expect(d.usage).toBeDefined(); + if (d.usage !== undefined) { + expect(d.usage.inputTokens).toBe(10); + expect(d.usage.outputTokens).toBe(5); + } + } + }); + + it("no now → timing fields absent", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hi" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + // no now + }); + + // step-complete still emitted (with stepId, no timing) + const stepCompleteEvts = events.filter((e) => e.type === "step-complete"); + expect(stepCompleteEvts).toHaveLength(2); + for (const sc of stepCompleteEvts) { + if (sc?.type === "step-complete") { + expect(sc.stepId).toBeDefined(); + expect(sc.ttftMs).toBeUndefined(); + expect(sc.decodeMs).toBeUndefined(); + expect(sc.genTotalMs).toBeUndefined(); + } + } + + // usage still carries stepId + const usageEvts = events.filter((e) => e.type === "usage"); + for (const ue of usageEvts) { + if (ue?.type === "usage") { + expect(ue.stepId).toBeDefined(); + } + } + + // no durationMs on tool-result + const toolResultEvts = events.filter((e) => e.type === "tool-result"); + for (const tr of toolResultEvts) { + if (tr?.type === "tool-result") { + expect(tr.durationMs).toBeUndefined(); + } + } + + // no durationMs on done, but usage is present (independent of now) + const doneEvts = events.filter((e) => e.type === "done"); + expect(doneEvts).toHaveLength(1); + const d = doneEvts[0]; + if (d?.type === "done") { + expect(d.durationMs).toBeUndefined(); + expect(d.usage).toBeDefined(); + if (d.usage !== undefined) { + expect(d.usage.inputTokens).toBe(15); + expect(d.usage.outputTokens).toBe(8); + } + } + }); + }); + + describe("contextSize", () => { + it("single-step turn: contextSize equals step inputTokens + outputTokens", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 100, outputTokens: 50 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBe(150); + } + }); + + it("multi-step turn: contextSize equals ONLY the last step's inputTokens + outputTokens", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "usage", usage: { inputTokens: 100, outputTokens: 20 } }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "usage", usage: { inputTokens: 300, outputTokens: 80 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBe(380); + expect(doneEvt.usage).toBeDefined(); + if (doneEvt.usage !== undefined) { + expect(doneEvt.contextSize).not.toBe(doneEvt.usage.inputTokens); + } + } + }); + + it("no usage reported: contextSize is undefined", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const doneEvt = events.find((e) => e.type === "done"); + expect(doneEvt).toBeDefined(); + if (doneEvt?.type === "done") { + expect(doneEvt.contextSize).toBeUndefined(); + expect(doneEvt.usage).toBeUndefined(); + } + }); + }); + + describe("drainSteering", () => { + it("drainSteering called once at the tool-result boundary; returned messages appended to the next step's provider input (after tool results)", async () => { + let drainCallCount = 0; + const steeringMessage: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "steer!" }], + }; + + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return [steeringMessage]; + }, + }); + + expect(drainCallCount).toBe(1); + // The provider was called twice (tool-call step, then text step). + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + // user, assistant(tool-call), tool-result, steering(user) — in order, + // steering appended AFTER the tool results, before the next call. + expect(secondStepMessages).toHaveLength(4); + expect(secondStepMessages[0]?.role).toBe("user"); + expect(secondStepMessages[1]?.role).toBe("assistant"); + expect(secondStepMessages[2]?.role).toBe("tool"); + expect(secondStepMessages[3]).toEqual(steeringMessage); + expect(secondStepMessages[3]?.role).toBe("user"); + // Steering is fed to the next provider call, NOT surfaced in the + // turn result — the caller owns the steering messages' lifecycle. + expect(result.messages).toHaveLength(3); + }); + + it("drainSteering omitted → no injection; turn byte-identical to before", async () => { + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + // drainSteering omitted — must be a strict no-op. + }); + + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + // user, assistant(tool-call), tool-result — NO steering injected. + expect(secondStepMessages).toHaveLength(3); + expect(secondStepMessages[0]?.role).toBe("user"); + expect(secondStepMessages[1]?.role).toBe("assistant"); + expect(secondStepMessages[2]?.role).toBe("tool"); + expect(result.messages).toHaveLength(3); + }); + + it("drainSteering returns [] → no injection", async () => { + let drainCallCount = 0; + const { provider, capturedMessages } = createCapturingProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Called at the boundary, but returned nothing → no injection. + expect(drainCallCount).toBe(1); + expect(capturedMessages).toHaveLength(2); + const secondStepMessages = capturedMessages[1] ?? []; + expect(secondStepMessages).toHaveLength(3); + expect(secondStepMessages[2]?.role).toBe("tool"); + }); + + it("drainSteering NOT called when a step has no tool calls (text-only turn)", async () => { + let drainCallCount = 0; + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "hello" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + expect(drainCallCount).toBe(0); + }); + + it("multiple tool-call steps → drainSteering called once per tool-call step", async () => { + let drainCallCount = 0; + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "tool-call", toolCallId: "tc2", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Steps 0 and 1 each produced tool calls → drained once each. + // Step 2 (text-only) → no boundary → no drain. Total = 2. + expect(drainCallCount).toBe(2); + }); + + it("MAX_STEPS=0 (unlimited): turn runs past the old 50-step limit and drains at every tool-result boundary until the model stops naturally", async () => { + let drainCallCount = 0; + // 100 tool-call steps (past the old MAX_STEPS=50) + 1 text-only step + // to end the turn naturally. + const STEPS_WITH_TOOLS = 100; + const script: ProviderEvent[][] = []; + for (let i = 0; i < STEPS_WITH_TOOLS; i++) { + script.push([ + { type: "tool-call", toolCallId: "tc", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ]); + } + // Final step: text only, no tool calls → natural end. + script.push([ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ]); + const provider = createFakeProvider(script); + + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + drainSteering: () => { + drainCallCount++; + return []; + }, + }); + + // Turn ended naturally, NOT via max-steps. + expect(result.finishReason).toBe("stop"); + // Every tool-call step (0..99) is followed by a next step → each + // triggers a drain. The text-only step breaks before draining. + expect(drainCallCount).toBe(STEPS_WITH_TOOLS); + // All 101 steps produced messages (100 tool steps with assistant + + // tool messages, 1 text-only step with an assistant message). + expect(result.messages.length).toBe(STEPS_WITH_TOOLS * 2 + 1); + }); + }); + + // ── Retry with backoff ────────────────────────────────────────────────── + // + // PURE tests: a fake `sleep` (records calls, resolves instantly, can abort + // on a chosen call) + a pure `delayFor` (the canonical schedule + 8h budget). + // A stub `ProviderContract` whose `stream` yields a retryable error N times + // then a finish. ZERO mocks of `@dispatch/*` modules — effects injected. + + /** The canonical backoff schedule (matches the orchestrator's concrete strategy). */ + const RETRY_SCHEDULE_MS = [5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000]; + const RETRY_TAIL_MS = 1_800_000; // 30m + const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000; // 8h + + /** Cumulative scheduled sleep through `attempt` (sum of delay[0..attempt]). */ + function cumulativeSleepMs(attempt: number): number { + let sum = 0; + for (let i = 0; i <= attempt; i++) { + sum += i < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[i] : RETRY_TAIL_MS; + } + return sum; + } + + /** Pure, deterministic delay decision (no I/O, no clock). */ + function delayFor(attempt: number): number | undefined { + const delay = attempt < RETRY_SCHEDULE_MS.length ? RETRY_SCHEDULE_MS[attempt] : RETRY_TAIL_MS; + if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop + return delay; + } + + /** The full schedule delayFor would emit (until budget exhausted). */ + function fullSchedule(): number[] { + const result: number[] = []; + let attempt = 0; + while (true) { + const delay = delayFor(attempt); + if (delay === undefined) break; + result.push(delay); + attempt++; + } + return result; + } + + /** + * Fake, controllable `sleep`: records every call's delay, resolves + * instantly (no real waiting), and can abort the controller on a chosen + * 1-based call index to simulate "abort during sleep". + */ + function createFakeSleep(controller: AbortController): { + sleep: (ms: number, signal: AbortSignal) => Promise; + calls: number[]; + abortOnCall: (n: number) => void; + } { + const calls: number[] = []; + let abortAt: number | undefined; + const sleep = async (ms: number, _signal: AbortSignal): Promise => { + calls.push(ms); + if (abortAt !== undefined && calls.length === abortAt) { + controller.abort(); + throw new Error("aborted"); + } + // Otherwise resolve instantly (no real waiting). + }; + return { + sleep, + calls, + abortOnCall: (n: number) => { + abortAt = n; + }, + }; + } + + /** A provider that yields a retryable error `errorCount` times, then success. */ + function createRetryingProvider(opts: { + errorCount: number; + error?: { message: string; code?: string; retryable?: boolean }; + success?: ProviderEvent[]; + }): { provider: ProviderContract; streamCalls: { value: number } } { + const streamCalls = { value: 0 }; + const error: ProviderEvent = { + type: "error", + message: opts.error?.message ?? "overloaded", + ...(opts.error?.code !== undefined ? { code: opts.error.code } : {}), + ...(opts.error?.retryable !== undefined ? { retryable: opts.error.retryable } : {}), + }; + const success = opts.success ?? [ + { type: "text-delta", delta: "hi" }, + { type: "finish", reason: "stop" }, + ]; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = streamCalls.value++; + return (async function* () { + if (idx < opts.errorCount) { + yield error; + return; + } + for (const event of success) yield event; + })(); + }, + }; + return { provider, streamCalls }; + } + + describe("retry with backoff", () => { + it("retries a retryable emitted error on schedule then succeeds", async () => { + const { provider } = createRetryingProvider({ + errorCount: 3, + error: { message: "HTTP 429: overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("stop"); + // 3 retries: 5s, 10s, 30s. + expect(fake.calls).toEqual([5_000, 10_000, 30_000]); + // 3 provider-retry events (one per sleep), then the successful text. + const retryEvents = events.filter((e) => e.type === "provider-retry"); + expect(retryEvents).toHaveLength(3); + if (retryEvents[0]?.type === "provider-retry") { + expect(retryEvents[0].attempt).toBe(0); + expect(retryEvents[0].delayMs).toBe(5_000); + expect(retryEvents[0].message).toBe("HTTP 429: overloaded"); + expect(retryEvents[0].code).toBe("429"); + expect(retryEvents[0].conversationId).toBe("conv-1"); + expect(retryEvents[0].turnId).toBe("turn-1"); + } + if (retryEvents[1]?.type === "provider-retry") { + expect(retryEvents[1].attempt).toBe(1); + expect(retryEvents[1].delayMs).toBe(10_000); + } + if (retryEvents[2]?.type === "provider-retry") { + expect(retryEvents[2].attempt).toBe(2); + expect(retryEvents[2].delayMs).toBe(30_000); + } + // The error was suppressed (no error event emitted — retry succeeded). + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + // The successful content still streams. + const deltas = events.filter((e) => e.type === "text-delta"); + expect(deltas).toHaveLength(1); + }); + + it("sleep is called with the full schedule [5s,10s,30s,60s,5m,10m,15m,30m,30m…]", async () => { + // Provider errors forever → retries until budget exhausted → gives up. + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + // Budget exhausted → give up → error. + expect(result.finishReason).toBe("error"); + + // The sleep schedule matches the pure delayFor output exactly. + expect(fake.calls).toEqual(fullSchedule()); + + // Head of the schedule (the 8 stepped delays). + expect(fake.calls.slice(0, 8)).toEqual([ + 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, + ]); + // Tail repeats 30m. + expect(fake.calls[8]).toBe(1_800_000); + expect(fake.calls.at(-1)).toBe(1_800_000); + + // 8h cumulative budget cap: head (3705s) + 13×30m = ~7h31m, then stop. + // 21 retries (attempts 0..20), then delayFor(21) → undefined → give up. + expect(fake.calls).toHaveLength(21); + const totalSlept = fake.calls.reduce((a, b) => a + b, 0); + expect(totalSlept).toBeLessThanOrEqual(RETRY_BUDGET_MS); + expect(totalSlept).toBe(3_705_000 + 13 * 1_800_000); // 27_105_000 + + // One provider-retry per sleep, plus a final error (give-up). + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(21); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + const errEvt = events.find((e) => e.type === "error"); + if (errEvt?.type === "error") { + expect(errEvt.message).toBe("overloaded"); + expect(errEvt.code).toBe("429"); + } + }); + + it("does NOT retry after content was emitted (safety invariant)", async () => { + // Provider yields text (content) THEN a retryable error. Because content + // was emitted, retrying is unsafe (would duplicate partial output). + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + yield { + type: "error", + message: "overloaded", + code: "429", + retryable: true, + } as ProviderEvent; + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + // No retries: stream called exactly once. + expect(callCount).toBe(1); + expect(fake.calls).toHaveLength(0); + // The error is emitted (give-up) and partial content preserved. + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); + }); + + it("does NOT retry a non-retryable emitted error (retryable: false)", async () => { + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "bad request", code: "400", retryable: false }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + }); + + it("does NOT retry a non-retryable emitted error (retryable absent)", async () => { + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "bad request", code: "400" }, // no retryable field + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + }); + + it("give-up emits the final error when budget is exhausted", async () => { + // Custom delayFor that allows exactly 1 retry then stops. + const shortDelayFor = (attempt: number): number | undefined => + attempt === 0 ? 100 : undefined; + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor: shortDelayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("error"); + expect(fake.calls).toEqual([100]); // one retry, then give up + // One provider-retry (attempt 0), then the final error. + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(1); + const errs = events.filter((e) => e.type === "error"); + expect(errs).toHaveLength(1); + if (errs[0]?.type === "error") { + expect(errs[0].message).toBe("overloaded"); + expect(errs[0].code).toBe("429"); + } + }); + + it("abort during sleep seals the turn aborted", async () => { + const { provider } = createRetryingProvider({ + errorCount: Number.POSITIVE_INFINITY, + error: { message: "overloaded", code: "429", retryable: true }, + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + fake.abortOnCall(2); // abort on the 2nd sleep + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(result.finishReason).toBe("aborted"); + // Two sleeps attempted; the 2nd aborted. + expect(fake.calls).toHaveLength(2); + // No terminal error emitted (it was an abort, not a give-up). + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + // One provider-retry before the aborted sleep (attempt 0). + const retries = events.filter((e) => e.type === "provider-retry"); + expect(retries).toHaveLength(2); + // The done event carries reason "aborted". + const done = events.find((e) => e.type === "done"); + if (done?.type === "done") { + expect(done.reason).toBe("aborted"); + } + }); + + it("omitting retry keeps the pre-retry behavior (backward-compatible)", async () => { + // A retryable error with no retry configured → ends the step as today. + const { provider, streamCalls } = createRetryingProvider({ + errorCount: 1, + error: { message: "overloaded", code: "429", retryable: true }, + }); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + // no retry field + }); + + expect(streamCalls.value).toBe(1); // no retry + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(0); + }); + + it("retries a THROWN error (retryable-by-default when pre-content)", async () => { + // A thrown error (no retryable flag) before content is retried. + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + if (callCount <= 2) { + throw new Error("network blip"); + } + yield { type: "text-delta", delta: "hi" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(callCount).toBe(3); // 2 throws retried, 3rd succeeds + expect(fake.calls).toEqual([5_000, 10_000]); + expect(result.finishReason).toBe("stop"); + expect(events.filter((e) => e.type === "provider-retry")).toHaveLength(2); + // Thrown errors have no code. + if (events[0]?.type === "provider-retry") { + expect(events[0].code).toBeUndefined(); + expect(events[0].message).toBe("network blip"); + } + expect(events.filter((e) => e.type === "error")).toHaveLength(0); + }); + + it("does NOT retry a thrown error after content was emitted", async () => { + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + callCount++; + return (async function* () { + yield { type: "text-delta", delta: "partial" } as ProviderEvent; + throw new Error("network blip"); + })(); + }, + }; + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + expect(callCount).toBe(1); + expect(fake.calls).toHaveLength(0); + expect(result.finishReason).toBe("error"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + expect(events.filter((e) => e.type === "text-delta")).toHaveLength(1); + }); + + it("provider-retry events interleave correctly: error → retry-event → sleep → retry", async () => { + // Verify ordering: each provider-retry event comes BEFORE its sleep, + // and the successful content comes only after the last retry. + const { provider } = createRetryingProvider({ + errorCount: 2, + error: { message: "overloaded", code: "429", retryable: true }, + success: [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + }); + const controller = new AbortController(); + const fake = createFakeSleep(controller); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + signal: controller.signal, + retry: { delayFor, sleep: fake.sleep }, + }); + + const types = events.map((e) => e.type); + // turn-start, provider-retry(0), provider-retry(1), text-delta, step-complete, done + expect(types[0]).toBe("turn-start"); + const firstRetryIdx = types.indexOf("provider-retry"); + const textIdx = types.indexOf("text-delta"); + expect(firstRetryIdx).toBeGreaterThan(0); + expect(textIdx).toBeGreaterThan(firstRetryIdx); + // Both retries precede the text. + const retryCount = types.filter((t) => t === "provider-retry").length; + expect(retryCount).toBe(2); + }); + }); }); diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index 273482f..3460033 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -1,30 +1,30 @@ import type { ChatMessage, Chunk, StepId } from "../contracts/conversation.js"; import type { Logger, Span } from "../contracts/logging.js"; import type { - ProviderContract, - ProviderEvent, - ProviderStreamOptions, - Usage, + ProviderContract, + ProviderEvent, + ProviderStreamOptions, + Usage, } from "../contracts/provider.js"; import type { - EventEmitter, - RetryStrategy, - RunTurnInput, - RunTurnResult, + EventEmitter, + RetryStrategy, + RunTurnInput, + RunTurnResult, } from "../contracts/runtime.js"; import type { ToolCall, ToolContract } from "../contracts/tool.js"; import { createStepDispatcher, type StepDispatcher } from "./dispatch.js"; import { - doneEvent, - errorEvent, - providerRetryEvent, - reasoningDeltaEvent, - stepCompleteEvent, - textDeltaEvent, - toolCallEvent, - toolResultEvent, - turnStartEvent, - usageEvent, + doneEvent, + errorEvent, + providerRetryEvent, + reasoningDeltaEvent, + stepCompleteEvent, + textDeltaEvent, + toolCallEvent, + toolResultEvent, + turnStartEvent, + usageEvent, } from "./events.js"; /** Max steps per turn. 0 = unlimited (the loop runs until the model stops @@ -32,69 +32,69 @@ import { export const MAX_STEPS = 0; function zeroUsage(): Usage { - return { inputTokens: 0, outputTokens: 0 }; + return { inputTokens: 0, outputTokens: 0 }; } function addUsage(a: Usage, b: Usage): Usage { - const inputTokens = a.inputTokens + b.inputTokens; - const outputTokens = a.outputTokens + b.outputTokens; - - if (a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined) { - const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); - if (a.cacheWriteTokens !== undefined || b.cacheWriteTokens !== undefined) { - return { - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0), - }; - } - return { inputTokens, outputTokens, cacheReadTokens }; - } - - if (a.cacheWriteTokens !== undefined || b.cacheWriteTokens !== undefined) { - return { - inputTokens, - outputTokens, - cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0), - }; - } - - return { inputTokens, outputTokens }; + const inputTokens = a.inputTokens + b.inputTokens; + const outputTokens = a.outputTokens + b.outputTokens; + + if (a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined) { + const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); + if (a.cacheWriteTokens !== undefined || b.cacheWriteTokens !== undefined) { + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0), + }; + } + return { inputTokens, outputTokens, cacheReadTokens }; + } + + if (a.cacheWriteTokens !== undefined || b.cacheWriteTokens !== undefined) { + return { + inputTokens, + outputTokens, + cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0), + }; + } + + return { inputTokens, outputTokens }; } function usageAttrs(usage: Usage): Record { - const attrs: Record = { - "usage.inputTokens": usage.inputTokens, - "usage.outputTokens": usage.outputTokens, - }; - if (usage.cacheReadTokens !== undefined) { - attrs["usage.cacheReadTokens"] = usage.cacheReadTokens; - } - if (usage.cacheWriteTokens !== undefined) { - attrs["usage.cacheWriteTokens"] = usage.cacheWriteTokens; - } - return attrs; + const attrs: Record = { + "usage.inputTokens": usage.inputTokens, + "usage.outputTokens": usage.outputTokens, + }; + if (usage.cacheReadTokens !== undefined) { + attrs["usage.cacheReadTokens"] = usage.cacheReadTokens; + } + if (usage.cacheWriteTokens !== undefined) { + attrs["usage.cacheWriteTokens"] = usage.cacheWriteTokens; + } + return attrs; } function appendTextDelta(chunks: Chunk[], delta: string): void { - const lastIdx = chunks.length - 1; - const last = chunks[lastIdx]; - if (last !== undefined && last.type === "text") { - chunks[lastIdx] = { type: "text", text: last.text + delta }; - } else { - chunks.push({ type: "text", text: delta }); - } + const lastIdx = chunks.length - 1; + const last = chunks[lastIdx]; + if (last !== undefined && last.type === "text") { + chunks[lastIdx] = { type: "text", text: last.text + delta }; + } else { + chunks.push({ type: "text", text: delta }); + } } function appendThinkingDelta(chunks: Chunk[], delta: string): void { - const lastIdx = chunks.length - 1; - const last = chunks[lastIdx]; - if (last !== undefined && last.type === "thinking") { - chunks[lastIdx] = { type: "thinking", text: last.text + delta }; - } else { - chunks.push({ type: "thinking", text: delta }); - } + const lastIdx = chunks.length - 1; + const last = chunks[lastIdx]; + if (last !== undefined && last.type === "thinking") { + chunks[lastIdx] = { type: "thinking", text: last.text + delta }; + } else { + chunks.push({ type: "thinking", text: delta }); + } } /** @@ -106,698 +106,698 @@ function appendThinkingDelta(chunks: Chunk[], delta: string): void { * orphaned `tool` messages in the next turn's history. */ function stripToolCallChunks(msg: ChatMessage): ChatMessage | undefined { - const stripped = msg.chunks.filter((c) => c.type !== "tool-call"); - return stripped.length > 0 ? { role: msg.role, chunks: stripped } : undefined; + const stripped = msg.chunks.filter((c) => c.type !== "tool-call"); + return stripped.length > 0 ? { role: msg.role, chunks: stripped } : undefined; } interface StepContext { - readonly provider: ProviderContract; - readonly messages: ChatMessage[]; - readonly tools: readonly ToolContract[]; - readonly toolMap: Map; - readonly dispatch: RunTurnInput["dispatch"]; - readonly emit: EventEmitter; - readonly signal: AbortSignal; - readonly conversationId: string; - readonly turnId: string; - readonly stepId: StepId; - readonly logger: Logger; - readonly turnSpan: Span | undefined; - readonly toolSpans: Map; - readonly cwd: string | undefined; - readonly computerId: string | undefined; - readonly now: (() => number) | undefined; - /** Per-turn provider options (model, systemPrompt, …) threaded to stream(). */ - readonly providerOpts: ProviderStreamOptions | undefined; - /** Optional injected retry strategy (omit = no retry, backward-compatible). */ - readonly retry: RetryStrategy | undefined; + readonly provider: ProviderContract; + readonly messages: ChatMessage[]; + readonly tools: readonly ToolContract[]; + readonly toolMap: Map; + readonly dispatch: RunTurnInput["dispatch"]; + readonly emit: EventEmitter; + readonly signal: AbortSignal; + readonly conversationId: string; + readonly turnId: string; + readonly stepId: StepId; + readonly logger: Logger; + readonly turnSpan: Span | undefined; + readonly toolSpans: Map; + readonly cwd: string | undefined; + readonly computerId: string | undefined; + readonly now: (() => number) | undefined; + /** Per-turn provider options (model, systemPrompt, …) threaded to stream(). */ + readonly providerOpts: ProviderStreamOptions | undefined; + /** Optional injected retry strategy (omit = no retry, backward-compatible). */ + readonly retry: RetryStrategy | undefined; } interface TimingState { - ttftSpan: Span | undefined; - decodeSpan: Span | undefined; - firstTokenSeen: boolean; - streamStartMs: number | undefined; - firstTokenMs: number | undefined; + ttftSpan: Span | undefined; + decodeSpan: Span | undefined; + firstTokenSeen: boolean; + streamStartMs: number | undefined; + firstTokenMs: number | undefined; } interface StepResult { - readonly assistantMessage: ChatMessage | undefined; - readonly toolCalls: ToolCall[]; - readonly toolMessages: ChatMessage[]; - readonly usage: Usage; - readonly finishReason: string; + readonly assistantMessage: ChatMessage | undefined; + readonly toolCalls: ToolCall[]; + readonly toolMessages: ChatMessage[]; + readonly usage: Usage; + readonly finishReason: string; } function processEvent( - event: ProviderEvent, - chunks: Chunk[], - toolCalls: ToolCall[], - dispatcher: StepDispatcher, - ctx: StepContext, - stepSpan: Span | undefined, - timing: TimingState, - toolDispatchTimes: Map, + event: ProviderEvent, + chunks: Chunk[], + toolCalls: ToolCall[], + dispatcher: StepDispatcher, + ctx: StepContext, + stepSpan: Span | undefined, + timing: TimingState, + toolDispatchTimes: Map, ): void { - switch (event.type) { - case "text-delta": - if (!timing.firstTokenSeen) { - timing.firstTokenSeen = true; - if (ctx.now !== undefined) { - timing.firstTokenMs = ctx.now(); - } - try { - timing.ttftSpan?.end({ attrs: { firstToken: true } }); - } catch { - // Swallow — D7. - } - timing.ttftSpan = undefined; - try { - timing.decodeSpan = stepSpan?.child("decode"); - } catch { - // Swallow — D7. - } - } - appendTextDelta(chunks, event.delta); - ctx.emit(textDeltaEvent(ctx.conversationId, ctx.turnId, event.delta)); - break; - case "reasoning-delta": - if (!timing.firstTokenSeen) { - timing.firstTokenSeen = true; - if (ctx.now !== undefined) { - timing.firstTokenMs = ctx.now(); - } - try { - timing.ttftSpan?.end({ attrs: { firstToken: true } }); - } catch { - // Swallow — D7. - } - timing.ttftSpan = undefined; - try { - timing.decodeSpan = stepSpan?.child("decode"); - } catch { - // Swallow — D7. - } - } - appendThinkingDelta(chunks, event.delta); - ctx.emit(reasoningDeltaEvent(ctx.conversationId, ctx.turnId, event.delta)); - break; - case "tool-call": { - const call: ToolCall = { - id: event.toolCallId, - name: event.toolName, - input: event.input, - }; - toolCalls.push(call); - chunks.push({ - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - stepId: ctx.stepId, - }); - ctx.emit( - toolCallEvent( - ctx.conversationId, - ctx.turnId, - ctx.stepId, - event.toolCallId, - event.toolName, - event.input, - ), - ); - - // Capture dispatch time for tool-call durationMs - if (ctx.now !== undefined) { - toolDispatchTimes.set(event.toolCallId, ctx.now()); - } - - // Open a tool-call span as a child of the step span (attrs: name, toolCallId) - try { - const tcSpan = - stepSpan !== undefined - ? stepSpan.child("tool-call", { - name: event.toolName, - toolCallId: event.toolCallId, - }) - : ctx.logger.span("tool-call", { - name: event.toolName, - toolCallId: event.toolCallId, - }); - ctx.toolSpans.set(event.toolCallId, tcSpan); - } catch { - // Swallow — D7: logging never breaks the turn. - } - - if (ctx.dispatch.eager) { - dispatcher.submit(call); - } - break; - } - case "usage": - ctx.emit(usageEvent(ctx.conversationId, ctx.turnId, event.usage, ctx.stepId)); - break; - case "finish": - break; - case "error": - // Handled by the retry loop in executeStep (not here): an error event - // is intercepted before processEvent so the step can decide whether to - // retry (suppressing the error) or give up (emit it). processEvent - // never receives an "error" event. - break; - } + switch (event.type) { + case "text-delta": + if (!timing.firstTokenSeen) { + timing.firstTokenSeen = true; + if (ctx.now !== undefined) { + timing.firstTokenMs = ctx.now(); + } + try { + timing.ttftSpan?.end({ attrs: { firstToken: true } }); + } catch { + // Swallow — D7. + } + timing.ttftSpan = undefined; + try { + timing.decodeSpan = stepSpan?.child("decode"); + } catch { + // Swallow — D7. + } + } + appendTextDelta(chunks, event.delta); + ctx.emit(textDeltaEvent(ctx.conversationId, ctx.turnId, event.delta)); + break; + case "reasoning-delta": + if (!timing.firstTokenSeen) { + timing.firstTokenSeen = true; + if (ctx.now !== undefined) { + timing.firstTokenMs = ctx.now(); + } + try { + timing.ttftSpan?.end({ attrs: { firstToken: true } }); + } catch { + // Swallow — D7. + } + timing.ttftSpan = undefined; + try { + timing.decodeSpan = stepSpan?.child("decode"); + } catch { + // Swallow — D7. + } + } + appendThinkingDelta(chunks, event.delta); + ctx.emit(reasoningDeltaEvent(ctx.conversationId, ctx.turnId, event.delta)); + break; + case "tool-call": { + const call: ToolCall = { + id: event.toolCallId, + name: event.toolName, + input: event.input, + }; + toolCalls.push(call); + chunks.push({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + stepId: ctx.stepId, + }); + ctx.emit( + toolCallEvent( + ctx.conversationId, + ctx.turnId, + ctx.stepId, + event.toolCallId, + event.toolName, + event.input, + ), + ); + + // Capture dispatch time for tool-call durationMs + if (ctx.now !== undefined) { + toolDispatchTimes.set(event.toolCallId, ctx.now()); + } + + // Open a tool-call span as a child of the step span (attrs: name, toolCallId) + try { + const tcSpan = + stepSpan !== undefined + ? stepSpan.child("tool-call", { + name: event.toolName, + toolCallId: event.toolCallId, + }) + : ctx.logger.span("tool-call", { + name: event.toolName, + toolCallId: event.toolCallId, + }); + ctx.toolSpans.set(event.toolCallId, tcSpan); + } catch { + // Swallow — D7: logging never breaks the turn. + } + + if (ctx.dispatch.eager) { + dispatcher.submit(call); + } + break; + } + case "usage": + ctx.emit(usageEvent(ctx.conversationId, ctx.turnId, event.usage, ctx.stepId)); + break; + case "finish": + break; + case "error": + // Handled by the retry loop in executeStep (not here): an error event + // is intercepted before processEvent so the step can decide whether to + // retry (suppressing the error) or give up (emit it). processEvent + // never receives an "error" event. + break; + } } async function executeStep(ctx: StepContext): Promise { - const chunks: Chunk[] = []; - const toolCalls: ToolCall[] = []; - const toolDispatchTimes = new Map(); - let stepUsage = zeroUsage(); - let finishReason = "stop"; - - // Open a step span as a child of the turn span; capture the verbatim - // pre-mutation prompt via a "prompt" child span whose body holds the - // serialized messages+tools. - let stepSpan: Span | undefined; - try { - stepSpan = ctx.turnSpan !== undefined ? ctx.turnSpan.child("step") : ctx.logger.span("step"); - const promptBody = JSON.stringify({ messages: ctx.messages, tools: ctx.tools }); - const promptSpan = stepSpan.child( - "prompt", - { - messageCount: ctx.messages.length, - toolCount: ctx.tools.length, - }, - promptBody, - ); - promptSpan.end(); - } catch { - // Swallow — D7. - } - - const dispatcher = createStepDispatcher( - ctx.toolMap, - ctx.dispatch, - ctx.signal, - ctx.emit, - ctx.conversationId, - ctx.turnId, - ctx.toolSpans, - ctx.cwd, - ctx.computerId, - ); - - const timing: TimingState = { - ttftSpan: undefined, - decodeSpan: undefined, - firstTokenSeen: false, - streamStartMs: ctx.now !== undefined ? ctx.now() : undefined, - firstTokenMs: undefined, - }; - - // Open TTFT span when spans are enabled - try { - if (stepSpan !== undefined) { - timing.ttftSpan = stepSpan.child("ttft"); - } - } catch { - // Swallow — D7. - } - - // Retry loop: wrap provider.stream() consumption. Retries are ONLY - // attempted when no content was emitted yet this step (the safety - // invariant — never duplicate partial output). On a retryable error — - // either an EMITTED `error` ProviderEvent with `retryable === true`, OR a - // THROWN error (retryable-by-default when pre-content) — with !hadContent: - // ask retry.delayFor(attempt); if it returns a delay → emit a transient - // provider-retry AgentEvent, sleep via the injected retry.sleep (abortable), - // attempt++, re-call provider.stream(); if it returns undefined (budget - // exhausted) → give up. Non-retryable emitted errors (retryable === false or - // absent), errors after content, and the no-retry-configured case all fall - // through to "give up" — identical to the pre-retry behavior. - let hadContent = false; - let attempt = 0; - while (true) { - let errored = false; - let wasThrown = false; - let errorMessage: string | undefined; - let errorCode: string | undefined; - let errorRetryable: boolean | undefined; - let thrownErr: unknown; - - try { - const opts: ProviderStreamOptions = { - ...ctx.providerOpts, - ...(ctx.turnSpan !== undefined && stepSpan !== undefined ? { logger: stepSpan.log } : {}), - }; - const stream = ctx.provider.stream(ctx.messages, ctx.tools, opts); - for await (const event of stream) { - if (ctx.signal.aborted) break; - if (event.type === "error") { - // Intercept: hold for the retry decision — don't push a chunk - // or emit yet (a successful retry would leave a stale error). - errored = true; - errorMessage = event.message; - errorCode = event.code; - errorRetryable = event.retryable; - break; - } - if ( - event.type === "text-delta" || - event.type === "reasoning-delta" || - event.type === "tool-call" || - event.type === "usage" - ) { - hadContent = true; - } - processEvent( - event, - chunks, - toolCalls, - dispatcher, - ctx, - stepSpan, - timing, - toolDispatchTimes, - ); - if (event.type === "usage") { - stepUsage = addUsage(stepUsage, event.usage); - } - if (event.type === "finish") { - finishReason = event.reason; - } - } - } catch (err) { - errored = true; - wasThrown = true; - errorMessage = err instanceof Error ? err.message : String(err); - errorCode = undefined; - errorRetryable = undefined; - thrownErr = err; - } - - // Abort (during stream) → stop; the runTurn loop seals aborted. - if (ctx.signal.aborted) { - break; - } - - // No error → step succeeded. - if (!errored) { - break; - } - - // Retryable? A thrown error is retryable-by-default when pre-content; - // an emitted error is retryable ONLY when `retryable === true` (absent - // or false → not retried, per the contract). - const isRetryable = wasThrown ? true : errorRetryable === true; - if (ctx.retry !== undefined && !hadContent && isRetryable) { - const delay = ctx.retry.delayFor(attempt); - if (delay !== undefined) { - // Emit the transient provider-retry event BEFORE the sleep so the - // UI shows "⚠ retrying in Ns…" immediately. Not persisted as a - // chat message — it never pollutes the prompt. - ctx.emit( - providerRetryEvent( - ctx.conversationId, - ctx.turnId, - attempt, - delay, - errorMessage ?? "", - errorCode, - ), - ); - // Abortable sleep. If the signal fires during sleep, the shell's - // sleep rejects — we catch it and break so the turn seals aborted. - try { - await ctx.retry.sleep(delay, ctx.signal); - } catch { - // Abort during sleep (or unexpected sleep failure). - } - if (ctx.signal.aborted) { - break; - } - attempt++; - continue; - } - // delayFor returned undefined → budget exhausted → give up. - } - - // Give up: emit the suppressed error and end the step. This is the - // single emission point for a terminal provider error (non-retryable, - // post-content, budget-exhausted, or no-retry-configured). - const message = errorMessage ?? ""; - if (errorCode !== undefined) { - chunks.push({ type: "error", message, code: errorCode }); - } else { - chunks.push({ type: "error", message }); - } - ctx.emit(errorEvent(ctx.conversationId, ctx.turnId, message, errorCode)); - finishReason = "error"; - try { - stepSpan?.end({ err: thrownErr ?? new Error(message) }); - } catch { - // Swallow — D7. - } - stepSpan = undefined; - break; - } - - // Close timing spans: if no first token was seen, end ttft with firstToken: false - // If decode span is open, close it - try { - if (timing.ttftSpan !== undefined) { - timing.ttftSpan.end({ attrs: { firstToken: false } }); - timing.ttftSpan = undefined; - } - if (timing.decodeSpan !== undefined) { - timing.decodeSpan.end(); - timing.decodeSpan = undefined; - } - } catch { - // Swallow — D7. - } - - // Emit step-complete event with timing - const streamEndMs = ctx.now !== undefined ? ctx.now() : undefined; - if (timing.streamStartMs !== undefined && streamEndMs !== undefined) { - const genTotalMs = streamEndMs - timing.streamStartMs; - const stepTiming: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = { - genTotalMs, - }; - if (timing.firstTokenMs !== undefined) { - stepTiming.ttftMs = timing.firstTokenMs - timing.streamStartMs; - stepTiming.decodeMs = streamEndMs - timing.firstTokenMs; - } - ctx.emit(stepCompleteEvent(ctx.conversationId, ctx.turnId, ctx.stepId, stepTiming)); - } else { - ctx.emit(stepCompleteEvent(ctx.conversationId, ctx.turnId, ctx.stepId)); - } - - if (!ctx.dispatch.eager) { - for (const call of toolCalls) { - dispatcher.submit(call); - } - } - - const results = await dispatcher.drain(); - - // Close remaining tool-call spans - for (const call of toolCalls) { - const tcSpan = ctx.toolSpans.get(call.id); - if (tcSpan !== undefined) { - const result = results.get(call.id); - try { - tcSpan.end({ - attrs: { - isError: result?.isError ?? false, - contentLength: result?.content.length ?? 0, - }, - }); - } catch { - // Swallow — D7. - } - ctx.toolSpans.delete(call.id); - } - } - - const toolMessages: ChatMessage[] = []; - for (const call of toolCalls) { - const result = results.get(call.id); - if (result !== undefined) { - const isError = result.isError ?? false; - const dispatchTime = toolDispatchTimes.get(call.id); - const toolDurationMs = - ctx.now !== undefined && dispatchTime !== undefined ? ctx.now() - dispatchTime : undefined; - ctx.emit( - toolResultEvent( - ctx.conversationId, - ctx.turnId, - ctx.stepId, - call.id, - call.name, - result.content, - isError, - toolDurationMs, - ), - ); - toolMessages.push({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: call.id, - toolName: call.name, - content: result.content, - isError, - stepId: ctx.stepId, - }, - ], - }); - } - } - - // Close step span (if not already closed by error) - if (stepSpan !== undefined) { - try { - stepSpan.end({ - attrs: { - finishReason, - ...usageAttrs(stepUsage), - }, - }); - } catch { - // Swallow — D7. - } - } - - const assistantMessage: ChatMessage | undefined = - chunks.length > 0 ? { role: "assistant", chunks } : undefined; - - return { assistantMessage, toolCalls, toolMessages, usage: stepUsage, finishReason }; + const chunks: Chunk[] = []; + const toolCalls: ToolCall[] = []; + const toolDispatchTimes = new Map(); + let stepUsage = zeroUsage(); + let finishReason = "stop"; + + // Open a step span as a child of the turn span; capture the verbatim + // pre-mutation prompt via a "prompt" child span whose body holds the + // serialized messages+tools. + let stepSpan: Span | undefined; + try { + stepSpan = ctx.turnSpan !== undefined ? ctx.turnSpan.child("step") : ctx.logger.span("step"); + const promptBody = JSON.stringify({ messages: ctx.messages, tools: ctx.tools }); + const promptSpan = stepSpan.child( + "prompt", + { + messageCount: ctx.messages.length, + toolCount: ctx.tools.length, + }, + promptBody, + ); + promptSpan.end(); + } catch { + // Swallow — D7. + } + + const dispatcher = createStepDispatcher( + ctx.toolMap, + ctx.dispatch, + ctx.signal, + ctx.emit, + ctx.conversationId, + ctx.turnId, + ctx.toolSpans, + ctx.cwd, + ctx.computerId, + ); + + const timing: TimingState = { + ttftSpan: undefined, + decodeSpan: undefined, + firstTokenSeen: false, + streamStartMs: ctx.now !== undefined ? ctx.now() : undefined, + firstTokenMs: undefined, + }; + + // Open TTFT span when spans are enabled + try { + if (stepSpan !== undefined) { + timing.ttftSpan = stepSpan.child("ttft"); + } + } catch { + // Swallow — D7. + } + + // Retry loop: wrap provider.stream() consumption. Retries are ONLY + // attempted when no content was emitted yet this step (the safety + // invariant — never duplicate partial output). On a retryable error — + // either an EMITTED `error` ProviderEvent with `retryable === true`, OR a + // THROWN error (retryable-by-default when pre-content) — with !hadContent: + // ask retry.delayFor(attempt); if it returns a delay → emit a transient + // provider-retry AgentEvent, sleep via the injected retry.sleep (abortable), + // attempt++, re-call provider.stream(); if it returns undefined (budget + // exhausted) → give up. Non-retryable emitted errors (retryable === false or + // absent), errors after content, and the no-retry-configured case all fall + // through to "give up" — identical to the pre-retry behavior. + let hadContent = false; + let attempt = 0; + while (true) { + let errored = false; + let wasThrown = false; + let errorMessage: string | undefined; + let errorCode: string | undefined; + let errorRetryable: boolean | undefined; + let thrownErr: unknown; + + try { + const opts: ProviderStreamOptions = { + ...ctx.providerOpts, + ...(ctx.turnSpan !== undefined && stepSpan !== undefined ? { logger: stepSpan.log } : {}), + }; + const stream = ctx.provider.stream(ctx.messages, ctx.tools, opts); + for await (const event of stream) { + if (ctx.signal.aborted) break; + if (event.type === "error") { + // Intercept: hold for the retry decision — don't push a chunk + // or emit yet (a successful retry would leave a stale error). + errored = true; + errorMessage = event.message; + errorCode = event.code; + errorRetryable = event.retryable; + break; + } + if ( + event.type === "text-delta" || + event.type === "reasoning-delta" || + event.type === "tool-call" || + event.type === "usage" + ) { + hadContent = true; + } + processEvent( + event, + chunks, + toolCalls, + dispatcher, + ctx, + stepSpan, + timing, + toolDispatchTimes, + ); + if (event.type === "usage") { + stepUsage = addUsage(stepUsage, event.usage); + } + if (event.type === "finish") { + finishReason = event.reason; + } + } + } catch (err) { + errored = true; + wasThrown = true; + errorMessage = err instanceof Error ? err.message : String(err); + errorCode = undefined; + errorRetryable = undefined; + thrownErr = err; + } + + // Abort (during stream) → stop; the runTurn loop seals aborted. + if (ctx.signal.aborted) { + break; + } + + // No error → step succeeded. + if (!errored) { + break; + } + + // Retryable? A thrown error is retryable-by-default when pre-content; + // an emitted error is retryable ONLY when `retryable === true` (absent + // or false → not retried, per the contract). + const isRetryable = wasThrown ? true : errorRetryable === true; + if (ctx.retry !== undefined && !hadContent && isRetryable) { + const delay = ctx.retry.delayFor(attempt); + if (delay !== undefined) { + // Emit the transient provider-retry event BEFORE the sleep so the + // UI shows "⚠ retrying in Ns…" immediately. Not persisted as a + // chat message — it never pollutes the prompt. + ctx.emit( + providerRetryEvent( + ctx.conversationId, + ctx.turnId, + attempt, + delay, + errorMessage ?? "", + errorCode, + ), + ); + // Abortable sleep. If the signal fires during sleep, the shell's + // sleep rejects — we catch it and break so the turn seals aborted. + try { + await ctx.retry.sleep(delay, ctx.signal); + } catch { + // Abort during sleep (or unexpected sleep failure). + } + if (ctx.signal.aborted) { + break; + } + attempt++; + continue; + } + // delayFor returned undefined → budget exhausted → give up. + } + + // Give up: emit the suppressed error and end the step. This is the + // single emission point for a terminal provider error (non-retryable, + // post-content, budget-exhausted, or no-retry-configured). + const message = errorMessage ?? ""; + if (errorCode !== undefined) { + chunks.push({ type: "error", message, code: errorCode }); + } else { + chunks.push({ type: "error", message }); + } + ctx.emit(errorEvent(ctx.conversationId, ctx.turnId, message, errorCode)); + finishReason = "error"; + try { + stepSpan?.end({ err: thrownErr ?? new Error(message) }); + } catch { + // Swallow — D7. + } + stepSpan = undefined; + break; + } + + // Close timing spans: if no first token was seen, end ttft with firstToken: false + // If decode span is open, close it + try { + if (timing.ttftSpan !== undefined) { + timing.ttftSpan.end({ attrs: { firstToken: false } }); + timing.ttftSpan = undefined; + } + if (timing.decodeSpan !== undefined) { + timing.decodeSpan.end(); + timing.decodeSpan = undefined; + } + } catch { + // Swallow — D7. + } + + // Emit step-complete event with timing + const streamEndMs = ctx.now !== undefined ? ctx.now() : undefined; + if (timing.streamStartMs !== undefined && streamEndMs !== undefined) { + const genTotalMs = streamEndMs - timing.streamStartMs; + const stepTiming: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = { + genTotalMs, + }; + if (timing.firstTokenMs !== undefined) { + stepTiming.ttftMs = timing.firstTokenMs - timing.streamStartMs; + stepTiming.decodeMs = streamEndMs - timing.firstTokenMs; + } + ctx.emit(stepCompleteEvent(ctx.conversationId, ctx.turnId, ctx.stepId, stepTiming)); + } else { + ctx.emit(stepCompleteEvent(ctx.conversationId, ctx.turnId, ctx.stepId)); + } + + if (!ctx.dispatch.eager) { + for (const call of toolCalls) { + dispatcher.submit(call); + } + } + + const results = await dispatcher.drain(); + + // Close remaining tool-call spans + for (const call of toolCalls) { + const tcSpan = ctx.toolSpans.get(call.id); + if (tcSpan !== undefined) { + const result = results.get(call.id); + try { + tcSpan.end({ + attrs: { + isError: result?.isError ?? false, + contentLength: result?.content.length ?? 0, + }, + }); + } catch { + // Swallow — D7. + } + ctx.toolSpans.delete(call.id); + } + } + + const toolMessages: ChatMessage[] = []; + for (const call of toolCalls) { + const result = results.get(call.id); + if (result !== undefined) { + const isError = result.isError ?? false; + const dispatchTime = toolDispatchTimes.get(call.id); + const toolDurationMs = + ctx.now !== undefined && dispatchTime !== undefined ? ctx.now() - dispatchTime : undefined; + ctx.emit( + toolResultEvent( + ctx.conversationId, + ctx.turnId, + ctx.stepId, + call.id, + call.name, + result.content, + isError, + toolDurationMs, + ), + ); + toolMessages.push({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + content: result.content, + isError, + stepId: ctx.stepId, + }, + ], + }); + } + } + + // Close step span (if not already closed by error) + if (stepSpan !== undefined) { + try { + stepSpan.end({ + attrs: { + finishReason, + ...usageAttrs(stepUsage), + }, + }); + } catch { + // Swallow — D7. + } + } + + const assistantMessage: ChatMessage | undefined = + chunks.length > 0 ? { role: "assistant", chunks } : undefined; + + return { assistantMessage, toolCalls, toolMessages, usage: stepUsage, finishReason }; } export async function runTurn(input: RunTurnInput): Promise { - const messages: ChatMessage[] = [...input.messages]; - const resultMessages: ChatMessage[] = []; - let totalUsage = zeroUsage(); - let lastStepUsage: Usage | undefined; - let finishReason = "stop"; - - const toolMap = new Map(); - for (const tool of input.tools) { - toolMap.set(tool.name, tool); - } - - const conversationId = input.conversationId; - const turnId = input.turnId; - const signal = input.signal ?? new AbortController().signal; - const logger = input.logger; - const now = input.now; - - // Record turn start time for durationMs on done - const turnStartMs = now !== undefined ? now() : undefined; - - // Open a turn span (attrs: conversationId, turnId, model) - let turnSpan: Span | undefined; - if (logger !== undefined) { - try { - turnSpan = logger.span("turn", { - conversationId, - turnId, - model: input.providerOpts?.model ?? input.provider.id, - }); - } catch { - // Swallow — D7. - } - } - - // Track open tool-call spans across steps so we can close them on abort - const toolSpans = new Map(); - - input.emit(turnStartEvent(conversationId, turnId)); - - try { - for (let step = 0; MAX_STEPS === 0 || step < MAX_STEPS; step++) { - if (signal.aborted) { - finishReason = "aborted"; - break; - } - - const stepId = `${turnId}#${step}` as StepId; - - const stepResult = await executeStep({ - provider: input.provider, - messages, - tools: input.tools, - toolMap, - dispatch: input.dispatch, - emit: input.emit, - signal, - conversationId, - turnId, - stepId, - logger: turnSpan?.log ?? logger ?? createNoopLogger(), - turnSpan, - toolSpans, - cwd: input.cwd, - computerId: input.computerId, - now, - providerOpts: input.providerOpts, - retry: input.retry, - }); - - totalUsage = addUsage(totalUsage, stepResult.usage); - lastStepUsage = stepResult.usage; - - // When the signal is aborted mid-step, the tool results are - // placeholders ({ content: "Aborted", isError: true }). If these - // are persisted and included in the next turn's message history, - // the provider sees a `tool` role message without a preceding - // `assistant` message carrying `tool_calls` → 400 error. - // - // To prevent this, when the signal is aborted we: - // 1. Strip tool-call chunks from the assistant message (keep - // text/thinking/error chunks so the partial response is - // preserved). - // 2. Omit tool-result messages entirely (they are not persisted, - // not added to resultMessages, and not passed to onStepComplete). - // - // This keeps the conversation history clean: the assistant's - // partial text is preserved, but no incomplete tool calls are - // left dangling. The `done` event still carries - // `reason: "aborted"`, so the turn seals cleanly. - const stepAborted = signal.aborted; - const assistantMessage = - stepAborted && stepResult.assistantMessage !== undefined - ? stripToolCallChunks(stepResult.assistantMessage) - : stepResult.assistantMessage; - const toolMessages = stepAborted ? [] : stepResult.toolMessages; - - if (assistantMessage !== undefined) { - messages.push(assistantMessage); - resultMessages.push(assistantMessage); - } - - for (const msg of toolMessages) { - messages.push(msg); - resultMessages.push(msg); - } - - // Incremental persistence: notify the caller that this step's - // messages are finalized. The caller can persist them immediately - // (assigning seq numbers during generation). The messages are the - // SAME objects in resultMessages — the caller must NOT double-persist. - if (input.onStepComplete !== undefined) { - const stepMessages: ChatMessage[] = []; - if (assistantMessage !== undefined) { - stepMessages.push(assistantMessage); - } - for (const msg of toolMessages) { - stepMessages.push(msg); - } - if (stepMessages.length > 0) { - await input.onStepComplete(stepMessages); - } - } - - if (stepAborted) { - finishReason = "aborted"; - break; - } - - if (stepResult.toolCalls.length === 0) { - finishReason = stepResult.finishReason; - break; - } - - if (MAX_STEPS > 0 && step === MAX_STEPS - 1) { - finishReason = "max-steps"; - // No next step → no tool-result boundary. Leave any pending - // steering messages for the caller (it owns the queue). - } else { - // Tool-result boundary: this step produced tool calls and we are - // about to call provider.stream again. Drain steering messages - // and append them after the tool results, before the next call. - // The kernel owns no queue and names no feature — it just calls - // the callback and appends. Emits nothing (caller emits the - // `steering` AgentEvent in its own wrapper). - const steering = input.drainSteering?.() ?? []; - for (const msg of steering) { - messages.push(msg); - } - } - } - } finally { - // Close any orphaned tool-call spans (e.g. abort mid-tool) - for (const [id, tcSpan] of toolSpans) { - try { - tcSpan.end({ attrs: { orphaned: true } }); - } catch { - // Swallow — D7. - } - toolSpans.delete(id); - } - - // Close the turn span - if (turnSpan !== undefined) { - try { - turnSpan.end({ - attrs: { - finishReason, - ...usageAttrs(totalUsage), - }, - }); - } catch { - // Swallow — D7. - } - } - } - - const turnDurationMs = - turnStartMs !== undefined && now !== undefined ? now() - turnStartMs : undefined; - const hasUsage = - totalUsage.inputTokens > 0 || - totalUsage.outputTokens > 0 || - totalUsage.cacheReadTokens !== undefined || - totalUsage.cacheWriteTokens !== undefined; - const contextSize = - hasUsage && lastStepUsage !== undefined - ? lastStepUsage.inputTokens + lastStepUsage.outputTokens - : undefined; - input.emit( - doneEvent( - conversationId, - turnId, - finishReason, - turnDurationMs, - hasUsage ? totalUsage : undefined, - contextSize, - ), - ); - - return { messages: resultMessages, usage: totalUsage, finishReason }; + const messages: ChatMessage[] = [...input.messages]; + const resultMessages: ChatMessage[] = []; + let totalUsage = zeroUsage(); + let lastStepUsage: Usage | undefined; + let finishReason = "stop"; + + const toolMap = new Map(); + for (const tool of input.tools) { + toolMap.set(tool.name, tool); + } + + const conversationId = input.conversationId; + const turnId = input.turnId; + const signal = input.signal ?? new AbortController().signal; + const logger = input.logger; + const now = input.now; + + // Record turn start time for durationMs on done + const turnStartMs = now !== undefined ? now() : undefined; + + // Open a turn span (attrs: conversationId, turnId, model) + let turnSpan: Span | undefined; + if (logger !== undefined) { + try { + turnSpan = logger.span("turn", { + conversationId, + turnId, + model: input.providerOpts?.model ?? input.provider.id, + }); + } catch { + // Swallow — D7. + } + } + + // Track open tool-call spans across steps so we can close them on abort + const toolSpans = new Map(); + + input.emit(turnStartEvent(conversationId, turnId)); + + try { + for (let step = 0; MAX_STEPS === 0 || step < MAX_STEPS; step++) { + if (signal.aborted) { + finishReason = "aborted"; + break; + } + + const stepId = `${turnId}#${step}` as StepId; + + const stepResult = await executeStep({ + provider: input.provider, + messages, + tools: input.tools, + toolMap, + dispatch: input.dispatch, + emit: input.emit, + signal, + conversationId, + turnId, + stepId, + logger: turnSpan?.log ?? logger ?? createNoopLogger(), + turnSpan, + toolSpans, + cwd: input.cwd, + computerId: input.computerId, + now, + providerOpts: input.providerOpts, + retry: input.retry, + }); + + totalUsage = addUsage(totalUsage, stepResult.usage); + lastStepUsage = stepResult.usage; + + // When the signal is aborted mid-step, the tool results are + // placeholders ({ content: "Aborted", isError: true }). If these + // are persisted and included in the next turn's message history, + // the provider sees a `tool` role message without a preceding + // `assistant` message carrying `tool_calls` → 400 error. + // + // To prevent this, when the signal is aborted we: + // 1. Strip tool-call chunks from the assistant message (keep + // text/thinking/error chunks so the partial response is + // preserved). + // 2. Omit tool-result messages entirely (they are not persisted, + // not added to resultMessages, and not passed to onStepComplete). + // + // This keeps the conversation history clean: the assistant's + // partial text is preserved, but no incomplete tool calls are + // left dangling. The `done` event still carries + // `reason: "aborted"`, so the turn seals cleanly. + const stepAborted = signal.aborted; + const assistantMessage = + stepAborted && stepResult.assistantMessage !== undefined + ? stripToolCallChunks(stepResult.assistantMessage) + : stepResult.assistantMessage; + const toolMessages = stepAborted ? [] : stepResult.toolMessages; + + if (assistantMessage !== undefined) { + messages.push(assistantMessage); + resultMessages.push(assistantMessage); + } + + for (const msg of toolMessages) { + messages.push(msg); + resultMessages.push(msg); + } + + // Incremental persistence: notify the caller that this step's + // messages are finalized. The caller can persist them immediately + // (assigning seq numbers during generation). The messages are the + // SAME objects in resultMessages — the caller must NOT double-persist. + if (input.onStepComplete !== undefined) { + const stepMessages: ChatMessage[] = []; + if (assistantMessage !== undefined) { + stepMessages.push(assistantMessage); + } + for (const msg of toolMessages) { + stepMessages.push(msg); + } + if (stepMessages.length > 0) { + await input.onStepComplete(stepMessages); + } + } + + if (stepAborted) { + finishReason = "aborted"; + break; + } + + if (stepResult.toolCalls.length === 0) { + finishReason = stepResult.finishReason; + break; + } + + if (MAX_STEPS > 0 && step === MAX_STEPS - 1) { + finishReason = "max-steps"; + // No next step → no tool-result boundary. Leave any pending + // steering messages for the caller (it owns the queue). + } else { + // Tool-result boundary: this step produced tool calls and we are + // about to call provider.stream again. Drain steering messages + // and append them after the tool results, before the next call. + // The kernel owns no queue and names no feature — it just calls + // the callback and appends. Emits nothing (caller emits the + // `steering` AgentEvent in its own wrapper). + const steering = input.drainSteering?.() ?? []; + for (const msg of steering) { + messages.push(msg); + } + } + } + } finally { + // Close any orphaned tool-call spans (e.g. abort mid-tool) + for (const [id, tcSpan] of toolSpans) { + try { + tcSpan.end({ attrs: { orphaned: true } }); + } catch { + // Swallow — D7. + } + toolSpans.delete(id); + } + + // Close the turn span + if (turnSpan !== undefined) { + try { + turnSpan.end({ + attrs: { + finishReason, + ...usageAttrs(totalUsage), + }, + }); + } catch { + // Swallow — D7. + } + } + } + + const turnDurationMs = + turnStartMs !== undefined && now !== undefined ? now() - turnStartMs : undefined; + const hasUsage = + totalUsage.inputTokens > 0 || + totalUsage.outputTokens > 0 || + totalUsage.cacheReadTokens !== undefined || + totalUsage.cacheWriteTokens !== undefined; + const contextSize = + hasUsage && lastStepUsage !== undefined + ? lastStepUsage.inputTokens + lastStepUsage.outputTokens + : undefined; + input.emit( + doneEvent( + conversationId, + turnId, + finishReason, + turnDurationMs, + hasUsage ? totalUsage : undefined, + contextSize, + ), + ); + + return { messages: resultMessages, usage: totalUsage, finishReason }; } function createNoopLogger(): Logger { - return { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return createNoopLogger(); - }, - span() { - return { - id: "noop", - log: createNoopLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return createNoopLogger(); + }, + span() { + return { + id: "noop", + log: createNoopLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index a882987..702d8cf 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../wire" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../wire" }] } diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 2b5a360..7f97e8d 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/lsp", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/lsp", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/lsp/src/aggregate.test.ts b/packages/lsp/src/aggregate.test.ts index 4579a0a..9f93893 100644 --- a/packages/lsp/src/aggregate.test.ts +++ b/packages/lsp/src/aggregate.test.ts @@ -8,134 +8,134 @@ import type { LanguageServerClient } from "./client.js"; * tool.test.ts) — no real process, no internal mocks of our own modules. */ function fakeClient( - waitForDiagnostics: LanguageServerClient["waitForDiagnostics"], + waitForDiagnostics: LanguageServerClient["waitForDiagnostics"], ): LanguageServerClient { - return { waitForDiagnostics } as unknown as LanguageServerClient; + return { waitForDiagnostics } as unknown as LanguageServerClient; } const SERVER_A: AggregateServer = { id: "a", name: "Ruby-LSP", root: "/p" }; const SERVER_B: AggregateServer = { id: "b", name: "Steep", root: "/p" }; describe("aggregateDiagnostics", () => { - it("returns merged diagnostics from all responding servers, tagged by source", async () => { - const clients = new Map([ - [ - "a", - fakeClient(async () => ({ formatted: "ERROR L1:1: boom", slow: false, timedOut: false })), - ], - [ - "b", - fakeClient(async () => ({ formatted: "WARNING L2:3: meh", slow: false, timedOut: false })), - ], - ]); + it("returns merged diagnostics from all responding servers, tagged by source", async () => { + const clients = new Map([ + [ + "a", + fakeClient(async () => ({ formatted: "ERROR L1:1: boom", slow: false, timedOut: false })), + ], + [ + "b", + fakeClient(async () => ({ formatted: "WARNING L2:3: meh", slow: false, timedOut: false })), + ], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - expect(result.timedOut).toBe(false); - expect(result.formatted).toContain("[Ruby-LSP]"); - expect(result.formatted).toContain("boom"); - expect(result.formatted).toContain("[Steep]"); - expect(result.formatted).toContain("meh"); - }); + expect(result.timedOut).toBe(false); + expect(result.formatted).toContain("[Ruby-LSP]"); + expect(result.formatted).toContain("boom"); + expect(result.formatted).toContain("[Steep]"); + expect(result.formatted).toContain("meh"); + }); - it("skips a server that times out with a raise-to-user notice, and still returns the fast server's result", async () => { - // Steep never resolves within the cap → timedOut; ruby-lsp answers fast. - const clients = new Map([ - ["a", fakeClient(async () => ({ formatted: "", slow: false, timedOut: false }))], - ["b", fakeClient(async () => ({ formatted: "", slow: false, timedOut: true }))], - ]); + it("skips a server that times out with a raise-to-user notice, and still returns the fast server's result", async () => { + // Steep never resolves within the cap → timedOut; ruby-lsp answers fast. + const clients = new Map([ + ["a", fakeClient(async () => ({ formatted: "", slow: false, timedOut: false }))], + ["b", fakeClient(async () => ({ formatted: "", slow: false, timedOut: true }))], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - expect(result.timedOut).toBe(true); - // The skip notice names the offending server and the cap. - expect(result.formatted).toContain("[Steep]"); - expect(result.formatted).toContain("took too long"); - expect(result.formatted).toContain(">10s"); - expect(result.formatted).toContain("raise this to the user"); - // ruby-lsp answered cleanly (empty diagnostics) → no line for it. - expect(result.formatted).not.toContain("[Ruby-LSP]"); - }); + expect(result.timedOut).toBe(true); + // The skip notice names the offending server and the cap. + expect(result.formatted).toContain("[Steep]"); + expect(result.formatted).toContain("took too long"); + expect(result.formatted).toContain(">10s"); + expect(result.formatted).toContain("raise this to the user"); + // ruby-lsp answered cleanly (empty diagnostics) → no line for it. + expect(result.formatted).not.toContain("[Ruby-LSP]"); + }); - it("runs servers concurrently: a slow server does not delay a fast one's contribution order", async () => { - const callOrder: string[] = []; - const clients = new Map([ - [ - "a", - fakeClient(async () => { - callOrder.push("a-start"); - await new Promise((r) => setTimeout(r, 5)); - callOrder.push("a-end"); - return { formatted: "from-a", slow: false, timedOut: false }; - }), - ], - [ - "b", - fakeClient(async () => { - callOrder.push("b-start"); - await new Promise((r) => setTimeout(r, 30)); - callOrder.push("b-end"); - return { formatted: "from-b", slow: false, timedOut: false }; - }), - ], - ]); + it("runs servers concurrently: a slow server does not delay a fast one's contribution order", async () => { + const callOrder: string[] = []; + const clients = new Map([ + [ + "a", + fakeClient(async () => { + callOrder.push("a-start"); + await new Promise((r) => setTimeout(r, 5)); + callOrder.push("a-end"); + return { formatted: "from-a", slow: false, timedOut: false }; + }), + ], + [ + "b", + fakeClient(async () => { + callOrder.push("b-start"); + await new Promise((r) => setTimeout(r, 30)); + callOrder.push("b-end"); + return { formatted: "from-b", slow: false, timedOut: false }; + }), + ], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - // Both started before either ended → concurrent, not sequential. - expect(callOrder.slice(0, 2).sort()).toEqual(["a-start", "b-start"]); - expect(result.formatted).toContain("from-a"); - expect(result.formatted).toContain("from-b"); - }); + // Both started before either ended → concurrent, not sequential. + expect(callOrder.slice(0, 2).sort()).toEqual(["a-start", "b-start"]); + expect(result.formatted).toContain("from-a"); + expect(result.formatted).toContain("from-b"); + }); - it("a missing client (dead/excluded) contributes nothing and never rejects", async () => { - const result = await aggregateDiagnostics(() => undefined, [SERVER_A], "/p/x.rb", 10_000, {}); - expect(result.formatted).toBe(""); - expect(result.timedOut).toBe(false); - }); + it("a missing client (dead/excluded) contributes nothing and never rejects", async () => { + const result = await aggregateDiagnostics(() => undefined, [SERVER_A], "/p/x.rb", 10_000, {}); + expect(result.formatted).toBe(""); + expect(result.timedOut).toBe(false); + }); - it("forwards text + minSeverity to each client's waitForDiagnostics", async () => { - const seen: Array<{ text?: string; minSeverity?: number; timeoutMs: number }> = []; - const clients = new Map([ - [ - "a", - fakeClient(async (_path, opts) => { - seen.push({ - text: opts?.text, - minSeverity: opts?.minSeverity, - timeoutMs: opts?.timeoutMs ?? -1, - }); - return { formatted: "", slow: false, timedOut: false }; - }), - ], - ]); + it("forwards text + minSeverity to each client's waitForDiagnostics", async () => { + const seen: Array<{ text?: string; minSeverity?: number; timeoutMs: number }> = []; + const clients = new Map([ + [ + "a", + fakeClient(async (_path, opts) => { + seen.push({ + text: opts?.text, + minSeverity: opts?.minSeverity, + timeoutMs: opts?.timeoutMs ?? -1, + }); + return { formatted: "", slow: false, timedOut: false }; + }), + ], + ]); - await aggregateDiagnostics((id) => clients.get(id), [SERVER_A], "/p/x.rb", 7000, { - text: "post-edit buffer", - minSeverity: 2, - }); + await aggregateDiagnostics((id) => clients.get(id), [SERVER_A], "/p/x.rb", 7000, { + text: "post-edit buffer", + minSeverity: 2, + }); - expect(seen).toHaveLength(1); - expect(seen[0]?.text).toBe("post-edit buffer"); - expect(seen[0]?.minSeverity).toBe(2); - expect(seen[0]?.timeoutMs).toBe(7000); - }); + expect(seen).toHaveLength(1); + expect(seen[0]?.text).toBe("post-edit buffer"); + expect(seen[0]?.minSeverity).toBe(2); + expect(seen[0]?.timeoutMs).toBe(7000); + }); }); diff --git a/packages/lsp/src/aggregate.ts b/packages/lsp/src/aggregate.ts index b044e21..af89c00 100644 --- a/packages/lsp/src/aggregate.ts +++ b/packages/lsp/src/aggregate.ts @@ -14,23 +14,23 @@ import type { LanguageServerClient } from "./client.js"; export interface AggregateServer { - readonly id: string; - readonly name: string; - readonly root: string; + readonly id: string; + readonly name: string; + readonly root: string; } export interface AggregateOpts { - /** Post-edit buffer; when omitted the server reads from disk. */ - readonly text?: string | undefined; - /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). */ - readonly minSeverity?: number | undefined; + /** Post-edit buffer; when omitted the server reads from disk. */ + readonly text?: string | undefined; + /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). */ + readonly minSeverity?: number | undefined; } export interface AggregateResult { - /** Merged diagnostics tagged by source + a per-skipped-server notice. */ - readonly formatted: string; - /** True if at least one server was skipped for exceeding the cap. */ - readonly timedOut: boolean; + /** Merged diagnostics tagged by source + a per-skipped-server notice. */ + readonly formatted: string; + /** True if at least one server was skipped for exceeding the cap. */ + readonly timedOut: boolean; } /** @@ -41,40 +41,40 @@ export interface AggregateResult { * contribution for that server. */ export async function aggregateDiagnostics( - getClient: (id: string, root: string) => LanguageServerClient | undefined, - servers: readonly AggregateServer[], - absolutePath: string, - timeoutMs: number, - opts: AggregateOpts, + getClient: (id: string, root: string) => LanguageServerClient | undefined, + servers: readonly AggregateServer[], + absolutePath: string, + timeoutMs: number, + opts: AggregateOpts, ): Promise { - const entries = await Promise.all( - servers.map(async (server) => { - const client = getClient(server.id, server.root); - if (!client) return null; - const waitOpts: { text?: string; timeoutMs: number; minSeverity?: number } = { timeoutMs }; - if (opts.text !== undefined) waitOpts.text = opts.text; - if (opts.minSeverity !== undefined) waitOpts.minSeverity = opts.minSeverity; - const result = await client.waitForDiagnostics(absolutePath, waitOpts); - return { server, result }; - }), - ); + const entries = await Promise.all( + servers.map(async (server) => { + const client = getClient(server.id, server.root); + if (!client) return null; + const waitOpts: { text?: string; timeoutMs: number; minSeverity?: number } = { timeoutMs }; + if (opts.text !== undefined) waitOpts.text = opts.text; + if (opts.minSeverity !== undefined) waitOpts.minSeverity = opts.minSeverity; + const result = await client.waitForDiagnostics(absolutePath, waitOpts); + return { server, result }; + }), + ); - const parts: string[] = []; - let timedOut = false; - const capSeconds = Math.round(timeoutMs / 1000); + const parts: string[] = []; + let timedOut = false; + const capSeconds = Math.round(timeoutMs / 1000); - for (const entry of entries) { - if (!entry) continue; - const { server, result } = entry; - if (result.timedOut) { - timedOut = true; - parts.push( - `⚠️ [${server.name}] LSP took too long (>${capSeconds}s), diagnostics skipped — please raise this to the user.`, - ); - } else if (result.formatted) { - parts.push(`[${server.name}]\n${result.formatted}`); - } - } + for (const entry of entries) { + if (!entry) continue; + const { server, result } = entry; + if (result.timedOut) { + timedOut = true; + parts.push( + `⚠️ [${server.name}] LSP took too long (>${capSeconds}s), diagnostics skipped — please raise this to the user.`, + ); + } else if (result.formatted) { + parts.push(`[${server.name}]\n${result.formatted}`); + } + } - return { formatted: parts.join("\n\n"), timedOut }; + return { formatted: parts.join("\n\n"), timedOut }; } diff --git a/packages/lsp/src/client.test.ts b/packages/lsp/src/client.test.ts index 338ef0b..9495888 100644 --- a/packages/lsp/src/client.test.ts +++ b/packages/lsp/src/client.test.ts @@ -1,437 +1,437 @@ import { describe, expect, it } from "vitest"; import { - type FileWatcher, - type FsAccess, - LanguageServerClient, - type ProcessExitHandler, - type SpawnProcess, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type ProcessExitHandler, + type SpawnProcess, } from "./client.js"; import { encode } from "./framing.js"; function makeClient(overrides?: { - readonly spawn?: SpawnProcess; - readonly fileWatcher?: FileWatcher; - readonly fs?: FsAccess; - readonly initialization?: Record; + readonly spawn?: SpawnProcess; + readonly fileWatcher?: FileWatcher; + readonly fs?: FsAccess; + readonly initialization?: Record; }): { - client: LanguageServerClient; - stdinChunks: Uint8Array[]; - serverResponses: (msg: string) => void; + client: LanguageServerClient; + stdinChunks: Uint8Array[]; + serverResponses: (msg: string) => void; } { - const stdinChunks: Uint8Array[] = []; - let serverMessageHandler: ((msg: string) => void) | null = null; - - const mockSpawn: SpawnProcess = () => ({ - stdin: { write: (bytes) => stdinChunks.push(bytes) }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - // We'll feed messages through serverResponses - serverMessageHandler = (msg: string) => { - cb(encode(msg)); - }; - }, - }, - pid: 123, - kill: () => {}, - }); - - const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ - close: () => {}, - }); - - const mockFs: FsAccess = { - readText: async (path) => `// content of ${path}`, - exists: async () => true, - }; - - const client = new LanguageServerClient({ - spawn: overrides?.spawn ?? mockSpawn, - fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, - fs: overrides?.fs ?? mockFs, - command: ["test-lsp"], - root: "/project", - serverId: "test", - ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), - }); - - return { - client, - stdinChunks, - serverResponses: (msg: string) => serverMessageHandler?.(msg), - }; + const stdinChunks: Uint8Array[] = []; + let serverMessageHandler: ((msg: string) => void) | null = null; + + const mockSpawn: SpawnProcess = () => ({ + stdin: { write: (bytes) => stdinChunks.push(bytes) }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + // We'll feed messages through serverResponses + serverMessageHandler = (msg: string) => { + cb(encode(msg)); + }; + }, + }, + pid: 123, + kill: () => {}, + }); + + const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ + close: () => {}, + }); + + const mockFs: FsAccess = { + readText: async (path) => `// content of ${path}`, + exists: async () => true, + }; + + const client = new LanguageServerClient({ + spawn: overrides?.spawn ?? mockSpawn, + fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, + fs: overrides?.fs ?? mockFs, + command: ["test-lsp"], + root: "/project", + serverId: "test", + ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), + }); + + return { + client, + stdinChunks, + serverResponses: (msg: string) => serverMessageHandler?.(msg), + }; } describe("client", () => { - it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { - const { client, stdinChunks, serverResponses } = makeClient(); - - const startPromise = client.start(); - - // Wait for the initialize message to be sent - await new Promise((r) => setTimeout(r, 50)); - - // Parse the sent messages to find initialize - const sentMessages = stdinChunks.map((chunk) => { - const decoded = new TextDecoder().decode(chunk); - const headerEnd = decoded.indexOf("\r\n\r\n"); - return JSON.parse(decoded.slice(headerEnd + 4)); - }); - - const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); - expect(initMsg).toBeDefined(); - expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( - true, - ); - - // Send initialize response - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: initMsg.id, - result: { capabilities: {} }, - }), - ); - - await startPromise; - expect(client.getState()).toBe("connected"); - }); - - it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Register workspace/didChangeWatchedFiles - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "client/registerCapability", - params: { - registrations: [ - { - id: "reg-watched", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }, - { - id: "reg-diag", - method: "textDocument/diagnostic", - registerOptions: {}, - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 50)); - - const registry = client.getWatchedFilesRegistry(); - expect(registry.matches("src/main.luau")).toBe(true); - expect(registry.matches("src/main.ts")).toBe(false); - }); - - it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { - const callbackHolder: { - cb: - | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) - | null; - } = { cb: null }; - - const trackingFileWatcher: FileWatcher = (_root, onEvent) => { - callbackHolder.cb = onEvent; - return { close: () => {} }; - }; - - const { client, stdinChunks, serverResponses } = makeClient({ - fileWatcher: trackingFileWatcher, - }); - - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Register a watcher for sourcemap.json - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "client/registerCapability", - params: { - registrations: [ - { - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "sourcemap.json" }], - }, - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 100)); - - // Simulate a file change - const onFsEvent = callbackHolder.cb; - if (!onFsEvent) throw new Error("file watcher callback was never registered"); - onFsEvent({ type: "change", path: "/project/sourcemap.json" }); - - await new Promise((r) => setTimeout(r, 100)); - - // Check that the notification was sent - const sentMessages = stdinChunks.map((chunk) => { - const decoded = new TextDecoder().decode(chunk); - const headerEnd = decoded.indexOf("\r\n\r\n"); - return JSON.parse(decoded.slice(headerEnd + 4)); - }); - - const didChangeMsg = sentMessages.find( - (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", - ); - expect(didChangeMsg).toBeDefined(); - expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); - expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed - }); - - it("publishDiagnostics are stored + returned", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Send diagnostics - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { - uri: "file:///project/test.ts", - diagnostics: [ - { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - severity: 1, - message: "Test error", - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 50)); - - const store = client.getDiagnosticsStore(); - const formatted = store.format("file:///project/test.ts"); - expect(formatted).toContain("ERROR"); - expect(formatted).toContain("Test error"); - }); - - it("shutdown kills the process", async () => { - const state = { killed: false }; - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - const killableSpawn: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - stdoutHolder.cb = cb; - }, - }, - pid: 123, - kill: () => { - state.killed = true; - }, - }); - - const { client } = makeClient({ spawn: killableSpawn }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - // Deliver the initialize response through the spawned process's own - // stdout (the real read path), so start() can complete the handshake. - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - - await startPromise; - - client.shutdown(); - expect(state.killed).toBe(true); - }); - - it("onExit marks the client broken (error) so callers stop querying a corpse", async () => { - const state = { killed: false }; - let exitCb: ProcessExitHandler | null = null; - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - - const spawnWithExit: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - stdoutHolder.cb = cb; - }, - }, - pid: 999, - kill: () => { - state.killed = true; - }, - onExit: (handler) => { - exitCb = handler; - }, - }); - - const { client } = makeClient({ spawn: spawnWithExit }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - await startPromise; - expect(client.getState()).toBe("connected"); - - // Simulate the process dying (user kill / crash). - exitCb?.({ code: 1 }); - - expect(client.getState()).toBe("error"); - expect(client.getStateError()).toMatch(/process exited/); - // The (still-alive-in-test) process was killed to avoid a zombie. - expect(state.killed).toBe(true); - }); - - it("a dead client is skipped by waitForDiagnostics callers (state !== connected)", async () => { - // Build a client, connect, kill via onExit, then assert a diagnostics - // query would not block: getState() is "error" so the matching filter - // (state === "connected") excludes it. We assert the state guard. - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - let exitCb: ProcessExitHandler | null = null; - const spawnWithExit: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_e, cb) => { - stdoutHolder.cb = cb; - }, - }, - pid: 1, - kill: () => {}, - onExit: (handler) => { - exitCb = handler; - }, - }); - - const { client } = makeClient({ spawn: spawnWithExit }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - await startPromise; - - exitCb?.({ code: null }); - expect(client.getState()).toBe("error"); - // The aggregate / getDiagnostics matching filter requires "connected". - expect(client.getState() === "connected").toBe(false); - }); - - it("corruption detector marks the client broken after repeated identical diagnostics despite text changes", async () => { - // A healthy server would change diagnostics as the file changes; a - // corrupted one re-emits the SAME non-empty set. Drive 5 edits with - // different text but identical diagnostics → client flips to error. - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); - await startPromise; - - const phantom = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { - uri: "file:///project/game.rb", - diagnostics: [ - { - range: { start: { line: 0, character: 27 }, end: { line: 0, character: 28 } }, - severity: 1, - message: "SyntaxError: unexpected token", - }, - ], - }, - }); - - const path = "/project/game.rb"; - // The first call establishes the baseline snapshot (no increment). - // Each subsequent call with identical diagnostics + changed text - // increments; the 6th call (5th increment) trips the threshold. - for (let i = 1; i <= 5; i++) { - const p = client.waitForDiagnostics(path, { text: `buf-v${i}`, timeoutMs: 2000 }); - // Push the identical phantom diagnostics so the poll resolves. - await new Promise((r) => setTimeout(r, 30)); - serverResponses(phantom); - await p; - expect(client.getState()).toBe("connected"); - } - // 6th identical-across-changed-text repeat trips the threshold. - const p6 = client.waitForDiagnostics(path, { text: "buf-v6", timeoutMs: 2000 }); - await new Promise((r) => setTimeout(r, 30)); - serverResponses(phantom); - await p6; - - expect(client.getState()).toBe("error"); - expect(client.getStateError()).toMatch(/repeated stale diagnostics/i); - }); - - it("corruption detector does NOT trip on a clean file (empty diagnostics stay identical)", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); - await startPromise; - - const clean = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { uri: "file:///project/game.rb", diagnostics: [] }, - }); - const path = "/project/game.rb"; - - for (let i = 1; i <= 6; i++) { - const p = client.waitForDiagnostics(path, { text: `clean-v${i}`, timeoutMs: 2000 }); - await new Promise((r) => setTimeout(r, 30)); - serverResponses(clean); - await p; - } - // Empty diagnostics never count as "stale" — a clean file staying clean - // is normal, not corruption. - expect(client.getState()).toBe("connected"); - }); + it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { + const { client, stdinChunks, serverResponses } = makeClient(); + + const startPromise = client.start(); + + // Wait for the initialize message to be sent + await new Promise((r) => setTimeout(r, 50)); + + // Parse the sent messages to find initialize + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); + expect(initMsg).toBeDefined(); + expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( + true, + ); + + // Send initialize response + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: initMsg.id, + result: { capabilities: {} }, + }), + ); + + await startPromise; + expect(client.getState()).toBe("connected"); + }); + + it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register workspace/didChangeWatchedFiles + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-watched", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }, + { + id: "reg-diag", + method: "textDocument/diagnostic", + registerOptions: {}, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const registry = client.getWatchedFilesRegistry(); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + }); + + it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { + const callbackHolder: { + cb: + | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) + | null; + } = { cb: null }; + + const trackingFileWatcher: FileWatcher = (_root, onEvent) => { + callbackHolder.cb = onEvent; + return { close: () => {} }; + }; + + const { client, stdinChunks, serverResponses } = makeClient({ + fileWatcher: trackingFileWatcher, + }); + + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register a watcher for sourcemap.json + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "sourcemap.json" }], + }, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Simulate a file change + const onFsEvent = callbackHolder.cb; + if (!onFsEvent) throw new Error("file watcher callback was never registered"); + onFsEvent({ type: "change", path: "/project/sourcemap.json" }); + + await new Promise((r) => setTimeout(r, 100)); + + // Check that the notification was sent + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const didChangeMsg = sentMessages.find( + (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", + ); + expect(didChangeMsg).toBeDefined(); + expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); + expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed + }); + + it("publishDiagnostics are stored + returned", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Send diagnostics + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Test error", + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const store = client.getDiagnosticsStore(); + const formatted = store.format("file:///project/test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("Test error"); + }); + + it("shutdown kills the process", async () => { + const state = { killed: false }; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + const killableSpawn: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 123, + kill: () => { + state.killed = true; + }, + }); + + const { client } = makeClient({ spawn: killableSpawn }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + // Deliver the initialize response through the spawned process's own + // stdout (the real read path), so start() can complete the handshake. + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + + await startPromise; + + client.shutdown(); + expect(state.killed).toBe(true); + }); + + it("onExit marks the client broken (error) so callers stop querying a corpse", async () => { + const state = { killed: false }; + let exitCb: ProcessExitHandler | null = null; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + + const spawnWithExit: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 999, + kill: () => { + state.killed = true; + }, + onExit: (handler) => { + exitCb = handler; + }, + }); + + const { client } = makeClient({ spawn: spawnWithExit }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + await startPromise; + expect(client.getState()).toBe("connected"); + + // Simulate the process dying (user kill / crash). + exitCb?.({ code: 1 }); + + expect(client.getState()).toBe("error"); + expect(client.getStateError()).toMatch(/process exited/); + // The (still-alive-in-test) process was killed to avoid a zombie. + expect(state.killed).toBe(true); + }); + + it("a dead client is skipped by waitForDiagnostics callers (state !== connected)", async () => { + // Build a client, connect, kill via onExit, then assert a diagnostics + // query would not block: getState() is "error" so the matching filter + // (state === "connected") excludes it. We assert the state guard. + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + let exitCb: ProcessExitHandler | null = null; + const spawnWithExit: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_e, cb) => { + stdoutHolder.cb = cb; + }, + }, + pid: 1, + kill: () => {}, + onExit: (handler) => { + exitCb = handler; + }, + }); + + const { client } = makeClient({ spawn: spawnWithExit }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + await startPromise; + + exitCb?.({ code: null }); + expect(client.getState()).toBe("error"); + // The aggregate / getDiagnostics matching filter requires "connected". + expect(client.getState() === "connected").toBe(false); + }); + + it("corruption detector marks the client broken after repeated identical diagnostics despite text changes", async () => { + // A healthy server would change diagnostics as the file changes; a + // corrupted one re-emits the SAME non-empty set. Drive 5 edits with + // different text but identical diagnostics → client flips to error. + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); + await startPromise; + + const phantom = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/game.rb", + diagnostics: [ + { + range: { start: { line: 0, character: 27 }, end: { line: 0, character: 28 } }, + severity: 1, + message: "SyntaxError: unexpected token", + }, + ], + }, + }); + + const path = "/project/game.rb"; + // The first call establishes the baseline snapshot (no increment). + // Each subsequent call with identical diagnostics + changed text + // increments; the 6th call (5th increment) trips the threshold. + for (let i = 1; i <= 5; i++) { + const p = client.waitForDiagnostics(path, { text: `buf-v${i}`, timeoutMs: 2000 }); + // Push the identical phantom diagnostics so the poll resolves. + await new Promise((r) => setTimeout(r, 30)); + serverResponses(phantom); + await p; + expect(client.getState()).toBe("connected"); + } + // 6th identical-across-changed-text repeat trips the threshold. + const p6 = client.waitForDiagnostics(path, { text: "buf-v6", timeoutMs: 2000 }); + await new Promise((r) => setTimeout(r, 30)); + serverResponses(phantom); + await p6; + + expect(client.getState()).toBe("error"); + expect(client.getStateError()).toMatch(/repeated stale diagnostics/i); + }); + + it("corruption detector does NOT trip on a clean file (empty diagnostics stay identical)", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); + await startPromise; + + const clean = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { uri: "file:///project/game.rb", diagnostics: [] }, + }); + const path = "/project/game.rb"; + + for (let i = 1; i <= 6; i++) { + const p = client.waitForDiagnostics(path, { text: `clean-v${i}`, timeoutMs: 2000 }); + await new Promise((r) => setTimeout(r, 30)); + serverResponses(clean); + await p; + } + // Empty diagnostics never count as "stale" — a clean file staying clean + // is normal, not corruption. + expect(client.getState()).toBe("connected"); + }); }); diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts index ac7d025..f0e40ff 100644 --- a/packages/lsp/src/client.ts +++ b/packages/lsp/src/client.ts @@ -13,565 +13,565 @@ import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js"; /** Info delivered to an `onExit` handler when the child process terminates. */ export interface ProcessExitInfo { - readonly code: number | null; - readonly signal?: string; + readonly code: number | null; + readonly signal?: string; } /** A handler registered to be called when the child process exits. */ export type ProcessExitHandler = (info: ProcessExitInfo) => void; export interface SpawnedProcess { - readonly stdin: { readonly write: (bytes: Uint8Array) => void }; - readonly stdout: - | AsyncIterable - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; - readonly stderr?: - | AsyncIterable - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } - | undefined; - readonly pid: number | undefined; - readonly kill: () => void; - /** - * Register a handler fired when the child process exits (code|signal). - * Optional: when absent, death is detected via stdout-end instead. Wires - * Bun's `proc.exited` in production; tests invoke it directly to simulate - * a crash. Lets the client stop querying a dead server (no per-edit hang). - */ - readonly onExit?: ((handler: ProcessExitHandler) => void) | undefined; + readonly stdin: { readonly write: (bytes: Uint8Array) => void }; + readonly stdout: + | AsyncIterable + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; + readonly stderr?: + | AsyncIterable + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } + | undefined; + readonly pid: number | undefined; + readonly kill: () => void; + /** + * Register a handler fired when the child process exits (code|signal). + * Optional: when absent, death is detected via stdout-end instead. Wires + * Bun's `proc.exited` in production; tests invoke it directly to simulate + * a crash. Lets the client stop querying a dead server (no per-edit hang). + */ + readonly onExit?: ((handler: ProcessExitHandler) => void) | undefined; } export type SpawnProcess = ( - command: string[], - opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, ) => SpawnedProcess; export interface FileWatcherHandle { - readonly close: () => void; + readonly close: () => void; } export type FileWatcher = ( - root: string, - onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, ) => FileWatcherHandle; export interface FsAccess { - readonly readText: (path: string) => Promise; - readonly exists: (path: string) => Promise; + readonly readText: (path: string) => Promise; + readonly exists: (path: string) => Promise; } export interface ClientCapabilities { - readonly window: { readonly workDoneProgress: boolean }; - readonly workspace: { - readonly configuration: boolean; - readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; - readonly diagnostics: { readonly refreshSupport: boolean }; - }; - readonly textDocument: { - readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; - readonly diagnostic: { - readonly dynamicRegistration: boolean; - readonly relatedDocumentSupport: boolean; - }; - readonly publishDiagnostics: { readonly versionSupport: boolean }; - }; + readonly window: { readonly workDoneProgress: boolean }; + readonly workspace: { + readonly configuration: boolean; + readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; + readonly diagnostics: { readonly refreshSupport: boolean }; + }; + readonly textDocument: { + readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; + readonly diagnostic: { + readonly dynamicRegistration: boolean; + readonly relatedDocumentSupport: boolean; + }; + readonly publishDiagnostics: { readonly versionSupport: boolean }; + }; } export const CLIENT_CAPABILITIES: ClientCapabilities = { - window: { workDoneProgress: true }, - workspace: { - configuration: true, - didChangeWatchedFiles: { dynamicRegistration: true }, - diagnostics: { refreshSupport: false }, - }, - textDocument: { - synchronization: { didOpen: true, didChange: true }, - diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, - publishDiagnostics: { versionSupport: false }, - }, + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, }; export interface ClientDeps { - readonly spawn: SpawnProcess; - readonly fileWatcher: FileWatcher; - readonly fs: FsAccess; - readonly command: readonly string[]; - readonly env?: Readonly> | undefined; - readonly root: string; - readonly initialization?: Readonly> | undefined; - readonly serverId: string; + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly command: readonly string[]; + readonly env?: Readonly> | undefined; + readonly root: string; + readonly initialization?: Readonly> | undefined; + readonly serverId: string; } export type ClientState = "starting" | "connected" | "error" | "not-started"; export class LanguageServerClient { - readonly serverId: string; - readonly root: string; - private process: SpawnedProcess | null = null; - private rpc: JsonRpcConnection | null = null; - private decoder = new FrameDecoder(); - private diagnostics = new DiagnosticsStore(); - private watchedFiles = new WatchedFilesRegistry(); - private fileWatcherHandle: FileWatcherHandle | null = null; - private state: ClientState = "not-started"; - private stateError: string | undefined; - private deps: ClientDeps; - private openDocuments = new Map(); - /** Sync mode captured from the server's initialize capabilities: 1=Full, 2=Incremental. */ - private textDocumentChange: 1 | 2 = 1; - /** - * Corruption detection: the last diagnostic-key set + synced text per URI. - * A healthy server's diagnostics change when the file changes; a corrupted - * one (e.g. Steep's ~3h phantom-SyntaxError drift) re-emits the identical - * non-empty set across edits. `staleRepeat` counts consecutive such repeats - * across URIs; at the threshold the client is marked broken (→ respawn). - */ - private lastDiagSnapshot = new Map; text: string }>(); - private staleRepeat = 0; - private static readonly STALE_REPEAT_THRESHOLD = 5; - /** Default timeout for outbound requests (hover/definition/references). */ - private static readonly REQUEST_TIMEOUT_MS = 10_000; - - constructor(deps: ClientDeps) { - this.deps = deps; - this.serverId = deps.serverId; - this.root = deps.root; - } - - getState(): ClientState { - return this.state; - } - - getStateError(): string | undefined { - return this.stateError; - } - - async start(): Promise { - this.state = "starting"; - try { - const spawnOpts: { readonly cwd: string; readonly env?: Readonly> } = { - cwd: this.root, - }; - if (this.deps.env) { - (spawnOpts as { env?: Readonly> }).env = this.deps.env; - } - const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); - this.process = proc; - // Detect process death so we stop querying a corpse (fixes the - // per-edit hang after a server is killed/crashes). onExit is the - // primary signal; stdout-end is the defence-in-depth fallback. - if (proc.onExit) { - proc.onExit((info) => this.handleExit(info)); - } - - const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); - const rpc = new JsonRpcConnection(writeFn); - this.rpc = rpc; - - this.setupServerHandlers(rpc); - - const stdoutSource = proc.stdout; - if (Symbol.asyncIterator in stdoutSource) { - this.readFromAsyncIterable(stdoutSource as AsyncIterable); - } else { - this.readFromEventSource( - stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, - ); - } - - await this.initialize(rpc); - this.state = "connected"; - } catch (err: unknown) { - this.state = "error"; - this.stateError = err instanceof Error ? err.message : String(err); - } - } - - private readFromAsyncIterable(source: AsyncIterable): void { - (async () => { - try { - for await (const chunk of source) { - this.handleBytes(chunk); - } - // stdout closed — the process is gone (defence-in-depth alongside onExit, - // which some edges never call). Idempotent via handleExit's guard. - this.handleExit({ code: null }); - } catch { - this.handleExit({ code: null }); - } - })(); - } - - private readFromEventSource(source: { - readonly on: (event: string, cb: (data: Uint8Array) => void) => void; - }): void { - source.on("data", (data: Uint8Array) => { - this.handleBytes(data); - }); - } - - /** - * The server process exited (onExit or stdout-end). Transition to a broken - * state so callers skip it and the manager re-spawns after backoff — instead - * of polling a corpse for the full timeout on every edit. Idempotent. - */ - private handleExit(info: ProcessExitInfo): void { - if (this.state === "error" || this.state === "not-started") return; - const detail = info.signal !== undefined ? `signal ${info.signal}` : `code ${info.code ?? "?"}`; - this.markBroken(`language server process exited (${detail})`); - } - - /** - * Mark this client permanently broken: kill the process if still alive - * (corruption case), dispose the rpc (rejects pending requests), and drop - * edge handles. The manager's status() observes state:"error" and re-spawns - * after the bounded backoff. Called on process death AND on corruption. - */ - private markBroken(reason: string): void { - if (this.state === "error") return; - this.state = "error"; - this.stateError = reason; - this.fileWatcherHandle?.close(); - this.fileWatcherHandle = null; - this.process?.kill(); - this.process = null; - this.rpc?.dispose(); - this.rpc = null; - } - - /** - * Detect a server stuck re-emitting identical non-empty diagnostics - * despite the file content changing between calls — the signature of a - * corrupted parse/type-check state (e.g. Steep's ~3h phantom-SyntaxError - * drift, where a fresh CLI reports green on the same project). After - * STALE_REPEAT_THRESHOLD consecutive such repeats, mark the client broken - * so it is skipped + re-spawned. A clean file (empty diagnostics) or a - * genuinely changing diagnostic set resets the counter. Note the - * tradeoff: a real, unfixed error on an untouched line also "stays the - * same across edits", so this can false-positive on a healthy server — - * the threshold is set conservatively and the CLI type-check gate remains - * authoritative either way. - */ - private detectStaleDiagnostics(uri: string, text: string): void { - const merged = this.diagnostics.getMerged(uri); - const keys = new Set(merged.map((d) => diagnosticKey(d))); - const prev = this.lastDiagSnapshot.get(uri); - if (prev && keys.size > 0 && setsEqual(keys, prev.keys) && text !== prev.text) { - this.staleRepeat++; - } else { - this.staleRepeat = 0; - } - this.lastDiagSnapshot.set(uri, { keys, text }); - if (this.staleRepeat >= LanguageServerClient.STALE_REPEAT_THRESHOLD) { - this.markBroken( - "language server emitting repeated stale diagnostics despite file changes — likely corrupted; restarting", - ); - } - } - - private handleBytes(chunk: Uint8Array): void { - const messages = this.decoder.decode(chunk); - for (const msg of messages) { - // handleMessage is async — catch rejections so a malformed - // message never becomes an unhandled rejection that crashes - // the server. (handleMessage also has its own try/catch around - // JSON.parse, but this is the defence-in-depth boundary.) - void this.rpc?.handleMessage(msg).catch(() => {}); - } - } - - private setupServerHandlers(rpc: JsonRpcConnection): void { - rpc.onNotification("textDocument/publishDiagnostics", (params) => { - this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); - }); - - rpc.onRequest("workspace/configuration", (params) => { - const { items } = params as { readonly items: readonly { readonly section?: string }[] }; - const init = this.deps.initialization ?? {}; - return items.map((item) => { - if (item.section) { - const keys = item.section.split("."); - let value: unknown = init; - for (const key of keys) { - if (value && typeof value === "object" && key in value) { - value = (value as Record)[key]; - } else { - return undefined; - } - } - return value; - } - return init; - }); - }); - - rpc.onRequest("workspace/workspaceFolders", () => { - return [{ uri: `file://${this.root}`, name: this.root }]; - }); - - rpc.onRequest("window/workDoneProgress/create", () => null); - rpc.onRequest("workspace/diagnostic/refresh", () => null); - - rpc.onRequest("client/registerCapability", (params) => { - const { registrations } = params as { - readonly registrations: readonly { - readonly id: string; - readonly method: string; - readonly registerOptions?: unknown; - }[]; - }; - for (const reg of registrations) { - if (reg.method === "textDocument/diagnostic") { - // Store diagnostic registration (future use) - } else if (reg.method === "workspace/didChangeWatchedFiles") { - const opts = reg.registerOptions as - | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions - | undefined; - if (opts) { - this.watchedFiles.applyRegister({ - id: reg.id, - method: reg.method, - registerOptions: opts, - }); - } - } - } - return null; - }); - - rpc.onRequest("client/unregisterCapability", (params) => { - const { unregistrations } = params as { - readonly unregistrations: readonly { - readonly id: string; - readonly method: string; - }[]; - }; - for (const unreg of unregistrations) { - this.watchedFiles.applyUnregister(unreg); - } - return null; - }); - } - - private async initialize(rpc: JsonRpcConnection): Promise { - const timeout = 45_000; - - const initPromise = rpc.sendRequest("initialize", { - processId: this.process?.pid ?? null, - rootUri: `file://${this.root}`, - workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], - capabilities: CLIENT_CAPABILITIES, - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Initialize timeout")), timeout); - }); - - const result = (await Promise.race([initPromise, timeoutPromise])) as { - readonly capabilities?: { - readonly textDocumentSync?: - | number - | { readonly openClose?: boolean; readonly change?: number } - | undefined; - }; - }; - - // Capture the server's text document sync mode for didChange. - const sync = result.capabilities?.textDocumentSync; - if (typeof sync === "number") { - this.textDocumentChange = sync as 1 | 2; - } else if (sync && typeof sync === "object" && sync.change !== undefined) { - this.textDocumentChange = sync.change as 1 | 2; - } - - rpc.sendNotification("initialized", {}); - - if (this.deps.initialization) { - rpc.sendNotification("workspace/didChangeConfiguration", { - settings: this.deps.initialization, - }); - } - - this.startFileWatcher(); - } - - private startFileWatcher(): void { - const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; - this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { - const changeType = - event.type === "create" - ? FileChangeType.Created - : event.type === "delete" - ? FileChangeType.Deleted - : FileChangeType.Changed; - - const relativePath = event.path.startsWith(rootPrefix) - ? event.path.slice(rootPrefix.length) - : event.path.replace(/^\/+/, ""); - - if (this.watchedFiles.matches(relativePath)) { - this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { - changes: [{ uri: `file://${event.path}`, type: changeType }], - }); - } - }); - } - - async open(filePath: string): Promise { - const rpc = this.rpc; - if (!rpc || this.state !== "connected") return; - - try { - const text = await this.deps.fs.readText(filePath); - await this.openWithText(filePath, text); - } catch { - // file may not exist - } - } - - async openWithText(filePath: string, text: string, langId?: string): Promise { - const rpc = this.rpc; - if (!rpc || this.state !== "connected") return; - - // If already open, use didChange instead of re-opening. - if (this.openDocuments.has(filePath)) { - await this.change(filePath, text); - return; - } - - const version = 1; - this.openDocuments.set(filePath, { version, text }); - - rpc.sendNotification("textDocument/didOpen", { - textDocument: { - uri: `file://${filePath}`, - languageId: langId ?? resolveLanguageId(filePath), - version, - text, - }, - }); - } - - async change(filePath: string, newText: string): Promise { - const rpc = this.rpc; - if (!rpc || this.state !== "connected") return; - - const existing = this.openDocuments.get(filePath); - if (!existing) { - // Not open yet — didOpen instead. - await this.openWithText(filePath, newText); - return; - } - - const version = existing.version + 1; - this.openDocuments.set(filePath, { version, text: newText }); - - if (this.textDocumentChange === 2) { - // Incremental sync — compute the minimal change range. - const changeEvent = computeChangeRange(existing.text, newText); - rpc.sendNotification("textDocument/didChange", { - textDocument: { uri: `file://${filePath}`, version }, - contentChanges: [changeEvent], - }); - } else { - // Full sync — send the entire content. - rpc.sendNotification("textDocument/didChange", { - textDocument: { uri: `file://${filePath}`, version }, - contentChanges: [{ text: newText }], - }); - } - } - - async waitForDiagnostics( - filePath: string, - opts?: { readonly text?: string; readonly timeoutMs?: number; readonly minSeverity?: number }, - ): Promise<{ readonly formatted: string; readonly slow: boolean; readonly timedOut: boolean }> { - const timeoutMs = opts?.timeoutMs ?? 10_000; - const uri = `file://${filePath}`; - - // Clear the "received" flag so we detect fresh publishDiagnostics after our sync. - this.diagnostics.clearReceived(uri); - - // Sync the document: use didChange with the provided text (post-edit buffer) - // or fall back to didOpen reading from disk. - if (opts?.text !== undefined) { - await this.change(filePath, opts.text); - } else { - await this.open(filePath); - } - - const start = Date.now(); - - // Poll until the server pushes diagnostics (even empty = done) or the - // per-server cap elapses (then we skip it — see aggregateDiagnostics). - const received = await new Promise((resolve) => { - const check = () => { - const elapsed = Date.now() - start; - const got = this.diagnostics.hasReceivedPush(uri); - if (got || elapsed >= timeoutMs) { - resolve(got); - return; - } - setTimeout(check, 100); - }; - check(); - }); - - // Only a server that actually pushed can be corruption-checked. - if (received) { - this.detectStaleDiagnostics(uri, opts?.text ?? ""); - } - - // `slow` is structurally false now: the per-server cap is 10s, so - // elapsed can never exceed the old "unusually long" threshold. That - // warning is superseded by the timeout→skip notice produced in - // aggregateDiagnostics. The field is kept for contract compatibility. - return { - formatted: this.diagnostics.formatFiltered(uri, opts?.minSeverity), - slow: false, - timedOut: !received, - }; - } - - getWatchedFilesRegistry(): WatchedFilesRegistry { - return this.watchedFiles; - } - - getDiagnosticsStore(): DiagnosticsStore { - return this.diagnostics; - } - - /** - * Send a request (hover/definition/references/documentSymbol). Capped at - * REQUEST_TIMEOUT_MS so a dead/slow server can't hang the turn — the - * initialize handshake bypasses this (it calls rpc.sendRequest directly - * with its own 45s race). - */ - async request( - method: string, - params?: unknown, - timeoutMs: number = LanguageServerClient.REQUEST_TIMEOUT_MS, - ): Promise { - if (!this.rpc || this.state !== "connected") { - throw new Error("Client not connected"); - } - return this.rpc.sendRequest(method, params, timeoutMs); - } - - shutdown(): void { - this.fileWatcherHandle?.close(); - this.fileWatcherHandle = null; - this.process?.kill(); - this.process = null; - this.rpc?.dispose(); - this.rpc = null; - this.state = "not-started"; - } + readonly serverId: string; + readonly root: string; + private process: SpawnedProcess | null = null; + private rpc: JsonRpcConnection | null = null; + private decoder = new FrameDecoder(); + private diagnostics = new DiagnosticsStore(); + private watchedFiles = new WatchedFilesRegistry(); + private fileWatcherHandle: FileWatcherHandle | null = null; + private state: ClientState = "not-started"; + private stateError: string | undefined; + private deps: ClientDeps; + private openDocuments = new Map(); + /** Sync mode captured from the server's initialize capabilities: 1=Full, 2=Incremental. */ + private textDocumentChange: 1 | 2 = 1; + /** + * Corruption detection: the last diagnostic-key set + synced text per URI. + * A healthy server's diagnostics change when the file changes; a corrupted + * one (e.g. Steep's ~3h phantom-SyntaxError drift) re-emits the identical + * non-empty set across edits. `staleRepeat` counts consecutive such repeats + * across URIs; at the threshold the client is marked broken (→ respawn). + */ + private lastDiagSnapshot = new Map; text: string }>(); + private staleRepeat = 0; + private static readonly STALE_REPEAT_THRESHOLD = 5; + /** Default timeout for outbound requests (hover/definition/references). */ + private static readonly REQUEST_TIMEOUT_MS = 10_000; + + constructor(deps: ClientDeps) { + this.deps = deps; + this.serverId = deps.serverId; + this.root = deps.root; + } + + getState(): ClientState { + return this.state; + } + + getStateError(): string | undefined { + return this.stateError; + } + + async start(): Promise { + this.state = "starting"; + try { + const spawnOpts: { readonly cwd: string; readonly env?: Readonly> } = { + cwd: this.root, + }; + if (this.deps.env) { + (spawnOpts as { env?: Readonly> }).env = this.deps.env; + } + const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); + this.process = proc; + // Detect process death so we stop querying a corpse (fixes the + // per-edit hang after a server is killed/crashes). onExit is the + // primary signal; stdout-end is the defence-in-depth fallback. + if (proc.onExit) { + proc.onExit((info) => this.handleExit(info)); + } + + const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); + const rpc = new JsonRpcConnection(writeFn); + this.rpc = rpc; + + this.setupServerHandlers(rpc); + + const stdoutSource = proc.stdout; + if (Symbol.asyncIterator in stdoutSource) { + this.readFromAsyncIterable(stdoutSource as AsyncIterable); + } else { + this.readFromEventSource( + stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, + ); + } + + await this.initialize(rpc); + this.state = "connected"; + } catch (err: unknown) { + this.state = "error"; + this.stateError = err instanceof Error ? err.message : String(err); + } + } + + private readFromAsyncIterable(source: AsyncIterable): void { + (async () => { + try { + for await (const chunk of source) { + this.handleBytes(chunk); + } + // stdout closed — the process is gone (defence-in-depth alongside onExit, + // which some edges never call). Idempotent via handleExit's guard. + this.handleExit({ code: null }); + } catch { + this.handleExit({ code: null }); + } + })(); + } + + private readFromEventSource(source: { + readonly on: (event: string, cb: (data: Uint8Array) => void) => void; + }): void { + source.on("data", (data: Uint8Array) => { + this.handleBytes(data); + }); + } + + /** + * The server process exited (onExit or stdout-end). Transition to a broken + * state so callers skip it and the manager re-spawns after backoff — instead + * of polling a corpse for the full timeout on every edit. Idempotent. + */ + private handleExit(info: ProcessExitInfo): void { + if (this.state === "error" || this.state === "not-started") return; + const detail = info.signal !== undefined ? `signal ${info.signal}` : `code ${info.code ?? "?"}`; + this.markBroken(`language server process exited (${detail})`); + } + + /** + * Mark this client permanently broken: kill the process if still alive + * (corruption case), dispose the rpc (rejects pending requests), and drop + * edge handles. The manager's status() observes state:"error" and re-spawns + * after the bounded backoff. Called on process death AND on corruption. + */ + private markBroken(reason: string): void { + if (this.state === "error") return; + this.state = "error"; + this.stateError = reason; + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + } + + /** + * Detect a server stuck re-emitting identical non-empty diagnostics + * despite the file content changing between calls — the signature of a + * corrupted parse/type-check state (e.g. Steep's ~3h phantom-SyntaxError + * drift, where a fresh CLI reports green on the same project). After + * STALE_REPEAT_THRESHOLD consecutive such repeats, mark the client broken + * so it is skipped + re-spawned. A clean file (empty diagnostics) or a + * genuinely changing diagnostic set resets the counter. Note the + * tradeoff: a real, unfixed error on an untouched line also "stays the + * same across edits", so this can false-positive on a healthy server — + * the threshold is set conservatively and the CLI type-check gate remains + * authoritative either way. + */ + private detectStaleDiagnostics(uri: string, text: string): void { + const merged = this.diagnostics.getMerged(uri); + const keys = new Set(merged.map((d) => diagnosticKey(d))); + const prev = this.lastDiagSnapshot.get(uri); + if (prev && keys.size > 0 && setsEqual(keys, prev.keys) && text !== prev.text) { + this.staleRepeat++; + } else { + this.staleRepeat = 0; + } + this.lastDiagSnapshot.set(uri, { keys, text }); + if (this.staleRepeat >= LanguageServerClient.STALE_REPEAT_THRESHOLD) { + this.markBroken( + "language server emitting repeated stale diagnostics despite file changes — likely corrupted; restarting", + ); + } + } + + private handleBytes(chunk: Uint8Array): void { + const messages = this.decoder.decode(chunk); + for (const msg of messages) { + // handleMessage is async — catch rejections so a malformed + // message never becomes an unhandled rejection that crashes + // the server. (handleMessage also has its own try/catch around + // JSON.parse, but this is the defence-in-depth boundary.) + void this.rpc?.handleMessage(msg).catch(() => {}); + } + } + + private setupServerHandlers(rpc: JsonRpcConnection): void { + rpc.onNotification("textDocument/publishDiagnostics", (params) => { + this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); + }); + + rpc.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + const init = this.deps.initialization ?? {}; + return items.map((item) => { + if (item.section) { + const keys = item.section.split("."); + let value: unknown = init; + for (const key of keys) { + if (value && typeof value === "object" && key in value) { + value = (value as Record)[key]; + } else { + return undefined; + } + } + return value; + } + return init; + }); + }); + + rpc.onRequest("workspace/workspaceFolders", () => { + return [{ uri: `file://${this.root}`, name: this.root }]; + }); + + rpc.onRequest("window/workDoneProgress/create", () => null); + rpc.onRequest("workspace/diagnostic/refresh", () => null); + + rpc.onRequest("client/registerCapability", (params) => { + const { registrations } = params as { + readonly registrations: readonly { + readonly id: string; + readonly method: string; + readonly registerOptions?: unknown; + }[]; + }; + for (const reg of registrations) { + if (reg.method === "textDocument/diagnostic") { + // Store diagnostic registration (future use) + } else if (reg.method === "workspace/didChangeWatchedFiles") { + const opts = reg.registerOptions as + | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions + | undefined; + if (opts) { + this.watchedFiles.applyRegister({ + id: reg.id, + method: reg.method, + registerOptions: opts, + }); + } + } + } + return null; + }); + + rpc.onRequest("client/unregisterCapability", (params) => { + const { unregistrations } = params as { + readonly unregistrations: readonly { + readonly id: string; + readonly method: string; + }[]; + }; + for (const unreg of unregistrations) { + this.watchedFiles.applyUnregister(unreg); + } + return null; + }); + } + + private async initialize(rpc: JsonRpcConnection): Promise { + const timeout = 45_000; + + const initPromise = rpc.sendRequest("initialize", { + processId: this.process?.pid ?? null, + rootUri: `file://${this.root}`, + workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], + capabilities: CLIENT_CAPABILITIES, + }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Initialize timeout")), timeout); + }); + + const result = (await Promise.race([initPromise, timeoutPromise])) as { + readonly capabilities?: { + readonly textDocumentSync?: + | number + | { readonly openClose?: boolean; readonly change?: number } + | undefined; + }; + }; + + // Capture the server's text document sync mode for didChange. + const sync = result.capabilities?.textDocumentSync; + if (typeof sync === "number") { + this.textDocumentChange = sync as 1 | 2; + } else if (sync && typeof sync === "object" && sync.change !== undefined) { + this.textDocumentChange = sync.change as 1 | 2; + } + + rpc.sendNotification("initialized", {}); + + if (this.deps.initialization) { + rpc.sendNotification("workspace/didChangeConfiguration", { + settings: this.deps.initialization, + }); + } + + this.startFileWatcher(); + } + + private startFileWatcher(): void { + const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; + this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { + const changeType = + event.type === "create" + ? FileChangeType.Created + : event.type === "delete" + ? FileChangeType.Deleted + : FileChangeType.Changed; + + const relativePath = event.path.startsWith(rootPrefix) + ? event.path.slice(rootPrefix.length) + : event.path.replace(/^\/+/, ""); + + if (this.watchedFiles.matches(relativePath)) { + this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri: `file://${event.path}`, type: changeType }], + }); + } + }); + } + + async open(filePath: string): Promise { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + try { + const text = await this.deps.fs.readText(filePath); + await this.openWithText(filePath, text); + } catch { + // file may not exist + } + } + + async openWithText(filePath: string, text: string, langId?: string): Promise { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + // If already open, use didChange instead of re-opening. + if (this.openDocuments.has(filePath)) { + await this.change(filePath, text); + return; + } + + const version = 1; + this.openDocuments.set(filePath, { version, text }); + + rpc.sendNotification("textDocument/didOpen", { + textDocument: { + uri: `file://${filePath}`, + languageId: langId ?? resolveLanguageId(filePath), + version, + text, + }, + }); + } + + async change(filePath: string, newText: string): Promise { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + const existing = this.openDocuments.get(filePath); + if (!existing) { + // Not open yet — didOpen instead. + await this.openWithText(filePath, newText); + return; + } + + const version = existing.version + 1; + this.openDocuments.set(filePath, { version, text: newText }); + + if (this.textDocumentChange === 2) { + // Incremental sync — compute the minimal change range. + const changeEvent = computeChangeRange(existing.text, newText); + rpc.sendNotification("textDocument/didChange", { + textDocument: { uri: `file://${filePath}`, version }, + contentChanges: [changeEvent], + }); + } else { + // Full sync — send the entire content. + rpc.sendNotification("textDocument/didChange", { + textDocument: { uri: `file://${filePath}`, version }, + contentChanges: [{ text: newText }], + }); + } + } + + async waitForDiagnostics( + filePath: string, + opts?: { readonly text?: string; readonly timeoutMs?: number; readonly minSeverity?: number }, + ): Promise<{ readonly formatted: string; readonly slow: boolean; readonly timedOut: boolean }> { + const timeoutMs = opts?.timeoutMs ?? 10_000; + const uri = `file://${filePath}`; + + // Clear the "received" flag so we detect fresh publishDiagnostics after our sync. + this.diagnostics.clearReceived(uri); + + // Sync the document: use didChange with the provided text (post-edit buffer) + // or fall back to didOpen reading from disk. + if (opts?.text !== undefined) { + await this.change(filePath, opts.text); + } else { + await this.open(filePath); + } + + const start = Date.now(); + + // Poll until the server pushes diagnostics (even empty = done) or the + // per-server cap elapses (then we skip it — see aggregateDiagnostics). + const received = await new Promise((resolve) => { + const check = () => { + const elapsed = Date.now() - start; + const got = this.diagnostics.hasReceivedPush(uri); + if (got || elapsed >= timeoutMs) { + resolve(got); + return; + } + setTimeout(check, 100); + }; + check(); + }); + + // Only a server that actually pushed can be corruption-checked. + if (received) { + this.detectStaleDiagnostics(uri, opts?.text ?? ""); + } + + // `slow` is structurally false now: the per-server cap is 10s, so + // elapsed can never exceed the old "unusually long" threshold. That + // warning is superseded by the timeout→skip notice produced in + // aggregateDiagnostics. The field is kept for contract compatibility. + return { + formatted: this.diagnostics.formatFiltered(uri, opts?.minSeverity), + slow: false, + timedOut: !received, + }; + } + + getWatchedFilesRegistry(): WatchedFilesRegistry { + return this.watchedFiles; + } + + getDiagnosticsStore(): DiagnosticsStore { + return this.diagnostics; + } + + /** + * Send a request (hover/definition/references/documentSymbol). Capped at + * REQUEST_TIMEOUT_MS so a dead/slow server can't hang the turn — the + * initialize handshake bypasses this (it calls rpc.sendRequest directly + * with its own 45s race). + */ + async request( + method: string, + params?: unknown, + timeoutMs: number = LanguageServerClient.REQUEST_TIMEOUT_MS, + ): Promise { + if (!this.rpc || this.state !== "connected") { + throw new Error("Client not connected"); + } + return this.rpc.sendRequest(method, params, timeoutMs); + } + + shutdown(): void { + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + this.state = "not-started"; + } } function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false; - for (const v of a) { - if (!b.has(v)) return false; - } - return true; + if (a.size !== b.size) return false; + for (const v of a) { + if (!b.has(v)) return false; + } + return true; } diff --git a/packages/lsp/src/config.test.ts b/packages/lsp/src/config.test.ts index 6d51774..0fa27e3 100644 --- a/packages/lsp/src/config.test.ts +++ b/packages/lsp/src/config.test.ts @@ -2,201 +2,201 @@ import { describe, expect, it } from "vitest"; import { resolveServers } from "./config.js"; describe("config", () => { - it("built-in typescript resolves when tsconfig.json exists", async () => { - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: null, - exists: async (path) => path === "/project/tsconfig.json", - }); - - const ts = servers.find((s) => s.id === "typescript"); - expect(ts).toBeDefined(); - expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); - expect(ts?.extensions).toContain(".ts"); - expect(ts?.rootMarkers).toContain("tsconfig.json"); - }); - - it(".dispatch/lsp.json servers resolve", async () => { - const config = JSON.stringify({ - servers: { - mylsp: { - command: ["my-lsp", "--stdio"], - extensions: [".ml"], - rootMarkers: ["Makefile"], - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: config, - opencodeJson: null, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("mylsp"); - expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); - }); - - it("opencode.json lsp is used only as fallback", async () => { - const opencodeConfig = JSON.stringify({ - lsp: { - fallback: { - command: ["fallback-lsp"], - extensions: [".fb"], - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: opencodeConfig, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("fallback"); - }); - - it(".dispatch/lsp.json wins over opencode.json", async () => { - const dispatchConfig = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - const opencodeConfig = JSON.stringify({ - lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatchConfig, - opencodeJson: opencodeConfig, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("primary"); - }); - - it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { - const config = JSON.stringify({ - servers: { - luau: { - command: ["luau-lsp", "lsp"], - extensions: [".luau"], - initialization: { - "luau-lsp": { - sourcemap: { - autogenerate: true, - rojoProjectFile: "default.project.json", - }, - }, - }, - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: config, - opencodeJson: null, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.sidecar).toBeDefined(); - expect(servers[0]?.sidecar?.command).toEqual([ - "rojo", - "sourcemap", - "default.project.json", - "--watch", - "-o", - "sourcemap.json", - ]); - }); - - it("config: resolveServers records configSource", async () => { - // .dispatch/lsp.json entry → ".dispatch/lsp.json" - const dispatch = JSON.stringify({ - servers: { - mylsp: { command: ["my-lsp"], extensions: [".ml"] }, - }, - }); - const fromDispatch = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: null, - exists: async () => false, - }); - expect(fromDispatch.servers[0]?.configSource).toBe(".dispatch/lsp.json"); - - // fallback to opencode.json → "opencode.json" - const opencode = JSON.stringify({ - lsp: { - oc: { command: ["oc-lsp"], extensions: [".oc"] }, - }, - }); - const fromOpencode = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: opencode, - exists: async () => false, - }); - expect(fromOpencode.servers[0]?.configSource).toBe("opencode.json"); - - // built-in TS → "built-in" - const fromBuiltin = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: null, - exists: async () => false, - }); - expect(fromBuiltin.servers[0]?.configSource).toBe("built-in"); - }); - - it("config: shadowed flag is true when .dispatch/lsp.json shadows opencode.json lsp", async () => { - const dispatch = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - const opencode = JSON.stringify({ - lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, - }); - - const { shadowed, servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: opencode, - exists: async () => false, - }); - - // dispatch wins, opencode entry is shadowed - expect(shadowed).toBe(true); - expect(servers.map((s) => s.id)).toEqual(["primary"]); - }); - - it("config: shadowed flag is false when only one config source declares lsp", async () => { - const dispatch = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - - // dispatch present, opencode has NO lsp key → not shadowed - const onlyDispatch = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: JSON.stringify({ lsp: {} }), - exists: async () => false, - }); - expect(onlyDispatch.shadowed).toBe(false); - - // dispatch absent, opencode has lsp → not shadowed (opencode is used) - const onlyOpencode = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: JSON.stringify({ lsp: { fb: { command: ["fb"] } } }), - exists: async () => false, - }); - expect(onlyOpencode.shadowed).toBe(false); - }); + it("built-in typescript resolves when tsconfig.json exists", async () => { + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async (path) => path === "/project/tsconfig.json", + }); + + const ts = servers.find((s) => s.id === "typescript"); + expect(ts).toBeDefined(); + expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); + expect(ts?.extensions).toContain(".ts"); + expect(ts?.rootMarkers).toContain("tsconfig.json"); + }); + + it(".dispatch/lsp.json servers resolve", async () => { + const config = JSON.stringify({ + servers: { + mylsp: { + command: ["my-lsp", "--stdio"], + extensions: [".ml"], + rootMarkers: ["Makefile"], + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("mylsp"); + expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); + }); + + it("opencode.json lsp is used only as fallback", async () => { + const opencodeConfig = JSON.stringify({ + lsp: { + fallback: { + command: ["fallback-lsp"], + extensions: [".fb"], + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("fallback"); + }); + + it(".dispatch/lsp.json wins over opencode.json", async () => { + const dispatchConfig = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencodeConfig = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatchConfig, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("primary"); + }); + + it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { + const config = JSON.stringify({ + servers: { + luau: { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { + "luau-lsp": { + sourcemap: { + autogenerate: true, + rojoProjectFile: "default.project.json", + }, + }, + }, + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.sidecar).toBeDefined(); + expect(servers[0]?.sidecar?.command).toEqual([ + "rojo", + "sourcemap", + "default.project.json", + "--watch", + "-o", + "sourcemap.json", + ]); + }); + + it("config: resolveServers records configSource", async () => { + // .dispatch/lsp.json entry → ".dispatch/lsp.json" + const dispatch = JSON.stringify({ + servers: { + mylsp: { command: ["my-lsp"], extensions: [".ml"] }, + }, + }); + const fromDispatch = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: null, + exists: async () => false, + }); + expect(fromDispatch.servers[0]?.configSource).toBe(".dispatch/lsp.json"); + + // fallback to opencode.json → "opencode.json" + const opencode = JSON.stringify({ + lsp: { + oc: { command: ["oc-lsp"], extensions: [".oc"] }, + }, + }); + const fromOpencode = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencode, + exists: async () => false, + }); + expect(fromOpencode.servers[0]?.configSource).toBe("opencode.json"); + + // built-in TS → "built-in" + const fromBuiltin = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async () => false, + }); + expect(fromBuiltin.servers[0]?.configSource).toBe("built-in"); + }); + + it("config: shadowed flag is true when .dispatch/lsp.json shadows opencode.json lsp", async () => { + const dispatch = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencode = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const { shadowed, servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: opencode, + exists: async () => false, + }); + + // dispatch wins, opencode entry is shadowed + expect(shadowed).toBe(true); + expect(servers.map((s) => s.id)).toEqual(["primary"]); + }); + + it("config: shadowed flag is false when only one config source declares lsp", async () => { + const dispatch = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + + // dispatch present, opencode has NO lsp key → not shadowed + const onlyDispatch = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: JSON.stringify({ lsp: {} }), + exists: async () => false, + }); + expect(onlyDispatch.shadowed).toBe(false); + + // dispatch absent, opencode has lsp → not shadowed (opencode is used) + const onlyOpencode = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: JSON.stringify({ lsp: { fb: { command: ["fb"] } } }), + exists: async () => false, + }); + expect(onlyOpencode.shadowed).toBe(false); + }); }); diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts index afec6bf..09cc9ce 100644 --- a/packages/lsp/src/config.ts +++ b/packages/lsp/src/config.ts @@ -11,22 +11,22 @@ */ export interface ResolvedServer { - readonly id: string; - readonly name: string; - readonly command: readonly string[]; - readonly env?: Readonly> | undefined; - readonly extensions: readonly string[]; - readonly rootMarkers: readonly string[]; - readonly initialization?: Readonly> | undefined; - readonly sidecar?: { readonly command: readonly string[] } | undefined; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"`. Omitted when not yet resolved. Mirrors - * the wire `LspServerInfo.configSource` so config-shadow debugging surfaces - * to the status caller (a broken `.dispatch/lsp.json` silently shadowing - * `opencode.json`). - */ - readonly configSource?: string | undefined; + readonly id: string; + readonly name: string; + readonly command: readonly string[]; + readonly env?: Readonly> | undefined; + readonly extensions: readonly string[]; + readonly rootMarkers: readonly string[]; + readonly initialization?: Readonly> | undefined; + readonly sidecar?: { readonly command: readonly string[] } | undefined; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"`. Omitted when not yet resolved. Mirrors + * the wire `LspServerInfo.configSource` so config-shadow debugging surfaces + * to the status caller (a broken `.dispatch/lsp.json` silently shadowing + * `opencode.json`). + */ + readonly configSource?: string | undefined; } /** Which config source a resolved server came from. */ @@ -35,153 +35,153 @@ export type ConfigSource = ".dispatch/lsp.json" | "opencode.json" | "built-in"; /** Result of resolving servers: the servers + whether `opencode.json`'s lsp key * was silently shadowed by a present `.dispatch/lsp.json`. */ export interface ResolveResult { - readonly servers: readonly ResolvedServer[]; - readonly shadowed: boolean; + readonly servers: readonly ResolvedServer[]; + readonly shadowed: boolean; } export interface ServerConfig { - readonly id?: string | undefined; - readonly name?: string | undefined; - readonly command: readonly string[]; - readonly env?: Readonly> | undefined; - readonly extensions?: readonly string[] | undefined; - readonly rootMarkers?: readonly string[] | undefined; - readonly initialization?: Readonly> | undefined; - readonly watch?: readonly string[] | undefined; + readonly id?: string | undefined; + readonly name?: string | undefined; + readonly command: readonly string[]; + readonly env?: Readonly> | undefined; + readonly extensions?: readonly string[] | undefined; + readonly rootMarkers?: readonly string[] | undefined; + readonly initialization?: Readonly> | undefined; + readonly watch?: readonly string[] | undefined; } export interface LspJsonConfig { - readonly servers?: Readonly> | undefined; + readonly servers?: Readonly> | undefined; } export interface OpencodeJsonConfig { - readonly lsp?: Readonly> | undefined; + readonly lsp?: Readonly> | undefined; } export interface ResolveServersDeps { - readonly cwd: string; - readonly dispatchLspJson: string | null; - readonly opencodeJson: string | null; - readonly exists: (path: string) => Promise; + readonly cwd: string; + readonly dispatchLspJson: string | null; + readonly opencodeJson: string | null; + readonly exists: (path: string) => Promise; } const BUILT_IN_REGISTRY: Record = { - typescript: { - id: "typescript", - name: "TypeScript Language Server", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], - rootMarkers: ["tsconfig.json", "package.json"], - configSource: "built-in", - }, + typescript: { + id: "typescript", + name: "TypeScript Language Server", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + rootMarkers: ["tsconfig.json", "package.json"], + configSource: "built-in", + }, }; export async function resolveServers(deps: ResolveServersDeps): Promise { - const result = new Map(); - - // Parse opencode.json once — used both as the fallback source and to detect - // whether a present `.dispatch/lsp.json` silently shadows its `lsp` key. - let opencodeConfig: OpencodeJsonConfig | null = null; - if (deps.opencodeJson) { - try { - opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; - } catch { - // ignore parse errors - } - } - const opencodeHasLsp = !!opencodeConfig?.lsp && Object.keys(opencodeConfig.lsp).length > 0; - - // 1. cwd/.dispatch/lsp.json (highest precedence) - let dispatchHadServers = false; - if (deps.dispatchLspJson) { - try { - const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; - if (config.servers) { - for (const [key, server] of Object.entries(config.servers)) { - const resolved = resolveServer(key, server, ".dispatch/lsp.json"); - result.set(resolved.id, resolved); - } - dispatchHadServers = result.size > 0; - } - } catch { - // ignore parse errors - } - } - - // 2. fallback cwd/opencode.json lsp (only when dispatch yielded nothing) - if (result.size === 0 && opencodeConfig?.lsp) { - for (const [key, server] of Object.entries(opencodeConfig.lsp)) { - const resolved = resolveServer(key, server, "opencode.json"); - result.set(resolved.id, resolved); - } - } - - // 3. the built-in registry (when nothing else resolved) - if (result.size === 0) { - for (const [, server] of Object.entries(BUILT_IN_REGISTRY)) { - result.set(server.id, server); - } - } - - // `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp key when both - // declare servers — the opencode entry is skipped with no warning otherwise. - const shadowed = dispatchHadServers && opencodeHasLsp; - return { servers: [...result.values()], shadowed }; + const result = new Map(); + + // Parse opencode.json once — used both as the fallback source and to detect + // whether a present `.dispatch/lsp.json` silently shadows its `lsp` key. + let opencodeConfig: OpencodeJsonConfig | null = null; + if (deps.opencodeJson) { + try { + opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; + } catch { + // ignore parse errors + } + } + const opencodeHasLsp = !!opencodeConfig?.lsp && Object.keys(opencodeConfig.lsp).length > 0; + + // 1. cwd/.dispatch/lsp.json (highest precedence) + let dispatchHadServers = false; + if (deps.dispatchLspJson) { + try { + const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; + if (config.servers) { + for (const [key, server] of Object.entries(config.servers)) { + const resolved = resolveServer(key, server, ".dispatch/lsp.json"); + result.set(resolved.id, resolved); + } + dispatchHadServers = result.size > 0; + } + } catch { + // ignore parse errors + } + } + + // 2. fallback cwd/opencode.json lsp (only when dispatch yielded nothing) + if (result.size === 0 && opencodeConfig?.lsp) { + for (const [key, server] of Object.entries(opencodeConfig.lsp)) { + const resolved = resolveServer(key, server, "opencode.json"); + result.set(resolved.id, resolved); + } + } + + // 3. the built-in registry (when nothing else resolved) + if (result.size === 0) { + for (const [, server] of Object.entries(BUILT_IN_REGISTRY)) { + result.set(server.id, server); + } + } + + // `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp key when both + // declare servers — the opencode entry is skipped with no warning otherwise. + const shadowed = dispatchHadServers && opencodeHasLsp; + return { servers: [...result.values()], shadowed }; } function resolveServer( - key: string, - config: ServerConfig, - configSource: ConfigSource, + key: string, + config: ServerConfig, + configSource: ConfigSource, ): ResolvedServer { - const id = config.id ?? key; - const name = config.name ?? id; - const extensions = config.extensions ?? []; - const rootMarkers = config.rootMarkers ?? []; - - let sidecar: { readonly command: readonly string[] } | undefined; - if (config.watch) { - sidecar = { command: config.watch }; - } else if (config.initialization) { - sidecar = detectSidecar(config.initialization); - } - - const result: ResolvedServer = { - id, - name, - command: config.command, - extensions, - rootMarkers, - configSource, - }; - if (config.env) { - (result as { env?: Readonly> }).env = config.env; - } - if (config.initialization) { - (result as { initialization?: Readonly> }).initialization = - config.initialization; - } - if (sidecar) { - (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; - } - return result; + const id = config.id ?? key; + const name = config.name ?? id; + const extensions = config.extensions ?? []; + const rootMarkers = config.rootMarkers ?? []; + + let sidecar: { readonly command: readonly string[] } | undefined; + if (config.watch) { + sidecar = { command: config.watch }; + } else if (config.initialization) { + sidecar = detectSidecar(config.initialization); + } + + const result: ResolvedServer = { + id, + name, + command: config.command, + extensions, + rootMarkers, + configSource, + }; + if (config.env) { + (result as { env?: Readonly> }).env = config.env; + } + if (config.initialization) { + (result as { initialization?: Readonly> }).initialization = + config.initialization; + } + if (sidecar) { + (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; + } + return result; } function detectSidecar( - init: Readonly>, + init: Readonly>, ): { readonly command: readonly string[] } | undefined { - const luauLsp = init["luau-lsp"]; - if (!luauLsp || typeof luauLsp !== "object") return undefined; - const luau = luauLsp as Record; - const sourcemap = luau.sourcemap; - if (!sourcemap || typeof sourcemap !== "object") return undefined; - const sm = sourcemap as Record; - if (sm.autogenerate !== true) return undefined; - const rojoProjectFile = sm.rojoProjectFile; - if (typeof rojoProjectFile !== "string") return undefined; - return { - command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], - }; + const luauLsp = init["luau-lsp"]; + if (!luauLsp || typeof luauLsp !== "object") return undefined; + const luau = luauLsp as Record; + const sourcemap = luau.sourcemap; + if (!sourcemap || typeof sourcemap !== "object") return undefined; + const sm = sourcemap as Record; + if (sm.autogenerate !== true) return undefined; + const rojoProjectFile = sm.rojoProjectFile; + if (typeof rojoProjectFile !== "string") return undefined; + return { + command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], + }; } /** @@ -195,20 +195,20 @@ function detectSidecar( * server stays broken until a bounded backoff elapses. */ export function configFingerprint(server: ResolvedServer): string { - return stableStringify(server); + return stableStringify(server); } /** Deterministic JSON: object keys sorted at every nesting level. */ function stableStringify(value: unknown): string { - return JSON.stringify(canonicalize(value)); + return JSON.stringify(canonicalize(value)); } function canonicalize(value: unknown): unknown { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(canonicalize); - const sorted: Record = {}; - for (const key of Object.keys(value as Record).sort()) { - sorted[key] = canonicalize((value as Record)[key]); - } - return sorted; + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(canonicalize); + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = canonicalize((value as Record)[key]); + } + return sorted; } diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts index 9f2b6b4..e72e007 100644 --- a/packages/lsp/src/diagnostics.test.ts +++ b/packages/lsp/src/diagnostics.test.ts @@ -2,50 +2,50 @@ import { describe, expect, it } from "vitest"; import { DiagnosticsStore } from "./diagnostics.js"; describe("diagnostics", () => { - it("formats diagnostics with severity, location, and message", () => { - const store = new DiagnosticsStore(); - store.setPushDiagnostics({ - uri: "file:///test.ts", - diagnostics: [ - { - range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, - severity: 1, - source: "typescript", - message: "Cannot find name 'hello'.", - }, - ], - }); + it("formats diagnostics with severity, location, and message", () => { + const store = new DiagnosticsStore(); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, + severity: 1, + source: "typescript", + message: "Cannot find name 'hello'.", + }, + ], + }); - const formatted = store.format("file:///test.ts"); - expect(formatted).toContain("ERROR"); - expect(formatted).toContain("L1:6"); - expect(formatted).toContain("Cannot find name 'hello'."); - expect(formatted).toContain("[typescript]"); - }); + const formatted = store.format("file:///test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("L1:6"); + expect(formatted).toContain("Cannot find name 'hello'."); + expect(formatted).toContain("[typescript]"); + }); - it("merges push and pull diagnostics with deduplication", () => { - const store = new DiagnosticsStore(); - const diag = { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - severity: 1, - message: "Error", - }; + it("merges push and pull diagnostics with deduplication", () => { + const store = new DiagnosticsStore(); + const diag = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Error", + }; - store.setPushDiagnostics({ - uri: "file:///test.ts", - diagnostics: [diag], - }); - store.setPullDiagnostics("file:///test.ts", { - kind: "full", - items: [diag], - }); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [diag], + }); + store.setPullDiagnostics("file:///test.ts", { + kind: "full", + items: [diag], + }); - const merged = store.getMerged("file:///test.ts"); - expect(merged).toHaveLength(1); - }); + const merged = store.getMerged("file:///test.ts"); + expect(merged).toHaveLength(1); + }); - it("returns empty string when no diagnostics exist", () => { - const store = new DiagnosticsStore(); - expect(store.format("file:///nonexistent.ts")).toBe(""); - }); + it("returns empty string when no diagnostics exist", () => { + const store = new DiagnosticsStore(); + expect(store.format("file:///nonexistent.ts")).toBe(""); + }); }); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts index 50beca9..ccd695f 100644 --- a/packages/lsp/src/diagnostics.ts +++ b/packages/lsp/src/diagnostics.ts @@ -4,107 +4,107 @@ */ export interface Diagnostic { - readonly range: { - readonly start: { readonly line: number; readonly character: number }; - readonly end: { readonly line: number; readonly character: number }; - }; - readonly severity?: number; - readonly source?: string; - readonly message: string; - readonly code?: string | number; + readonly range: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; + readonly severity?: number; + readonly source?: string; + readonly message: string; + readonly code?: string | number; } export interface PublishDiagnosticsParams { - readonly uri: string; - readonly diagnostics: readonly Diagnostic[]; + readonly uri: string; + readonly diagnostics: readonly Diagnostic[]; } export interface DocumentDiagnosticReport { - readonly kind: "full" | "unchanged"; - readonly items?: readonly Diagnostic[]; + readonly kind: "full" | "unchanged"; + readonly items?: readonly Diagnostic[]; } const severityNames: Record = { - 1: "ERROR", - 2: "WARNING", - 3: "INFO", - 4: "HINT", + 1: "ERROR", + 2: "WARNING", + 3: "INFO", + 4: "HINT", }; export class DiagnosticsStore { - private pushDiagnostics = new Map(); - private pullDiagnostics = new Map(); - private pushReceived = new Set(); + private pushDiagnostics = new Map(); + private pullDiagnostics = new Map(); + private pushReceived = new Set(); - setPushDiagnostics(params: PublishDiagnosticsParams): void { - this.pushDiagnostics.set(params.uri, params.diagnostics); - this.pushReceived.add(params.uri); - } + setPushDiagnostics(params: PublishDiagnosticsParams): void { + this.pushDiagnostics.set(params.uri, params.diagnostics); + this.pushReceived.add(params.uri); + } - setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { - if (report.kind === "full" && report.items) { - this.pullDiagnostics.set(uri, report.items); - } - } + setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { + if (report.kind === "full" && report.items) { + this.pullDiagnostics.set(uri, report.items); + } + } - /** True if the server has pushed at least one publishDiagnostics for this URI. */ - hasReceivedPush(uri: string): boolean { - return this.pushReceived.has(uri); - } + /** True if the server has pushed at least one publishDiagnostics for this URI. */ + hasReceivedPush(uri: string): boolean { + return this.pushReceived.has(uri); + } - /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */ - clearReceived(uri: string): void { - this.pushReceived.delete(uri); - } + /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */ + clearReceived(uri: string): void { + this.pushReceived.delete(uri); + } - getMerged(uri: string): readonly Diagnostic[] { - const push = this.pushDiagnostics.get(uri) ?? []; - const pull = this.pullDiagnostics.get(uri) ?? []; - return dedupeDiagnostics([...push, ...pull]); - } + getMerged(uri: string): readonly Diagnostic[] { + const push = this.pushDiagnostics.get(uri) ?? []; + const pull = this.pullDiagnostics.get(uri) ?? []; + return dedupeDiagnostics([...push, ...pull]); + } - /** - * Format diagnostics for a URI, optionally filtering by minimum severity. - * `minSeverity` includes only diagnostics with severity ≤ the given value - * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all. - */ - formatFiltered(uri: string, minSeverity?: number): string { - let diags = this.getMerged(uri); - if (minSeverity !== undefined) { - diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity); - } - if (diags.length === 0) return ""; - const lines: string[] = []; - for (const d of diags) { - const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; - const line = d.range.start.line + 1; - const col = d.range.start.character + 1; - const src = d.source ? ` [${d.source}]` : ""; - const code = d.code ? ` (${d.code})` : ""; - lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); - } - return lines.join("\n"); - } + /** + * Format diagnostics for a URI, optionally filtering by minimum severity. + * `minSeverity` includes only diagnostics with severity ≤ the given value + * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all. + */ + formatFiltered(uri: string, minSeverity?: number): string { + let diags = this.getMerged(uri); + if (minSeverity !== undefined) { + diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity); + } + if (diags.length === 0) return ""; + const lines: string[] = []; + for (const d of diags) { + const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; + const line = d.range.start.line + 1; + const col = d.range.start.character + 1; + const src = d.source ? ` [${d.source}]` : ""; + const code = d.code ? ` (${d.code})` : ""; + lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); + } + return lines.join("\n"); + } - format(uri: string): string { - return this.formatFiltered(uri); - } + format(uri: string): string { + return this.formatFiltered(uri); + } } export function diagnosticKey(d: Diagnostic): string { - const r = d.range; - return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; + const r = d.range; + return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; } function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] { - const seen = new Set(); - const result: Diagnostic[] = []; - for (const d of diags) { - const key = diagnosticKey(d); - if (!seen.has(key)) { - seen.add(key); - result.push(d); - } - } - return result; + const seen = new Set(); + const result: Diagnostic[] = []; + for (const d of diags) { + const key = diagnosticKey(d); + if (!seen.has(key)) { + seen.add(key); + result.push(d); + } + } + return result; } diff --git a/packages/lsp/src/diff.test.ts b/packages/lsp/src/diff.test.ts index b5b6a7b..add3c64 100644 --- a/packages/lsp/src/diff.test.ts +++ b/packages/lsp/src/diff.test.ts @@ -2,116 +2,116 @@ import { describe, expect, it } from "vitest"; import { computeChangeRange, offsetToPosition } from "./diff.js"; describe("offsetToPosition", () => { - it("returns 0:0 for offset 0", () => { - expect(offsetToPosition("hello", 0)).toEqual({ line: 0, character: 0 }); - }); + it("returns 0:0 for offset 0", () => { + expect(offsetToPosition("hello", 0)).toEqual({ line: 0, character: 0 }); + }); - it("counts characters on the first line", () => { - expect(offsetToPosition("hello", 3)).toEqual({ line: 0, character: 3 }); - }); + it("counts characters on the first line", () => { + expect(offsetToPosition("hello", 3)).toEqual({ line: 0, character: 3 }); + }); - it("resets character count after newline", () => { - expect(offsetToPosition("ab\ncd", 4)).toEqual({ line: 1, character: 1 }); - }); + it("resets character count after newline", () => { + expect(offsetToPosition("ab\ncd", 4)).toEqual({ line: 1, character: 1 }); + }); - it("handles multiple lines", () => { - expect(offsetToPosition("a\nb\nc", 4)).toEqual({ line: 2, character: 0 }); - }); + it("handles multiple lines", () => { + expect(offsetToPosition("a\nb\nc", 4)).toEqual({ line: 2, character: 0 }); + }); - it("clamps offset beyond text length", () => { - expect(offsetToPosition("ab", 100)).toEqual({ line: 0, character: 2 }); - }); + it("clamps offset beyond text length", () => { + expect(offsetToPosition("ab", 100)).toEqual({ line: 0, character: 2 }); + }); - it("handles empty string", () => { - expect(offsetToPosition("", 0)).toEqual({ line: 0, character: 0 }); - }); + it("handles empty string", () => { + expect(offsetToPosition("", 0)).toEqual({ line: 0, character: 0 }); + }); }); describe("computeChangeRange", () => { - it("detects a single-line insertion", () => { - const oldText = "hello world"; - const newText = "hello cruel world"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 6 }); - expect(change.text).toBe("cruel "); - }); + it("detects a single-line insertion", () => { + const oldText = "hello world"; + const newText = "hello cruel world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 6 }); + expect(change.text).toBe("cruel "); + }); - it("detects a single-line deletion", () => { - const oldText = "hello cruel world"; - const newText = "hello world"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 12 }); - expect(change.text).toBe(""); - }); + it("detects a single-line deletion", () => { + const oldText = "hello cruel world"; + const newText = "hello world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 12 }); + expect(change.text).toBe(""); + }); - it("detects a single-line replacement", () => { - const oldText = "hello world"; - const newText = "hello earth"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 11 }); - expect(change.text).toBe("earth"); - }); + it("detects a single-line replacement", () => { + const oldText = "hello world"; + const newText = "hello earth"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 11 }); + expect(change.text).toBe("earth"); + }); - it("handles multi-line changes with correct line positions", () => { - const oldText = "line1\nline2\nline3"; - const newText = "line1\nCHANGED\nline3"; - const change = computeChangeRange(oldText, newText); - // Common prefix: "line1\n" → start at beginning of line 1 - expect(change.range.start).toEqual({ line: 1, character: 0 }); - // Common suffix: "\nline3" → end after "line2" on line 1 - expect(change.range.end).toEqual({ line: 1, character: 5 }); - expect(change.text).toBe("CHANGED"); - }); + it("handles multi-line changes with correct line positions", () => { + const oldText = "line1\nline2\nline3"; + const newText = "line1\nCHANGED\nline3"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "line1\n" → start at beginning of line 1 + expect(change.range.start).toEqual({ line: 1, character: 0 }); + // Common suffix: "\nline3" → end after "line2" on line 1 + expect(change.range.end).toEqual({ line: 1, character: 5 }); + expect(change.text).toBe("CHANGED"); + }); - it("handles insertion at end of file", () => { - const oldText = "abc"; - const newText = "abcdef"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 3 }); - expect(change.range.end).toEqual({ line: 0, character: 3 }); - expect(change.text).toBe("def"); - }); + it("handles insertion at end of file", () => { + const oldText = "abc"; + const newText = "abcdef"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 3 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("def"); + }); - it("handles complete file replacement (no common prefix/suffix)", () => { - const oldText = "abc"; - const newText = "xyz"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 0 }); - expect(change.range.end).toEqual({ line: 0, character: 3 }); - expect(change.text).toBe("xyz"); - }); + it("handles complete file replacement (no common prefix/suffix)", () => { + const oldText = "abc"; + const newText = "xyz"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("xyz"); + }); - it("handles empty old text (new file)", () => { - const oldText = ""; - const newText = "hello\nworld"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 0 }); - expect(change.range.end).toEqual({ line: 0, character: 0 }); - expect(change.text).toBe("hello\nworld"); - }); + it("handles empty old text (new file)", () => { + const oldText = ""; + const newText = "hello\nworld"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 0 }); + expect(change.text).toBe("hello\nworld"); + }); - it("handles identical text (no change)", () => { - const oldText = "same text"; - const newText = "same text"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 9 }); - expect(change.range.end).toEqual({ line: 0, character: 9 }); - expect(change.text).toBe(""); - }); + it("handles identical text (no change)", () => { + const oldText = "same text"; + const newText = "same text"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 9 }); + expect(change.range.end).toEqual({ line: 0, character: 9 }); + expect(change.text).toBe(""); + }); - it("handles change spanning multiple lines", () => { - const oldText = "function foo() {\n return 1;\n}\n"; - const newText = "function foo() {\n return 2;\n console.log('hi');\n}\n"; - const change = computeChangeRange(oldText, newText); - // Common prefix: "function foo() {\n return " (26 chars) - // The change starts at "1" on line 1, character 9 - expect(change.range.start).toEqual({ line: 1, character: 9 }); - // Common suffix: ";\n}\n" (4 chars) → end at offset 27 (the ";") - // offset 27 = line 1, char 10 (after " return 1") - expect(change.range.end).toEqual({ line: 1, character: 10 }); - expect(change.text).toBe("2;\n console.log('hi')"); - }); + it("handles change spanning multiple lines", () => { + const oldText = "function foo() {\n return 1;\n}\n"; + const newText = "function foo() {\n return 2;\n console.log('hi');\n}\n"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "function foo() {\n return " (26 chars) + // The change starts at "1" on line 1, character 9 + expect(change.range.start).toEqual({ line: 1, character: 9 }); + // Common suffix: ";\n}\n" (4 chars) → end at offset 27 (the ";") + // offset 27 = line 1, char 10 (after " return 1") + expect(change.range.end).toEqual({ line: 1, character: 10 }); + expect(change.text).toBe("2;\n console.log('hi')"); + }); }); diff --git a/packages/lsp/src/diff.ts b/packages/lsp/src/diff.ts index ab40b36..1bf6048 100644 --- a/packages/lsp/src/diff.ts +++ b/packages/lsp/src/diff.ts @@ -7,16 +7,16 @@ */ export interface Position { - readonly line: number; // 0-based - readonly character: number; // 0-based + readonly line: number; // 0-based + readonly character: number; // 0-based } export interface TextDocumentContentChangeEvent { - readonly range: { - readonly start: Position; - readonly end: Position; - }; - readonly text: string; + readonly range: { + readonly start: Position; + readonly end: Position; + }; + readonly text: string; } /** @@ -29,40 +29,40 @@ export interface TextDocumentContentChangeEvent { * the portion of `newText` between the prefix and suffix. */ export function computeChangeRange( - oldText: string, - newText: string, + oldText: string, + newText: string, ): TextDocumentContentChangeEvent { - const minLen = Math.min(oldText.length, newText.length); + const minLen = Math.min(oldText.length, newText.length); - // Longest common prefix - let prefixLen = 0; - while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { - prefixLen++; - } + // Longest common prefix + let prefixLen = 0; + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } - // Longest common suffix (must not overlap with prefix) - const oldRemaining = oldText.length - prefixLen; - const newRemaining = newText.length - prefixLen; - const maxSuffix = Math.min(oldRemaining, newRemaining); - let suffixLen = 0; - while ( - suffixLen < maxSuffix && - oldText[oldText.length - 1 - suffixLen] === newText[newText.length - 1 - suffixLen] - ) { - suffixLen++; - } + // Longest common suffix (must not overlap with prefix) + const oldRemaining = oldText.length - prefixLen; + const newRemaining = newText.length - prefixLen; + const maxSuffix = Math.min(oldRemaining, newRemaining); + let suffixLen = 0; + while ( + suffixLen < maxSuffix && + oldText[oldText.length - 1 - suffixLen] === newText[newText.length - 1 - suffixLen] + ) { + suffixLen++; + } - const startOffset = prefixLen; - const endOffset = oldText.length - suffixLen; - const replacementText = newText.slice(prefixLen, newText.length - suffixLen); + const startOffset = prefixLen; + const endOffset = oldText.length - suffixLen; + const replacementText = newText.slice(prefixLen, newText.length - suffixLen); - return { - range: { - start: offsetToPosition(oldText, startOffset), - end: offsetToPosition(oldText, endOffset), - }, - text: replacementText, - }; + return { + range: { + start: offsetToPosition(oldText, startOffset), + end: offsetToPosition(oldText, endOffset), + }, + text: replacementText, + }; } /** @@ -70,16 +70,16 @@ export function computeChangeRange( * (0-based line and character). Scans for newlines up to the offset. */ export function offsetToPosition(text: string, offset: number): Position { - let line = 0; - let character = 0; - const limit = Math.min(offset, text.length); - for (let i = 0; i < limit; i++) { - if (text[i] === "\n") { - line++; - character = 0; - } else { - character++; - } - } - return { line, character }; + let line = 0; + let character = 0; + const limit = Math.min(offset, text.length); + for (let i = 0; i < limit; i++) { + if (text[i] === "\n") { + line++; + character = 0; + } else { + character++; + } + } + return { line, character }; } diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts index c0fee44..b4eb71b 100644 --- a/packages/lsp/src/extension.ts +++ b/packages/lsp/src/extension.ts @@ -13,156 +13,156 @@ import type { SpawnedProcess } from "./client.js"; import { LspManager } from "./manager.js"; import { createLspTool } from "./tool.js"; import type { - DiagnosticsResult, - GetDiagnosticsOpts, - LspServerStatus, - LspService, + DiagnosticsResult, + GetDiagnosticsOpts, + LspServerStatus, + LspService, } from "./types.js"; export const lspServiceHandle: ServiceHandle = defineService("lsp"); function realSpawn( - command: string[], - opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, ): SpawnedProcess { - const env: Record = { ...process.env }; - if (opts.env) { - for (const [key, value] of Object.entries(opts.env)) { - env[key] = value; - } - } - const proc = Bun.spawn(command, { - cwd: opts.cwd, - env: env as Record, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); - return { - stdin: proc.stdin, - stdout: proc.stdout, - stderr: proc.stderr, - pid: proc.pid, - kill: () => proc.kill(), - // Surface process exit so the client can stop querying a dead server - // and self-heal (respawn). Bun's Subprocess.exited resolves with the - // exit code (or rejects if killed by signal — treat as code:null). - onExit: (handler) => { - (proc as { exited: Promise }).exited - .then((code) => handler({ code })) - .catch(() => handler({ code: null })); - }, - }; + const env: Record = { ...process.env }; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + env[key] = value; + } + } + const proc = Bun.spawn(command, { + cwd: opts.cwd, + env: env as Record, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid, + kill: () => proc.kill(), + // Surface process exit so the client can stop querying a dead server + // and self-heal (respawn). Bun's Subprocess.exited resolves with the + // exit code (or rejects if killed by signal — treat as code:null). + onExit: (handler) => { + (proc as { exited: Promise }).exited + .then((code) => handler({ code })) + .catch(() => handler({ code: null })); + }, + }; } function realFileWatcher( - root: string, - onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, ): { readonly close: () => void } { - const { watch } = require("node:fs"); - const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { - if (!filename) return; - const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; - const type = eventType === "rename" ? "create" : "change"; - onEvent({ type, path: fullPath }); - }); - return { close: () => watcher.close() }; + const { watch } = require("node:fs"); + const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { + if (!filename) return; + const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; + const type = eventType === "rename" ? "create" : "change"; + onEvent({ type, path: fullPath }); + }); + return { close: () => watcher.close() }; } function realFs() { - return { - readText: async (path: string) => { - const file = Bun.file(path); - return file.text(); - }, - exists: async (path: string) => { - const file = Bun.file(path); - return file.exists(); - }, - }; + return { + readText: async (path: string) => { + const file = Bun.file(path); + return file.text(); + }, + exists: async (path: string) => { + const file = Bun.file(path); + return file.exists(); + }, + }; } export const extension: Extension = { - manifest: { - id: "lsp", - name: "Language Server Protocol", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { spawn: true, fs: true }, - contributes: { tools: ["lsp"], services: ["lsp"] }, - }, - activate(host: HostAPI) { - const logger = host.logger; - - const manager = new LspManager({ - spawn: realSpawn, - fileWatcher: realFileWatcher, - fs: realFs(), - logger: { - info: (msg, attrs) => - logger.info(msg, attrs as Record | undefined), - warn: (msg, attrs) => - logger.warn(msg, attrs as Record | undefined), - error: (msg, attrs) => logger.error(msg, attrs as Record | undefined), - }, - }); - - const lspTool = createLspTool(manager); - host.defineTool(lspTool); - - const service: LspService = { - async status(cwd: string): Promise { - return manager.status(cwd); - }, - async getDiagnostics(opts: GetDiagnosticsOpts): Promise { - // 10s hard ceiling per server, regardless of what the caller - // passes (the edit hook still passes 60_000 — clamped here, so - // no other-unit edit is needed). A server that doesn't respond - // in 10s is skipped with a notice instead of waited out. - const PER_SERVER_CAP_MS = 10_000; - const timeoutMs = Math.min(opts.timeoutMs ?? PER_SERVER_CAP_MS, PER_SERVER_CAP_MS); - const fileExt = extname(opts.filePath).toLowerCase(); - const absolutePath = opts.filePath.startsWith("/") - ? opts.filePath - : join(opts.cwd, opts.filePath); - - // Get all connected servers matching this file's extension. - // A dead/corrupted server has state:"error" and is excluded — - // no per-edit hang on a corpse. - const statuses = await manager.status(opts.cwd); - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); - - if (matching.length === 0) { - return { formatted: "", slow: false, timedOut: false }; - } - - const agg = await aggregateDiagnostics( - (id, root) => manager.getClient(id, root), - matching, - absolutePath, - timeoutMs, - { text: opts.text, minSeverity: opts.minSeverity }, - ); - - return { formatted: agg.formatted, slow: false, timedOut: agg.timedOut }; - }, - }; - host.provideService(lspServiceHandle, service); - - host.logger.info("LSP extension activated"); - - // Store manager for deactivate - (lspManagerStore as { manager: LspManager | null }).manager = manager; - }, - deactivate() { - const store = lspManagerStore as { manager: LspManager | null }; - store.manager?.shutdownAll(); - store.manager = null; - }, + manifest: { + id: "lsp", + name: "Language Server Protocol", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { spawn: true, fs: true }, + contributes: { tools: ["lsp"], services: ["lsp"] }, + }, + activate(host: HostAPI) { + const logger = host.logger; + + const manager = new LspManager({ + spawn: realSpawn, + fileWatcher: realFileWatcher, + fs: realFs(), + logger: { + info: (msg, attrs) => + logger.info(msg, attrs as Record | undefined), + warn: (msg, attrs) => + logger.warn(msg, attrs as Record | undefined), + error: (msg, attrs) => logger.error(msg, attrs as Record | undefined), + }, + }); + + const lspTool = createLspTool(manager); + host.defineTool(lspTool); + + const service: LspService = { + async status(cwd: string): Promise { + return manager.status(cwd); + }, + async getDiagnostics(opts: GetDiagnosticsOpts): Promise { + // 10s hard ceiling per server, regardless of what the caller + // passes (the edit hook still passes 60_000 — clamped here, so + // no other-unit edit is needed). A server that doesn't respond + // in 10s is skipped with a notice instead of waited out. + const PER_SERVER_CAP_MS = 10_000; + const timeoutMs = Math.min(opts.timeoutMs ?? PER_SERVER_CAP_MS, PER_SERVER_CAP_MS); + const fileExt = extname(opts.filePath).toLowerCase(); + const absolutePath = opts.filePath.startsWith("/") + ? opts.filePath + : join(opts.cwd, opts.filePath); + + // Get all connected servers matching this file's extension. + // A dead/corrupted server has state:"error" and is excluded — + // no per-edit hang on a corpse. + const statuses = await manager.status(opts.cwd); + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + + if (matching.length === 0) { + return { formatted: "", slow: false, timedOut: false }; + } + + const agg = await aggregateDiagnostics( + (id, root) => manager.getClient(id, root), + matching, + absolutePath, + timeoutMs, + { text: opts.text, minSeverity: opts.minSeverity }, + ); + + return { formatted: agg.formatted, slow: false, timedOut: agg.timedOut }; + }, + }; + host.provideService(lspServiceHandle, service); + + host.logger.info("LSP extension activated"); + + // Store manager for deactivate + (lspManagerStore as { manager: LspManager | null }).manager = manager; + }, + deactivate() { + const store = lspManagerStore as { manager: LspManager | null }; + store.manager?.shutdownAll(); + store.manager = null; + }, }; // Module-scoped store for deactivate diff --git a/packages/lsp/src/framing.test.ts b/packages/lsp/src/framing.test.ts index 721665c..cea7b78 100644 --- a/packages/lsp/src/framing.test.ts +++ b/packages/lsp/src/framing.test.ts @@ -2,141 +2,141 @@ import { describe, expect, it } from "vitest"; import { encode, FrameDecoder } from "./framing.js"; describe("framing", () => { - it("encode/decode round-trips", () => { - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); - const encoded = encode(msg); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("decoder reassembles a frame split across two chunks", () => { - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); - const encoded = encode(msg); - const mid = Math.floor(encoded.length / 2); - const chunk1 = encoded.slice(0, mid); - const chunk2 = encoded.slice(mid); - - const decoder = new FrameDecoder(); - const result1 = decoder.decode(chunk1); - expect(result1).toHaveLength(0); - - const result2 = decoder.decode(chunk2); - expect(result2).toHaveLength(1); - expect(result2[0]).toBe(msg); - }); - - it("decoder yields two messages from one chunk", () => { - const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); - const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toHaveLength(2); - expect(messages[0]).toBe(msg1); - expect(messages[1]).toBe(msg2); - }); + it("encode/decode round-trips", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("decoder reassembles a frame split across two chunks", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const chunk1 = encoded.slice(0, mid); + const chunk2 = encoded.slice(mid); + + const decoder = new FrameDecoder(); + const result1 = decoder.decode(chunk1); + expect(result1).toHaveLength(0); + + const result2 = decoder.decode(chunk2); + expect(result2).toHaveLength(1); + expect(result2[0]).toBe(msg); + }); + + it("decoder yields two messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); }); describe("multi-byte UTF-8", () => { - it("round-trips a message with multi-byte characters", () => { - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { message: "Type '漢字' is not assignable to type 'number'. 🚫" }, - }); - const encoded = encode(msg); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("reassembles a multi-byte message split at a character boundary", () => { - // A message whose JSON body contains 3-byte UTF-8 characters (漢字). - // We split the encoded frame so the boundary falls INSIDE a multi-byte - // sequence — the old string-based decoder would corrupt this. - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: { text: "漢字テスト" }, - }); - const encoded = encode(msg); - - // Find a split point inside the body (skip the ASCII header). - const headerEnd = encoded.indexOf(0x0d, 0); // first \r - const bodyStart = headerEnd + 4; // skip \r\n\r\n - // Split in the middle of the body — likely inside a multi-byte char. - const splitPoint = bodyStart + Math.floor((encoded.length - bodyStart) / 2); - const chunk1 = encoded.slice(0, splitPoint); - const chunk2 = encoded.slice(splitPoint); - - const decoder = new FrameDecoder(); - expect(decoder.decode(chunk1)).toHaveLength(0); // incomplete - const result = decoder.decode(chunk2); - expect(result).toHaveLength(1); - expect(result[0]).toBe(msg); - }); - - it("handles Content-Length in bytes (not characters)", () => { - // Content-Length counts bytes. For multi-byte content, byte length - // > character length. The decoder must slice by bytes, not chars. - const unicode = "🎉分段測試"; - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { text: unicode } }); - const encoded = encode(msg); - - // Verify the Content-Length header matches the byte length of the body. - const headerStr = new TextDecoder().decode(encoded.slice(0, encoded.indexOf(0x0d))); - const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(headerStr); - expect(contentLengthMatch).not.toBeNull(); - const declaredLength = Number.parseInt(contentLengthMatch?.[1], 10); - const bodyBytes = new TextEncoder().encode(msg); - expect(declaredLength).toBe(bodyBytes.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("reassembles two multi-byte messages from one chunk", () => { - const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a", params: { t: "日本語" } }); - const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b", params: { t: "한국어" } }); - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toHaveLength(2); - expect(messages[0]).toBe(msg1); - expect(messages[1]).toBe(msg2); - }); - - it("reassembles a multi-byte message split across three chunks", () => { - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: { text: "𝕳𝖊𝖑𝖑𝖔, 世界! Привет! 🌍" }, - }); - const encoded = encode(msg); - - const third = Math.floor(encoded.length / 3); - const decoder = new FrameDecoder(); - expect(decoder.decode(encoded.slice(0, third))).toHaveLength(0); - expect(decoder.decode(encoded.slice(third, third * 2))).toHaveLength(0); - const result = decoder.decode(encoded.slice(third * 2)); - expect(result).toHaveLength(1); - expect(result[0]).toBe(msg); - }); + it("round-trips a message with multi-byte characters", () => { + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { message: "Type '漢字' is not assignable to type 'number'. 🚫" }, + }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("reassembles a multi-byte message split at a character boundary", () => { + // A message whose JSON body contains 3-byte UTF-8 characters (漢字). + // We split the encoded frame so the boundary falls INSIDE a multi-byte + // sequence — the old string-based decoder would corrupt this. + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: { text: "漢字テスト" }, + }); + const encoded = encode(msg); + + // Find a split point inside the body (skip the ASCII header). + const headerEnd = encoded.indexOf(0x0d, 0); // first \r + const bodyStart = headerEnd + 4; // skip \r\n\r\n + // Split in the middle of the body — likely inside a multi-byte char. + const splitPoint = bodyStart + Math.floor((encoded.length - bodyStart) / 2); + const chunk1 = encoded.slice(0, splitPoint); + const chunk2 = encoded.slice(splitPoint); + + const decoder = new FrameDecoder(); + expect(decoder.decode(chunk1)).toHaveLength(0); // incomplete + const result = decoder.decode(chunk2); + expect(result).toHaveLength(1); + expect(result[0]).toBe(msg); + }); + + it("handles Content-Length in bytes (not characters)", () => { + // Content-Length counts bytes. For multi-byte content, byte length + // > character length. The decoder must slice by bytes, not chars. + const unicode = "🎉分段測試"; + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { text: unicode } }); + const encoded = encode(msg); + + // Verify the Content-Length header matches the byte length of the body. + const headerStr = new TextDecoder().decode(encoded.slice(0, encoded.indexOf(0x0d))); + const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(headerStr); + expect(contentLengthMatch).not.toBeNull(); + const declaredLength = Number.parseInt(contentLengthMatch?.[1], 10); + const bodyBytes = new TextEncoder().encode(msg); + expect(declaredLength).toBe(bodyBytes.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("reassembles two multi-byte messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a", params: { t: "日本語" } }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b", params: { t: "한국어" } }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); + + it("reassembles a multi-byte message split across three chunks", () => { + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: { text: "𝕳𝖊𝖑𝖑𝖔, 世界! Привет! 🌍" }, + }); + const encoded = encode(msg); + + const third = Math.floor(encoded.length / 3); + const decoder = new FrameDecoder(); + expect(decoder.decode(encoded.slice(0, third))).toHaveLength(0); + expect(decoder.decode(encoded.slice(third, third * 2))).toHaveLength(0); + const result = decoder.decode(encoded.slice(third * 2)); + expect(result).toHaveLength(1); + expect(result[0]).toBe(msg); + }); }); diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts index 88e60c1..b86fcbf 100644 --- a/packages/lsp/src/framing.ts +++ b/packages/lsp/src/framing.ts @@ -16,13 +16,13 @@ const LF = 0x0a; // \n const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i; export function encode(msg: string): Uint8Array { - const body = new TextEncoder().encode(msg); - const header = `Content-Length: ${body.length}\r\n\r\n`; - const frame = new TextEncoder().encode(header); - const result = new Uint8Array(frame.length + body.length); - result.set(frame); - result.set(body, frame.length); - return result; + const body = new TextEncoder().encode(msg); + const header = `Content-Length: ${body.length}\r\n\r\n`; + const frame = new TextEncoder().encode(header); + const result = new Uint8Array(frame.length + body.length); + result.set(frame); + result.set(body, frame.length); + return result; } /** @@ -30,65 +30,65 @@ export function encode(msg: string): Uint8Array { * starting at offset `from`. Returns the index of the first byte, or -1. */ function findHeaderSep(buf: Uint8Array, from: number): number { - const limit = buf.length - 3; - for (let i = from; i < limit; i++) { - if (buf[i] === CR && buf[i + 1] === LF && buf[i + 2] === CR && buf[i + 3] === LF) { - return i; - } - } - return -1; + const limit = buf.length - 3; + for (let i = from; i < limit; i++) { + if (buf[i] === CR && buf[i + 1] === LF && buf[i + 2] === CR && buf[i + 3] === LF) { + return i; + } + } + return -1; } export class FrameDecoder { - private buffer: Uint8Array = new Uint8Array(0); - private expectedLength: number | null = null; - private headerEndByte = -1; + private buffer: Uint8Array = new Uint8Array(0); + private expectedLength: number | null = null; + private headerEndByte = -1; - /** - * Feed raw bytes into the decoder. Returns all complete JSON messages - * that can be extracted from the accumulated buffer. - */ - decode(chunk: Uint8Array): string[] { - // Append the new chunk to the internal byte buffer. - const newBuf = new Uint8Array(this.buffer.length + chunk.length); - newBuf.set(this.buffer); - newBuf.set(chunk, this.buffer.length); - this.buffer = newBuf; + /** + * Feed raw bytes into the decoder. Returns all complete JSON messages + * that can be extracted from the accumulated buffer. + */ + decode(chunk: Uint8Array): string[] { + // Append the new chunk to the internal byte buffer. + const newBuf = new Uint8Array(this.buffer.length + chunk.length); + newBuf.set(this.buffer); + newBuf.set(chunk, this.buffer.length); + this.buffer = newBuf; - const messages: string[] = []; + const messages: string[] = []; - while (true) { - if (this.expectedLength === null) { - const sepIdx = findHeaderSep(this.buffer, 0); - if (sepIdx === -1) break; + while (true) { + if (this.expectedLength === null) { + const sepIdx = findHeaderSep(this.buffer, 0); + if (sepIdx === -1) break; - // Decode only the header bytes (always ASCII) to read Content-Length. - const headerStr = new TextDecoder().decode(this.buffer.slice(0, sepIdx)); - const match = CONTENT_LENGTH_RE.exec(headerStr); - if (!match?.[1]) { - // Not a Content-Length header — skip past this separator and retry. - this.buffer = this.buffer.slice(sepIdx + 4); - continue; - } - this.expectedLength = Number.parseInt(match[1], 10); - this.headerEndByte = sepIdx + 4; // skip \r\n\r\n - } + // Decode only the header bytes (always ASCII) to read Content-Length. + const headerStr = new TextDecoder().decode(this.buffer.slice(0, sepIdx)); + const match = CONTENT_LENGTH_RE.exec(headerStr); + if (!match?.[1]) { + // Not a Content-Length header — skip past this separator and retry. + this.buffer = this.buffer.slice(sepIdx + 4); + continue; + } + this.expectedLength = Number.parseInt(match[1], 10); + this.headerEndByte = sepIdx + 4; // skip \r\n\r\n + } - const bodyStart = this.headerEndByte; - const available = this.buffer.length - bodyStart; + const bodyStart = this.headerEndByte; + const available = this.buffer.length - bodyStart; - if (available >= this.expectedLength) { - // Extract exactly `expectedLength` bytes (Content-Length is in bytes). - const bodyBytes = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); - messages.push(new TextDecoder().decode(bodyBytes)); - this.buffer = this.buffer.slice(bodyStart + this.expectedLength); - this.expectedLength = null; - this.headerEndByte = -1; - } else { - break; - } - } + if (available >= this.expectedLength) { + // Extract exactly `expectedLength` bytes (Content-Length is in bytes). + const bodyBytes = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); + messages.push(new TextDecoder().decode(bodyBytes)); + this.buffer = this.buffer.slice(bodyStart + this.expectedLength); + this.expectedLength = null; + this.headerEndByte = -1; + } else { + break; + } + } - return messages; - } + return messages; + } } diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 49be7ae..750aa87 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -1,25 +1,25 @@ export type { - ClientDeps, - FileWatcher, - FileWatcherHandle, - FsAccess, - SpawnedProcess, - SpawnProcess, + ClientDeps, + FileWatcher, + FileWatcherHandle, + FsAccess, + SpawnedProcess, + SpawnProcess, } from "./client.js"; export { LanguageServerClient } from "./client.js"; export type { - ConfigSource, - LspJsonConfig, - OpencodeJsonConfig, - ResolvedServer, - ResolveResult, - ServerConfig, + ConfigSource, + LspJsonConfig, + OpencodeJsonConfig, + ResolvedServer, + ResolveResult, + ServerConfig, } from "./config.js"; export { configFingerprint, resolveServers } from "./config.js"; export type { - Diagnostic, - DocumentDiagnosticReport, - PublishDiagnosticsParams, + Diagnostic, + DocumentDiagnosticReport, + PublishDiagnosticsParams, } from "./diagnostics.js"; export { DiagnosticsStore } from "./diagnostics.js"; export { extension, lspServiceHandle } from "./extension.js"; diff --git a/packages/lsp/src/language.test.ts b/packages/lsp/src/language.test.ts index 3ec4b47..b464774 100644 --- a/packages/lsp/src/language.test.ts +++ b/packages/lsp/src/language.test.ts @@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest"; import { languageId } from "./language.js"; describe("language", () => { - it("maps .ts to typescript", () => { - expect(languageId("file.ts")).toBe("typescript"); - }); + it("maps .ts to typescript", () => { + expect(languageId("file.ts")).toBe("typescript"); + }); - it("maps .tsx to typescriptreact", () => { - expect(languageId("file.tsx")).toBe("typescriptreact"); - }); + it("maps .tsx to typescriptreact", () => { + expect(languageId("file.tsx")).toBe("typescriptreact"); + }); - it("maps .js to javascript", () => { - expect(languageId("file.js")).toBe("javascript"); - }); + it("maps .js to javascript", () => { + expect(languageId("file.js")).toBe("javascript"); + }); - it("maps .luau to luau", () => { - expect(languageId("file.luau")).toBe("luau"); - }); + it("maps .luau to luau", () => { + expect(languageId("file.luau")).toBe("luau"); + }); - it("returns unknown for unrecognized extensions", () => { - expect(languageId("file.xyz")).toBe("unknown"); - }); + it("returns unknown for unrecognized extensions", () => { + expect(languageId("file.xyz")).toBe("unknown"); + }); - it("returns unknown for files without extensions", () => { - expect(languageId("Makefile")).toBe("unknown"); - }); + it("returns unknown for files without extensions", () => { + expect(languageId("Makefile")).toBe("unknown"); + }); }); diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts index a10dbed..cd5b5fd 100644 --- a/packages/lsp/src/language.ts +++ b/packages/lsp/src/language.ts @@ -3,50 +3,50 @@ */ const extensionMap: Record = { - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".js": "javascript", - ".jsx": "javascriptreact", - ".mjs": "javascript", - ".cjs": "javascript", - ".json": "json", - ".lua": "lua", - ".luau": "luau", - ".py": "python", - ".rs": "rust", - ".go": "go", - ".md": "markdown", - ".yaml": "yaml", - ".yml": "yaml", - ".toml": "toml", - ".css": "css", - ".html": "html", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".rb": "ruby", - ".rbs": "ruby", - ".c": "c", - ".h": "c", - ".cpp": "cpp", - ".cc": "cpp", - ".cxx": "cpp", - ".hpp": "cpp", - ".hxx": "cpp", - ".java": "java", - ".kt": "kotlin", - ".swift": "swift", - ".php": "php", - ".cs": "csharp", - ".sql": "sql", - ".dockerfile": "dockerfile", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".lua": "lua", + ".luau": "luau", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".md": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".css": "css", + ".html": "html", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".rb": "ruby", + ".rbs": "ruby", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".php": "php", + ".cs": "csharp", + ".sql": "sql", + ".dockerfile": "dockerfile", }; export function languageId(filePath: string): string { - const dotIdx = filePath.lastIndexOf("."); - if (dotIdx === -1) return "unknown"; - const ext = filePath.slice(dotIdx).toLowerCase(); - return extensionMap[ext] ?? "unknown"; + const dotIdx = filePath.lastIndexOf("."); + if (dotIdx === -1) return "unknown"; + const ext = filePath.slice(dotIdx).toLowerCase(); + return extensionMap[ext] ?? "unknown"; } diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts index d8cba4f..613ded6 100644 --- a/packages/lsp/src/manager.test.ts +++ b/packages/lsp/src/manager.test.ts @@ -4,449 +4,449 @@ import { encode } from "./framing.js"; import { LspManager } from "./manager.js"; function makeAutoHandshakeSpawn(): SpawnProcess { - return () => { - let messageHandler: ((data: Uint8Array) => void) | null = null; - - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - // Parse the message to handle initialize handshake - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - // Send back initialize response - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 5); - } - // Ignore other messages - } catch { - // ignore parse errors - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 12345, - kill: () => {}, - }; - return proc; - }; + return () => { + let messageHandler: ((data: Uint8Array) => void) | null = null; + + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + // Parse the message to handle initialize handshake + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + // Send back initialize response + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 5); + } + // Ignore other messages + } catch { + // ignore parse errors + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 12345, + kill: () => {}, + }; + return proc; + }; } function noopFileWatcher(): FileWatcher { - return () => ({ close: () => {} }); + return () => ({ close: () => {} }); } function fakeFs(files: Record = {}): FsAccess { - return { - readText: async (path) => files[path] ?? "", - exists: async (path) => path in files, - }; + return { + readText: async (path) => files[path] ?? "", + exists: async (path) => path in files, + }; } describe("manager", () => { - it("status(cwd) lazy-spawns matching servers and reports connected", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp", "--stdio"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const statuses = await manager.status("/project"); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.id).toBe("test"); - expect(statuses[0]?.state).toBe("connected"); - expect(statuses[0]?.root).toBe("/project"); - }, 10000); - - it("concurrent status for the same root spawns one process", async () => { - let spawnCount = 0; - const countingSpawn: SpawnProcess = () => { - spawnCount++; - return makeAutoHandshakeSpawn()([], { cwd: "/project" }); - }; - - const manager = new LspManager({ - spawn: countingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); - - expect(s1).toHaveLength(1); - expect(s2).toHaveLength(1); - expect(spawnCount).toBe(1); - }, 10000); - - it("a failing spawn reports state:error and is not retried", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - - // Second call should not retry - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - }); - - it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { - let killed = false; - const trackableSpawn: SpawnProcess = () => { - const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); - return { - ...proc, - kill: () => { - killed = true; - }, - }; - }; - - const manager = new LspManager({ - spawn: trackableSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - await manager.status("/project"); - manager.shutdownAll(); - expect(killed).toBe(true); - }, 10000); - - it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/proj-a/.dispatch/lsp.json": JSON.stringify({ - servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, - }), - "/proj-b/opencode.json": JSON.stringify({ - lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, - }), - }), - }); - - const a = await manager.status("/proj-a"); - const b = await manager.status("/proj-b"); - expect(a.map((s) => s.id)).toEqual(["a"]); - expect(b.map((s) => s.id)).toEqual(["b"]); - }, 10000); - - it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { - const files: Record = { - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["bad-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }; - - const spawn: SpawnProcess = (command, opts) => { - if (command[0] === "bad-lsp") throw new Error("spawn failed"); - return makeAutoHandshakeSpawn()(command, opts); - }; - - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs(files), - now: () => 0, - }); - - // Bad config → spawn fails → broken. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - - // Fix the config: switch the command to a working one. NO shutdownAll. - files["/project/.dispatch/lsp.json"] = JSON.stringify({ - servers: { - test: { - command: ["good-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }); - - // Next status() re-reads config, detects the change, and re-spawns. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("connected"); - }, 10000); - - it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - // Frozen clock: the bounded backoff never elapses, so a config-unchanged - // broken server is never retried in a loop. - now: () => 0, - }); - - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - - expect(spawnCount).toBe(1); - }); - - it("manager: configSource reaches status()", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/d/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, - }), - "/o/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - "/b/tsconfig.json": "{}", - }), - now: () => 0, - }); - - const d = await manager.status("/d"); - expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); - - const o = await manager.status("/o"); - expect(o[0]?.configSource).toBe("opencode.json"); - - const b = await manager.status("/b"); - expect(b[0]?.configSource).toBe("built-in"); - }, 10000); - - it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { - const warns: Array<{ - msg: string; - attrs?: Record; - }> = []; - - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, - }), - "/project/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - }), - logger: { - info: () => {}, - warn: (msg, attrs) => warns.push({ msg, attrs }), - error: () => {}, - }, - now: () => 0, - }); - - await manager.status("/project"); - - const shadow = warns.find((w) => w.msg.includes("shadowing")); - expect(shadow).toBeDefined(); - expect(shadow?.attrs?.cwd).toBe("/project"); - - // A second status() over the SAME (still-shadowed) cwd does not re-warn. - const before = warns.length; - await manager.status("/project"); - expect(warns.length).toBe(before); - }, 10000); - - it("status: error string includes config source on spawn failure", async () => { - const failingSpawn: SpawnProcess = () => { - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - now: () => 0, - }); - - const s = await manager.status("/project"); - expect(s[0]?.state).toBe("error"); - expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); - expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); - expect(s[0]?.error).toContain("spawn failed"); - }); - - it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { - // A spawn that completes the initialize handshake AND lets the test - // simulate process death via the captured onExit handler. - const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; - let spawnCount = 0; - const spawn: SpawnProcess = () => { - spawnCount++; - let messageHandler: ((data: Uint8Array) => void) | null = null; - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 1); - } - } catch { - // ignore - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 1000 + spawnCount, - kill: () => {}, - onExit: (handler) => { - exitHandlers.push(handler); - }, - }; - return proc; - }; - - const clock = { now: 0 }; - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - steep: { - command: ["steep", "--stdio"], - extensions: [".rb"], - rootMarkers: [], - }, - }, - }), - }), - now: () => clock.now, - }); - - // 1) Connects. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("connected"); - expect(spawnCount).toBe(1); - - // 2) Simulate the process dying (user kill / crash) via onExit. - exitHandlers[0]?.({ code: 1 }); - const clientAfterDeath = manager.getClient("steep", "/project"); - expect(clientAfterDeath?.getState()).toBe("error"); - - // 3) status() now reports error (and seeds a broken entry for backoff). - // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(s2[0]?.error).toMatch(/process exited/); - expect(spawnCount).toBe(1); // no retry storm before backoff - - // 4) After the backoff elapses, status() re-spawns a fresh server. - clock.now = 31_000; - const s3 = await manager.status("/project"); - expect(s3[0]?.state).toBe("connected"); - expect(spawnCount).toBe(2); // re-spawned exactly once - }); + it("status(cwd) lazy-spawns matching servers and reports connected", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp", "--stdio"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const statuses = await manager.status("/project"); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.id).toBe("test"); + expect(statuses[0]?.state).toBe("connected"); + expect(statuses[0]?.root).toBe("/project"); + }, 10000); + + it("concurrent status for the same root spawns one process", async () => { + let spawnCount = 0; + const countingSpawn: SpawnProcess = () => { + spawnCount++; + return makeAutoHandshakeSpawn()([], { cwd: "/project" }); + }; + + const manager = new LspManager({ + spawn: countingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); + + expect(s1).toHaveLength(1); + expect(s2).toHaveLength(1); + expect(spawnCount).toBe(1); + }, 10000); + + it("a failing spawn reports state:error and is not retried", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + + // Second call should not retry + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + }); + + it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { + let killed = false; + const trackableSpawn: SpawnProcess = () => { + const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); + return { + ...proc, + kill: () => { + killed = true; + }, + }; + }; + + const manager = new LspManager({ + spawn: trackableSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + await manager.status("/project"); + manager.shutdownAll(); + expect(killed).toBe(true); + }, 10000); + + it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/proj-a/.dispatch/lsp.json": JSON.stringify({ + servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, + }), + "/proj-b/opencode.json": JSON.stringify({ + lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, + }), + }), + }); + + const a = await manager.status("/proj-a"); + const b = await manager.status("/proj-b"); + expect(a.map((s) => s.id)).toEqual(["a"]); + expect(b.map((s) => s.id)).toEqual(["b"]); + }, 10000); + + it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { + const files: Record = { + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["bad-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }; + + const spawn: SpawnProcess = (command, opts) => { + if (command[0] === "bad-lsp") throw new Error("spawn failed"); + return makeAutoHandshakeSpawn()(command, opts); + }; + + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs(files), + now: () => 0, + }); + + // Bad config → spawn fails → broken. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + + // Fix the config: switch the command to a working one. NO shutdownAll. + files["/project/.dispatch/lsp.json"] = JSON.stringify({ + servers: { + test: { + command: ["good-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }); + + // Next status() re-reads config, detects the change, and re-spawns. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("connected"); + }, 10000); + + it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + // Frozen clock: the bounded backoff never elapses, so a config-unchanged + // broken server is never retried in a loop. + now: () => 0, + }); + + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + + expect(spawnCount).toBe(1); + }); + + it("manager: configSource reaches status()", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/d/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, + }), + "/o/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + "/b/tsconfig.json": "{}", + }), + now: () => 0, + }); + + const d = await manager.status("/d"); + expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); + + const o = await manager.status("/o"); + expect(o[0]?.configSource).toBe("opencode.json"); + + const b = await manager.status("/b"); + expect(b[0]?.configSource).toBe("built-in"); + }, 10000); + + it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { + const warns: Array<{ + msg: string; + attrs?: Record; + }> = []; + + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, + }), + "/project/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + }), + logger: { + info: () => {}, + warn: (msg, attrs) => warns.push({ msg, attrs }), + error: () => {}, + }, + now: () => 0, + }); + + await manager.status("/project"); + + const shadow = warns.find((w) => w.msg.includes("shadowing")); + expect(shadow).toBeDefined(); + expect(shadow?.attrs?.cwd).toBe("/project"); + + // A second status() over the SAME (still-shadowed) cwd does not re-warn. + const before = warns.length; + await manager.status("/project"); + expect(warns.length).toBe(before); + }, 10000); + + it("status: error string includes config source on spawn failure", async () => { + const failingSpawn: SpawnProcess = () => { + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + now: () => 0, + }); + + const s = await manager.status("/project"); + expect(s[0]?.state).toBe("error"); + expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); + expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); + expect(s[0]?.error).toContain("spawn failed"); + }); + + it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { + // A spawn that completes the initialize handshake AND lets the test + // simulate process death via the captured onExit handler. + const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; + let spawnCount = 0; + const spawn: SpawnProcess = () => { + spawnCount++; + let messageHandler: ((data: Uint8Array) => void) | null = null; + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 1); + } + } catch { + // ignore + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 1000 + spawnCount, + kill: () => {}, + onExit: (handler) => { + exitHandlers.push(handler); + }, + }; + return proc; + }; + + const clock = { now: 0 }; + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + steep: { + command: ["steep", "--stdio"], + extensions: [".rb"], + rootMarkers: [], + }, + }, + }), + }), + now: () => clock.now, + }); + + // 1) Connects. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("connected"); + expect(spawnCount).toBe(1); + + // 2) Simulate the process dying (user kill / crash) via onExit. + exitHandlers[0]?.({ code: 1 }); + const clientAfterDeath = manager.getClient("steep", "/project"); + expect(clientAfterDeath?.getState()).toBe("error"); + + // 3) status() now reports error (and seeds a broken entry for backoff). + // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(s2[0]?.error).toMatch(/process exited/); + expect(spawnCount).toBe(1); // no retry storm before backoff + + // 4) After the backoff elapses, status() re-spawns a fresh server. + clock.now = 31_000; + const s3 = await manager.status("/project"); + expect(s3[0]?.state).toBe("connected"); + expect(spawnCount).toBe(2); // re-spawned exactly once + }); }); diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts index bc84479..273011f 100644 --- a/packages/lsp/src/manager.ts +++ b/packages/lsp/src/manager.ts @@ -5,38 +5,38 @@ import { join } from "node:path"; import { - type ClientDeps, - type FileWatcher, - type FsAccess, - LanguageServerClient, - type SpawnProcess, + type ClientDeps, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, } from "./client.js"; import { configFingerprint, type ResolvedServer, resolveServers } from "./config.js"; import { findRoot } from "./root.js"; import type { LspServerState, LspServerStatus } from "./types.js"; export type Logger = { - readonly info: (msg: string, attrs?: Record) => void; - readonly warn: (msg: string, attrs?: Record) => void; - readonly error: (msg: string, attrs?: Record) => void; + readonly info: (msg: string, attrs?: Record) => void; + readonly warn: (msg: string, attrs?: Record) => void; + readonly error: (msg: string, attrs?: Record) => void; }; export interface ManagerDeps { - readonly spawn: SpawnProcess; - readonly fileWatcher: FileWatcher; - readonly fs: FsAccess; - readonly logger?: Logger | undefined; - /** - * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to - * `Date.now`; injected in tests so backoff is deterministic. - */ - readonly now?: (() => number) | undefined; + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly logger?: Logger | undefined; + /** + * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to + * `Date.now`; injected in tests so backoff is deterministic. + */ + readonly now?: (() => number) | undefined; } type ClientEntry = { - readonly client: LanguageServerClient; - readonly server: ResolvedServer; - readonly promise: Promise; + readonly client: LanguageServerClient; + readonly server: ResolvedServer; + readonly promise: Promise; }; /** @@ -49,251 +49,251 @@ type ClientEntry = { * broken. */ type BrokenEntry = { - readonly configFingerprint: string; - readonly brokenAt: number; - readonly error: string; + readonly configFingerprint: string; + readonly brokenAt: number; + readonly error: string; }; /** Bounded backoff before a transient (config-unchanged) failure is retried. */ const BACKOFF_MS = 30_000; export class LspManager { - private clients = new Map(); - private broken = new Map(); - private spawning = new Map>(); - private shadowWarned = new Set(); - private readonly deps: ManagerDeps; - private readonly now: () => number; + private clients = new Map(); + private broken = new Map(); + private spawning = new Map>(); + private shadowWarned = new Set(); + private readonly deps: ManagerDeps; + private readonly now: () => number; - constructor(deps: ManagerDeps) { - this.deps = deps; - this.now = deps.now ?? Date.now; - } + constructor(deps: ManagerDeps) { + this.deps = deps; + this.now = deps.now ?? Date.now; + } - async status(cwd: string): Promise { - // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox - // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. - const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); - const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); - const { servers, shadowed } = await resolveServers({ - cwd, - dispatchLspJson, - opencodeJson, - exists: this.deps.fs.exists, - }); + async status(cwd: string): Promise { + // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox + // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. + const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); + const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); + const { servers, shadowed } = await resolveServers({ + cwd, + dispatchLspJson, + opencodeJson, + exists: this.deps.fs.exists, + }); - // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp - // key — warn once per cwd so a broken shadow names itself (an agent - // running inside dispatch can only see `status()`, not the journal). - if (shadowed && !this.shadowWarned.has(cwd)) { - this.shadowWarned.add(cwd); - this.deps.logger?.warn( - `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`, - { cwd }, - ); - } + // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp + // key — warn once per cwd so a broken shadow names itself (an agent + // running inside dispatch can only see `status()`, not the journal). + if (shadowed && !this.shadowWarned.has(cwd)) { + this.shadowWarned.add(cwd); + this.deps.logger?.warn( + `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`, + { cwd }, + ); + } - const results: LspServerStatus[] = []; + const results: LspServerStatus[] = []; - for (const server of servers) { - const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); - const key = `${server.id}:${root}`; + for (const server of servers) { + const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); + const key = `${server.id}:${root}`; - const brokenEntry = this.broken.get(key); - if (brokenEntry) { - // Recovery, storm-safe: a config change is a discrete event, so it - // cannot loop. Transient failures (config unchanged) are retried - // only after a bounded backoff — never in a tight loop. - const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint; - const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; - if (configChanged || backoffElapsed) { - this.broken.delete(key); - // Discard the stale client entry (and any leaked process) from - // the failed spawn so status() re-spawns fresh. - const stale = this.clients.get(key); - if (stale) { - this.clients.delete(key); - stale.client.shutdown(); - } - // fall through to (re)spawn - } else { - results.push({ - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: "error", - error: brokenEntry.error, - configSource: server.configSource, - }); - continue; - } - } + const brokenEntry = this.broken.get(key); + if (brokenEntry) { + // Recovery, storm-safe: a config change is a discrete event, so it + // cannot loop. Transient failures (config unchanged) are retried + // only after a bounded backoff — never in a tight loop. + const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint; + const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; + if (configChanged || backoffElapsed) { + this.broken.delete(key); + // Discard the stale client entry (and any leaked process) from + // the failed spawn so status() re-spawns fresh. + const stale = this.clients.get(key); + if (stale) { + this.clients.delete(key); + stale.client.shutdown(); + } + // fall through to (re)spawn + } else { + results.push({ + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: brokenEntry.error, + configSource: server.configSource, + }); + continue; + } + } - const existing = this.clients.get(key); - if (existing) { - const state = existing.client.getState(); - const stateError = existing.client.getStateError(); - // A client that died or corrupted AFTER connecting flipped its - // own state to "error" (client.ts handleExit/markBroken). Spawn - // succeeded so there's no broken entry yet — seed one so the - // bounded-backoff path above re-spawns it, instead of reporting - // error forever (and so getDiagnostics' "connected" filter skips - // it, avoiding a per-edit hang on the corpse). - if (state === "error" && !this.broken.has(key)) { - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, stateError ?? "server unavailable"), - }); - } - const status: LspServerStatus = { - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: mapState(state), - configSource: server.configSource, - }; - if (stateError !== undefined) { - (status as { error?: string }).error = enrichError(server, stateError); - } - results.push(status); - continue; - } + const existing = this.clients.get(key); + if (existing) { + const state = existing.client.getState(); + const stateError = existing.client.getStateError(); + // A client that died or corrupted AFTER connecting flipped its + // own state to "error" (client.ts handleExit/markBroken). Spawn + // succeeded so there's no broken entry yet — seed one so the + // bounded-backoff path above re-spawns it, instead of reporting + // error forever (and so getDiagnostics' "connected" filter skips + // it, avoiding a per-edit hang on the corpse). + if (state === "error" && !this.broken.has(key)) { + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, stateError ?? "server unavailable"), + }); + } + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + configSource: server.configSource, + }; + if (stateError !== undefined) { + (status as { error?: string }).error = enrichError(server, stateError); + } + results.push(status); + continue; + } - try { - await this.spawnClient(server, root, key); - const entry = this.clients.get(key); - if (entry) { - const state = entry.client.getState(); - const stateError = entry.client.getStateError(); - const status: LspServerStatus = { - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: mapState(state), - configSource: server.configSource, - }; - if (stateError !== undefined) { - (status as { error?: string }).error = enrichError(server, stateError); - } - results.push(status); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, message), - }); - results.push({ - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: "error", - error: enrichError(server, message), - configSource: server.configSource, - }); - } - } + try { + await this.spawnClient(server, root, key); + const entry = this.clients.get(key); + if (entry) { + const state = entry.client.getState(); + const stateError = entry.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + configSource: server.configSource, + }; + if (stateError !== undefined) { + (status as { error?: string }).error = enrichError(server, stateError); + } + results.push(status); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, message), + }); + results.push({ + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: enrichError(server, message), + configSource: server.configSource, + }); + } + } - return results; - } + return results; + } - getClient(serverId: string, root: string): LanguageServerClient | undefined { - const key = `${serverId}:${root}`; - return this.clients.get(key)?.client; - } + getClient(serverId: string, root: string): LanguageServerClient | undefined { + const key = `${serverId}:${root}`; + return this.clients.get(key)?.client; + } - /** Read a config file's contents, or null if it is absent/unreadable. */ - private async readOrNull(path: string): Promise { - if (!(await this.deps.fs.exists(path))) return null; - try { - return await this.deps.fs.readText(path); - } catch { - return null; - } - } + /** Read a config file's contents, or null if it is absent/unreadable. */ + private async readOrNull(path: string): Promise { + if (!(await this.deps.fs.exists(path))) return null; + try { + return await this.deps.fs.readText(path); + } catch { + return null; + } + } - private async spawnClient(server: ResolvedServer, root: string, key: string): Promise { - const existingSpawn = this.spawning.get(key); - if (existingSpawn) return existingSpawn; + private async spawnClient(server: ResolvedServer, root: string, key: string): Promise { + const existingSpawn = this.spawning.get(key); + if (existingSpawn) return existingSpawn; - const spawnPromise = this.doSpawn(server, root, key); - this.spawning.set(key, spawnPromise); + const spawnPromise = this.doSpawn(server, root, key); + this.spawning.set(key, spawnPromise); - try { - await spawnPromise; - } finally { - this.spawning.delete(key); - } - } + try { + await spawnPromise; + } finally { + this.spawning.delete(key); + } + } - private async doSpawn(server: ResolvedServer, root: string, key: string): Promise { - const clientDeps: ClientDeps = { - spawn: this.deps.spawn, - fileWatcher: this.deps.fileWatcher, - fs: this.deps.fs, - command: server.command, - root, - serverId: server.id, - }; - if (server.env) { - (clientDeps as { env?: Readonly> }).env = server.env; - } - if (server.initialization) { - (clientDeps as { initialization?: Readonly> }).initialization = - server.initialization; - } + private async doSpawn(server: ResolvedServer, root: string, key: string): Promise { + const clientDeps: ClientDeps = { + spawn: this.deps.spawn, + fileWatcher: this.deps.fileWatcher, + fs: this.deps.fs, + command: server.command, + root, + serverId: server.id, + }; + if (server.env) { + (clientDeps as { env?: Readonly> }).env = server.env; + } + if (server.initialization) { + (clientDeps as { initialization?: Readonly> }).initialization = + server.initialization; + } - const client = new LanguageServerClient(clientDeps); - const entry: ClientEntry = { - client, - server, - promise: client.start(), - }; + const client = new LanguageServerClient(clientDeps); + const entry: ClientEntry = { + client, + server, + promise: client.start(), + }; - this.clients.set(key, entry); - await entry.promise; + this.clients.set(key, entry); + await entry.promise; - if (client.getState() === "error") { - const message = client.getStateError() ?? "unknown"; - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, message), - }); - this.deps.logger?.warn("LSP server failed to start", { - serverId: server.id, - root, - configSource: server.configSource ?? "unknown", - error: message, - }); - } else { - this.deps.logger?.info("LSP server connected", { - serverId: server.id, - root, - }); - } - } + if (client.getState() === "error") { + const message = client.getStateError() ?? "unknown"; + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, message), + }); + this.deps.logger?.warn("LSP server failed to start", { + serverId: server.id, + root, + configSource: server.configSource ?? "unknown", + error: message, + }); + } else { + this.deps.logger?.info("LSP server connected", { + serverId: server.id, + root, + }); + } + } - shutdownAll(): void { - for (const [key, entry] of this.clients) { - entry.client.shutdown(); - this.deps.logger?.info("LSP server shutdown", { key }); - } - this.clients.clear(); - this.broken.clear(); - this.spawning.clear(); - this.shadowWarned.clear(); - } + shutdownAll(): void { + for (const [key, entry] of this.clients) { + entry.client.shutdown(); + this.deps.logger?.info("LSP server shutdown", { key }); + } + this.clients.clear(); + this.broken.clear(); + this.spawning.clear(); + this.shadowWarned.clear(); + } } function mapState(state: LspServerState): LspServerState { - return state; + return state; } /** @@ -302,6 +302,6 @@ function mapState(state: LspServerState): LspServerState { * `ruby-lsp [from .dispatch/lsp.json]: spawn failed`). */ function enrichError(server: ResolvedServer, message: string): string { - const source = server.configSource ?? "unknown"; - return `${server.id} [from ${source}]: ${message}`; + const source = server.configSource ?? "unknown"; + return `${server.id} [from ${source}]: ${message}`; } diff --git a/packages/lsp/src/root.test.ts b/packages/lsp/src/root.test.ts index ffe2e31..77d2881 100644 --- a/packages/lsp/src/root.test.ts +++ b/packages/lsp/src/root.test.ts @@ -2,50 +2,50 @@ import { describe, expect, it } from "vitest"; import { findRoot } from "./root.js"; describe("root", () => { - it("findRoot returns nearest marker ancestor bounded by cwd", async () => { - const existingFiles = new Set([ - "/project/src/components/tsconfig.json", - "/project/tsconfig.json", - ]); - - const result = await findRoot( - "/project/src/components/widgets", - "/project", - ["tsconfig.json"], - async (path) => existingFiles.has(path), - ); - - expect(result).toBe("/project/src/components"); - }); - - it("falls back to cwd", async () => { - const result = await findRoot( - "/project/src/deep/nested", - "/project", - ["tsconfig.json"], - async () => false, - ); - - expect(result).toBe("/project"); - }); - - it("finds marker at start directory", async () => { - const existingFiles = new Set(["/project/tsconfig.json"]); - - const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => - existingFiles.has(path), - ); - - expect(result).toBe("/project"); - }); - - it("respects cwd boundary", async () => { - const existingFiles = new Set(["/tsconfig.json"]); - - const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => - existingFiles.has(path), - ); - - expect(result).toBe("/project"); - }); + it("findRoot returns nearest marker ancestor bounded by cwd", async () => { + const existingFiles = new Set([ + "/project/src/components/tsconfig.json", + "/project/tsconfig.json", + ]); + + const result = await findRoot( + "/project/src/components/widgets", + "/project", + ["tsconfig.json"], + async (path) => existingFiles.has(path), + ); + + expect(result).toBe("/project/src/components"); + }); + + it("falls back to cwd", async () => { + const result = await findRoot( + "/project/src/deep/nested", + "/project", + ["tsconfig.json"], + async () => false, + ); + + expect(result).toBe("/project"); + }); + + it("finds marker at start directory", async () => { + const existingFiles = new Set(["/project/tsconfig.json"]); + + const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); + + it("respects cwd boundary", async () => { + const existingFiles = new Set(["/tsconfig.json"]); + + const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); }); diff --git a/packages/lsp/src/root.ts b/packages/lsp/src/root.ts index fc7814e..b0801fd 100644 --- a/packages/lsp/src/root.ts +++ b/packages/lsp/src/root.ts @@ -3,42 +3,42 @@ */ export async function findRoot( - startDir: string, - cwd: string, - markers: readonly string[], - exists: (path: string) => Promise, + startDir: string, + cwd: string, + markers: readonly string[], + exists: (path: string) => Promise, ): Promise { - const normalizedStart = normalizePath(startDir); - const normalizedCwd = normalizePath(cwd); + const normalizedStart = normalizePath(startDir); + const normalizedCwd = normalizePath(cwd); - let current = normalizedStart; - while (true) { - for (const marker of markers) { - const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; - if (await exists(markerPath)) { - return current; - } - } - if (current === normalizedCwd || current === "/") { - return normalizedCwd; - } - const parent = getParent(current); - if (parent === current) return normalizedCwd; - current = parent; - } + let current = normalizedStart; + while (true) { + for (const marker of markers) { + const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; + if (await exists(markerPath)) { + return current; + } + } + if (current === normalizedCwd || current === "/") { + return normalizedCwd; + } + const parent = getParent(current); + if (parent === current) return normalizedCwd; + current = parent; + } } function normalizePath(p: string): string { - let normalized = p.replace(/\\/g, "/"); - if (normalized.length > 1 && normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - return normalized || "/"; + let normalized = p.replace(/\\/g, "/"); + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized || "/"; } function getParent(p: string): string { - if (p === "/") return "/"; - const lastSlash = p.lastIndexOf("/"); - if (lastSlash <= 0) return "/"; - return p.slice(0, lastSlash) || "/"; + if (p === "/") return "/"; + const lastSlash = p.lastIndexOf("/"); + if (lastSlash <= 0) return "/"; + return p.slice(0, lastSlash) || "/"; } diff --git a/packages/lsp/src/rpc.test.ts b/packages/lsp/src/rpc.test.ts index 7b22ec5..5db5f97 100644 --- a/packages/lsp/src/rpc.test.ts +++ b/packages/lsp/src/rpc.test.ts @@ -2,111 +2,111 @@ import { describe, expect, it } from "vitest"; import { JsonRpcConnection } from "./rpc.js"; function makeConnection(): { conn: JsonRpcConnection; messages: string[] } { - const messages: string[] = []; - const conn = new JsonRpcConnection((bytes) => { - const decoded = new TextDecoder().decode(bytes); - // Extract JSON from the LSP-framed message - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd !== -1) { - messages.push(decoded.slice(headerEnd + 4)); - } - }); - return { conn, messages }; + const messages: string[] = []; + const conn = new JsonRpcConnection((bytes) => { + const decoded = new TextDecoder().decode(bytes); + // Extract JSON from the LSP-framed message + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + messages.push(decoded.slice(headerEnd + 4)); + } + }); + return { conn, messages }; } function frameResponse(id: number, result: unknown): string { - return JSON.stringify({ jsonrpc: "2.0", id, result }); + return JSON.stringify({ jsonrpc: "2.0", id, result }); } describe("rpc", () => { - it("sendRequest resolves by matching id", async () => { - const { conn, messages } = makeConnection(); - - const promise = conn.sendRequest("test/method", { key: "value" }); - expect(messages).toHaveLength(1); - - const rawSent = messages[0]; - if (rawSent === undefined) throw new Error("expected a sent message"); - const sent = JSON.parse(rawSent); - expect(sent.method).toBe("test/method"); - expect(sent.params).toEqual({ key: "value" }); - expect(sent.id).toBe(1); - - conn.handleMessage(frameResponse(1, { ok: true })); - const result = await promise; - expect(result).toEqual({ ok: true }); - }); - - it("onNotification dispatches by method", () => { - const { conn } = makeConnection(); - let received: unknown = null; - conn.onNotification("test/notify", (params) => { - received = params; - }); - - conn.handleMessage( - JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), - ); - expect(received).toEqual({ data: 42 }); - }); - - it("onRequest replies to a server-to-client request", async () => { - const { conn, messages } = makeConnection(); - - conn.onRequest("workspace/configuration", (params) => { - const { items } = params as { readonly items: readonly { readonly section?: string }[] }; - return items.map(() => ({ setting: true })); - }); - - await conn.handleMessage( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "workspace/configuration", - params: { items: [{ section: "test" }] }, - }), - ); - - // The response should be sent back - expect(messages).toHaveLength(1); - const rawResponse = messages[0]; - if (rawResponse === undefined) throw new Error("expected a response message"); - const response = JSON.parse(rawResponse); - expect(response.id).toBe(100); - expect(response.result).toEqual([{ setting: true }]); - }); + it("sendRequest resolves by matching id", async () => { + const { conn, messages } = makeConnection(); + + const promise = conn.sendRequest("test/method", { key: "value" }); + expect(messages).toHaveLength(1); + + const rawSent = messages[0]; + if (rawSent === undefined) throw new Error("expected a sent message"); + const sent = JSON.parse(rawSent); + expect(sent.method).toBe("test/method"); + expect(sent.params).toEqual({ key: "value" }); + expect(sent.id).toBe(1); + + conn.handleMessage(frameResponse(1, { ok: true })); + const result = await promise; + expect(result).toEqual({ ok: true }); + }); + + it("onNotification dispatches by method", () => { + const { conn } = makeConnection(); + let received: unknown = null; + conn.onNotification("test/notify", (params) => { + received = params; + }); + + conn.handleMessage( + JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), + ); + expect(received).toEqual({ data: 42 }); + }); + + it("onRequest replies to a server-to-client request", async () => { + const { conn, messages } = makeConnection(); + + conn.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + return items.map(() => ({ setting: true })); + }); + + await conn.handleMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "workspace/configuration", + params: { items: [{ section: "test" }] }, + }), + ); + + // The response should be sent back + expect(messages).toHaveLength(1); + const rawResponse = messages[0]; + if (rawResponse === undefined) throw new Error("expected a response message"); + const response = JSON.parse(rawResponse); + expect(response.id).toBe(100); + expect(response.result).toEqual([{ setting: true }]); + }); }); it("handleMessage does not throw on malformed JSON", async () => { - const { conn } = makeConnection(); - // A corrupted/truncated LSP message — must not throw or reject. - await expect(conn.handleMessage("{ broken json")).resolves.toBeUndefined(); - await expect(conn.handleMessage("")).resolves.toBeUndefined(); - await expect(conn.handleMessage("not json at all")).resolves.toBeUndefined(); + const { conn } = makeConnection(); + // A corrupted/truncated LSP message — must not throw or reject. + await expect(conn.handleMessage("{ broken json")).resolves.toBeUndefined(); + await expect(conn.handleMessage("")).resolves.toBeUndefined(); + await expect(conn.handleMessage("not json at all")).resolves.toBeUndefined(); }); describe("sendRequest timeout", () => { - it("rejects with a timeout error when no response arrives within timeoutMs", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("textDocument/hover", {}, 50); - await expect(promise).rejects.toThrow(/LSP request timed out after 50ms: textDocument\/hover/); - }); - - it("clears the timer on a normal response (no unhandled rejection)", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("textDocument/hover", {}, 5000); - conn.handleMessage(frameResponse(1, { ok: true })); - await expect(promise).resolves.toEqual({ ok: true }); - // Give the (now-cleared) timer window ample time to prove it never fires. - await new Promise((r) => setTimeout(r, 80)); - }); - - it("does not time out when no timeoutMs is given (initialize handshake path)", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("initialize", {}); - // A late response well past any plausible default still resolves. - await new Promise((r) => setTimeout(r, 60)); - conn.handleMessage(frameResponse(1, { capabilities: {} })); - await expect(promise).resolves.toEqual({ capabilities: {} }); - }); + it("rejects with a timeout error when no response arrives within timeoutMs", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("textDocument/hover", {}, 50); + await expect(promise).rejects.toThrow(/LSP request timed out after 50ms: textDocument\/hover/); + }); + + it("clears the timer on a normal response (no unhandled rejection)", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("textDocument/hover", {}, 5000); + conn.handleMessage(frameResponse(1, { ok: true })); + await expect(promise).resolves.toEqual({ ok: true }); + // Give the (now-cleared) timer window ample time to prove it never fires. + await new Promise((r) => setTimeout(r, 80)); + }); + + it("does not time out when no timeoutMs is given (initialize handshake path)", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("initialize", {}); + // A late response well past any plausible default still resolves. + await new Promise((r) => setTimeout(r, 60)); + conn.handleMessage(frameResponse(1, { capabilities: {} })); + await expect(promise).resolves.toEqual({ capabilities: {} }); + }); }); diff --git a/packages/lsp/src/rpc.ts b/packages/lsp/src/rpc.ts index 95157de..442176a 100644 --- a/packages/lsp/src/rpc.ts +++ b/packages/lsp/src/rpc.ts @@ -10,157 +10,157 @@ import { encode } from "./framing.js"; export type WriteFn = (bytes: Uint8Array) => void; export interface PendingRequest { - readonly resolve: (value: unknown) => void; - readonly reject: (reason: unknown) => void; + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; } export type RequestHandler = (params: unknown) => unknown | Promise; export type NotificationHandler = (params: unknown) => void; export interface JsonRpcMessage { - readonly jsonrpc: "2.0"; - readonly id?: number | string | undefined; - readonly method?: string | undefined; - readonly params?: unknown; - readonly result?: unknown; - readonly error?: - | { readonly code: number; readonly message: string; readonly data?: unknown } - | undefined; + readonly jsonrpc: "2.0"; + readonly id?: number | string | undefined; + readonly method?: string | undefined; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: + | { readonly code: number; readonly message: string; readonly data?: unknown } + | undefined; } export class JsonRpcConnection { - private nextId = 1; - private pending = new Map(); - private requestHandlers = new Map(); - private notificationHandlers = new Map(); - private write: WriteFn; - - constructor(write: WriteFn) { - this.write = write; - } - - /** - * Send a request and await the correlated response. If `timeoutMs` is given, - * the promise rejects with a timeout error after that long — so a dead/slow - * server can't hang the caller forever (hover/definition/references). - * No `timeoutMs` = wait indefinitely (used by the initialize handshake, which - * has its own 45s race). - */ - sendRequest(method: string, params?: unknown, timeoutMs?: number): Promise { - const id = this.nextId++; - const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - // Wrap resolve/reject so the timer is cleared on a normal response - // (or on dispose) — no dangling timer after completion. - const finish = (fn: () => void): void => { - if (timer) clearTimeout(timer); - fn(); - }; - const entry: PendingRequest = { - resolve: (value: unknown) => finish(() => resolve(value)), - reject: (reason: unknown) => finish(() => reject(reason)), - }; - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - if (this.pending.delete(id)) { - reject(new Error(`LSP request timed out after ${timeoutMs}ms: ${method}`)); - } - }, timeoutMs); - } - this.pending.set(id, entry); - this.sendMessage(msg); - }); - } - - sendNotification(method: string, params?: unknown): void { - const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; - this.sendMessage(msg); - } - - onRequest(method: string, handler: RequestHandler): void { - this.requestHandlers.set(method, handler); - } - - onNotification(method: string, handler: NotificationHandler): void { - this.notificationHandlers.set(method, handler); - } - - async handleMessage(json: string): Promise { - let msg: JsonRpcMessage; - try { - msg = JSON.parse(json) as JsonRpcMessage; - } catch { - // A malformed LSP message must never crash the server. The most - // common cause is a multi-byte UTF-8 character split across stdout - // chunks (see FrameDecoder). Log and skip — the language server - // will re-send diagnostics on the next file change. - return; - } - const { id, method } = msg; - - if (id !== undefined && method !== undefined) { - await this.handleIncomingRequest(id, method, msg.params); - } else if (method !== undefined) { - this.handleIncomingNotification(method, msg.params); - } else if (id !== undefined) { - this.handleResponse(id, msg); - } - } - - private sendMessage(msg: JsonRpcMessage): void { - this.write(encode(JSON.stringify(msg))); - } - - private handleResponse(id: number | string, msg: JsonRpcMessage): void { - const entry = this.pending.get(id); - if (!entry) return; - this.pending.delete(id); - if (msg.error) { - entry.reject(new Error(msg.error.message)); - } else { - entry.resolve(msg.result); - } - } - - private async handleIncomingRequest( - id: number | string, - method: string, - params: unknown, - ): Promise { - const handler = this.requestHandlers.get(method); - if (!handler) { - this.sendMessage({ - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Method not found: ${method}` }, - }); - return; - } - try { - const result = await handler(params); - this.sendMessage({ jsonrpc: "2.0", id, result }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.sendMessage({ - jsonrpc: "2.0", - id, - error: { code: -32603, message }, - }); - } - } - - private handleIncomingNotification(method: string, params: unknown): void { - const handler = this.notificationHandlers.get(method); - if (handler) { - handler(params); - } - } - - dispose(): void { - for (const entry of this.pending.values()) { - entry.reject(new Error("Connection closed")); - } - this.pending.clear(); - } + private nextId = 1; + private pending = new Map(); + private requestHandlers = new Map(); + private notificationHandlers = new Map(); + private write: WriteFn; + + constructor(write: WriteFn) { + this.write = write; + } + + /** + * Send a request and await the correlated response. If `timeoutMs` is given, + * the promise rejects with a timeout error after that long — so a dead/slow + * server can't hang the caller forever (hover/definition/references). + * No `timeoutMs` = wait indefinitely (used by the initialize handshake, which + * has its own 45s race). + */ + sendRequest(method: string, params?: unknown, timeoutMs?: number): Promise { + const id = this.nextId++; + const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + // Wrap resolve/reject so the timer is cleared on a normal response + // (or on dispose) — no dangling timer after completion. + const finish = (fn: () => void): void => { + if (timer) clearTimeout(timer); + fn(); + }; + const entry: PendingRequest = { + resolve: (value: unknown) => finish(() => resolve(value)), + reject: (reason: unknown) => finish(() => reject(reason)), + }; + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + if (this.pending.delete(id)) { + reject(new Error(`LSP request timed out after ${timeoutMs}ms: ${method}`)); + } + }, timeoutMs); + } + this.pending.set(id, entry); + this.sendMessage(msg); + }); + } + + sendNotification(method: string, params?: unknown): void { + const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; + this.sendMessage(msg); + } + + onRequest(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + async handleMessage(json: string): Promise { + let msg: JsonRpcMessage; + try { + msg = JSON.parse(json) as JsonRpcMessage; + } catch { + // A malformed LSP message must never crash the server. The most + // common cause is a multi-byte UTF-8 character split across stdout + // chunks (see FrameDecoder). Log and skip — the language server + // will re-send diagnostics on the next file change. + return; + } + const { id, method } = msg; + + if (id !== undefined && method !== undefined) { + await this.handleIncomingRequest(id, method, msg.params); + } else if (method !== undefined) { + this.handleIncomingNotification(method, msg.params); + } else if (id !== undefined) { + this.handleResponse(id, msg); + } + } + + private sendMessage(msg: JsonRpcMessage): void { + this.write(encode(JSON.stringify(msg))); + } + + private handleResponse(id: number | string, msg: JsonRpcMessage): void { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + + private async handleIncomingRequest( + id: number | string, + method: string, + params: unknown, + ): Promise { + const handler = this.requestHandlers.get(method); + if (!handler) { + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + return; + } + try { + const result = await handler(params); + this.sendMessage({ jsonrpc: "2.0", id, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + }); + } + } + + private handleIncomingNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (handler) { + handler(params); + } + } + + dispose(): void { + for (const entry of this.pending.values()) { + entry.reject(new Error("Connection closed")); + } + this.pending.clear(); + } } diff --git a/packages/lsp/src/tool.test.ts b/packages/lsp/src/tool.test.ts index efd1514..e4c72ed 100644 --- a/packages/lsp/src/tool.test.ts +++ b/packages/lsp/src/tool.test.ts @@ -3,274 +3,274 @@ import type { LspManager } from "./manager.js"; import { createLspTool } from "./tool.js"; function stubManager(overrides?: Partial): LspManager { - return { - status: async () => [], - getClient: () => undefined, - shutdownAll: () => {}, - ...overrides, - } as unknown as LspManager; + return { + status: async () => [], + getClient: () => undefined, + shutdownAll: () => {}, + ...overrides, + } as unknown as LspManager; } describe("tool", () => { - it("diagnostics formats errors", async () => { - const tool = createLspTool(stubManager()); - const result = await tool.execute( - { operation: "diagnostics", path: "test.ts" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("No language server configured"); - }); + it("diagnostics formats errors", async () => { + const tool = createLspTool(stubManager()); + const result = await tool.execute( + { operation: "diagnostics", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("No language server configured"); + }); - it("position ops convert 1-based to 0-based", async () => { - let receivedPosition: { line: number; character: number } | null = null; + it("position ops convert 1-based to 0-based", async () => { + let receivedPosition: { line: number; character: number } | null = null; - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async (method: string, params: unknown) => { - if (method === "textDocument/hover") { - receivedPosition = (params as { position: { line: number; character: number } }).position; - return { contents: { value: "hover result" } }; - } - return null; - }, - waitForDiagnostics: async () => "", - }; + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedPosition = (params as { position: { line: number; character: number } }).position; + return { contents: { value: "hover result" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "hover", path: "test.ts", line: 5, character: 10 }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "hover", path: "test.ts", line: 5, character: 10 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(receivedPosition).toEqual({ line: 4, character: 9 }); - expect(result.content).toBe("hover result"); - }); + expect(receivedPosition).toEqual({ line: 4, character: 9 }); + expect(result.content).toBe("hover result"); + }); - it("path resolved against ctx.cwd", async () => { - let receivedUri: string | null = null; + it("path resolved against ctx.cwd", async () => { + let receivedUri: string | null = null; - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async (method: string, params: unknown) => { - if (method === "textDocument/hover") { - receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; - return { contents: { value: "ok" } }; - } - return null; - }, - waitForDiagnostics: async () => "", - }; + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; + return { contents: { value: "ok" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - await tool.execute( - { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + await tool.execute( + { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(receivedUri).toBe("file:///project/src/test.ts"); - }); + expect(receivedUri).toBe("file:///project/src/test.ts"); + }); - it("position op without line/character returns isError", async () => { - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - }), - ); + it("position op without line/character returns isError", async () => { + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + }), + ); - const result = await tool.execute( - { operation: "hover", path: "test.ts" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "hover", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.isError).toBe(true); - expect(result.content).toContain("requires both"); - }); + expect(result.isError).toBe(true); + expect(result.content).toContain("requires both"); + }); - it("diagnostics op: a server that times out is skipped with a raise-to-user notice", async () => { - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async () => null, - waitForDiagnostics: async () => ({ formatted: "", slow: false, timedOut: true }), - }; + it("diagnostics op: a server that times out is skipped with a raise-to-user notice", async () => { + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async () => null, + waitForDiagnostics: async () => ({ formatted: "", slow: false, timedOut: true }), + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "steep", - name: "Steep", - root: "/project", - extensions: [".rb"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "steep", + name: "Steep", + root: "/project", + extensions: [".rb"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "diagnostics", path: "game.rb" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "diagnostics", path: "game.rb" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.isError).not.toBe(true); - expect(result.content).toContain("[Steep]"); - expect(result.content).toContain("took too long"); - expect(result.content).toContain(">10s"); - expect(result.content).toContain("raise this to the user"); - }); + expect(result.isError).not.toBe(true); + expect(result.content).toContain("[Steep]"); + expect(result.content).toContain("took too long"); + expect(result.content).toContain(">10s"); + expect(result.content).toContain("raise this to the user"); + }); - it("diagnostics op: responding servers' diagnostics are merged, tagged by source", async () => { - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async () => null, - waitForDiagnostics: async () => ({ - formatted: "ERROR L1:1: boom", - slow: false, - timedOut: false, - }), - }; + it("diagnostics op: responding servers' diagnostics are merged, tagged by source", async () => { + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async () => null, + waitForDiagnostics: async () => ({ + formatted: "ERROR L1:1: boom", + slow: false, + timedOut: false, + }), + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "steep", - name: "Steep", - root: "/project", - extensions: [".rb"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "steep", + name: "Steep", + root: "/project", + extensions: [".rb"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "diagnostics", path: "game.rb" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "diagnostics", path: "game.rb" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.content).toContain("[Steep]"); - expect(result.content).toContain("boom"); - }); + expect(result.content).toContain("[Steep]"); + expect(result.content).toContain("boom"); + }); }); diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts index be0d269..bd10a82 100644 --- a/packages/lsp/src/tool.ts +++ b/packages/lsp/src/tool.ts @@ -14,250 +14,250 @@ type Operation = "diagnostics" | "hover" | "definition" | "references" | "docume const POSITION_OPS: ReadonlySet = new Set(["hover", "definition", "references"]); interface ValidatedArgs { - readonly operation: Operation; - readonly path: string; - readonly line?: number | undefined; - readonly character?: number | undefined; + readonly operation: Operation; + readonly path: string; + readonly line?: number | undefined; + readonly character?: number | undefined; } function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; - const rawOp = obj.operation; - if (typeof rawOp !== "string") { - return { error: 'Error: Missing "operation" parameter (must be a string).' }; - } - const validOps: ReadonlySet = new Set([ - "diagnostics", - "hover", - "definition", - "references", - "documentSymbol", - ]); - if (!validOps.has(rawOp)) { - return { - error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, - }; - } - const operation = rawOp as Operation; + const rawOp = obj.operation; + if (typeof rawOp !== "string") { + return { error: 'Error: Missing "operation" parameter (must be a string).' }; + } + const validOps: ReadonlySet = new Set([ + "diagnostics", + "hover", + "definition", + "references", + "documentSymbol", + ]); + if (!validOps.has(rawOp)) { + return { + error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, + }; + } + const operation = rawOp as Operation; - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.trim().length === 0) { - return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; - } + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.trim().length === 0) { + return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; + } - let line: number | undefined; - let character: number | undefined; + let line: number | undefined; + let character: number | undefined; - if (obj.line !== undefined) { - const n = Number(obj.line); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; - } - line = Math.floor(n); - } + if (obj.line !== undefined) { + const n = Number(obj.line); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; + } + line = Math.floor(n); + } - if (obj.character !== undefined) { - const n = Number(obj.character); - if (!Number.isFinite(n) || n < 1) { - return { - error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', - }; - } - character = Math.floor(n); - } + if (obj.character !== undefined) { + const n = Number(obj.character); + if (!Number.isFinite(n) || n < 1) { + return { + error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', + }; + } + character = Math.floor(n); + } - const result: ValidatedArgs = { operation, path: rawPath }; - if (line !== undefined) { - (result as { line?: number }).line = line; - } - if (character !== undefined) { - (result as { character?: number }).character = character; - } - return result; + const result: ValidatedArgs = { operation, path: rawPath }; + if (line !== undefined) { + (result as { line?: number }).line = line; + } + if (character !== undefined) { + (result as { character?: number }).character = character; + } + return result; } function resolveFilePath(filePath: string, cwd: string): string { - const resolved = resolve(cwd, filePath); - const normalizedCwd = resolve(cwd); - if (!resolved.startsWith(normalizedCwd)) { - return normalizedCwd; - } - return resolved; + const resolved = resolve(cwd, filePath); + const normalizedCwd = resolve(cwd); + if (!resolved.startsWith(normalizedCwd)) { + return normalizedCwd; + } + return resolved; } /** Convert validated 1-based line/character to an LSP 0-based position. */ function toPosition( - line: number | undefined, - character: number | undefined, + line: number | undefined, + character: number | undefined, ): { readonly line: number; readonly character: number } { - if (line === undefined || character === undefined) { - throw new Error("Position operations require both line and character."); - } - return { line: line - 1, character: character - 1 }; + if (line === undefined || character === undefined) { + throw new Error("Position operations require both line and character."); + } + return { line: line - 1, character: character - 1 }; } export function createLspTool(manager: LspManager): ToolContract { - return { - name: "lsp", - description: - "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", - parameters: { - type: "object", - properties: { - operation: { - type: "string", - enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], - description: "The LSP operation to perform.", - }, - path: { - type: "string", - description: "File path relative to the workspace.", - }, - line: { - type: "number", - description: "Line number (1-based). Required for hover, definition, references.", - }, - character: { - type: "number", - description: "Character position (1-based). Required for hover, definition, references.", - }, - }, - required: ["operation", "path"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } + return { + name: "lsp", + description: + "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", + parameters: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], + description: "The LSP operation to perform.", + }, + path: { + type: "string", + description: "File path relative to the workspace.", + }, + line: { + type: "number", + description: "Line number (1-based). Required for hover, definition, references.", + }, + character: { + type: "number", + description: "Character position (1-based). Required for hover, definition, references.", + }, + }, + required: ["operation", "path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } - const { operation, path: filePath, line, character } = validated; - const cwd = ctx.cwd ?? process.cwd(); - const absolutePath = resolveFilePath(filePath, cwd); + const { operation, path: filePath, line, character } = validated; + const cwd = ctx.cwd ?? process.cwd(); + const absolutePath = resolveFilePath(filePath, cwd); - if (POSITION_OPS.has(operation)) { - if (line === undefined || character === undefined) { - return { - content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, - isError: true, - }; - } - } + if (POSITION_OPS.has(operation)) { + if (line === undefined || character === undefined) { + return { + content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, + isError: true, + }; + } + } - try { - const statuses = await manager.status(cwd); - if (statuses.length === 0) { - return { content: "No language server configured for this workspace.", isError: true }; - } + try { + const statuses = await manager.status(cwd); + if (statuses.length === 0) { + return { content: "No language server configured for this workspace.", isError: true }; + } - const fileExt = extname(absolutePath).toLowerCase(); + const fileExt = extname(absolutePath).toLowerCase(); - switch (operation) { - case "diagnostics": { - // 10s hard ceiling per server (same policy as the edit path). - const DIAGNOSTICS_TIMEOUT_MS = 10_000; - // Query ALL connected servers whose extensions match this file. - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); + switch (operation) { + case "diagnostics": { + // 10s hard ceiling per server (same policy as the edit path). + const DIAGNOSTICS_TIMEOUT_MS = 10_000; + // Query ALL connected servers whose extensions match this file. + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); - if (matching.length === 0) { - // No matching server — fall back to any connected server. - const connected = statuses.find((s) => s.state === "connected"); - if (!connected) { - const first = statuses[0]; - const detail = first - ? `"${first.name}" is not connected (state: ${first.state})` - : "is not connected"; - return { - content: `Language server ${detail}.`, - isError: true, - }; - } - const client = manager.getClient(connected.id, connected.root); - if (!client) { - return { content: "Language server client not available.", isError: true }; - } - const result = await client.waitForDiagnostics(absolutePath, { - timeoutMs: DIAGNOSTICS_TIMEOUT_MS, - }); - if (result.timedOut) { - return { - content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`, - }; - } - return { content: result.formatted || "No diagnostics found." }; - } + if (matching.length === 0) { + // No matching server — fall back to any connected server. + const connected = statuses.find((s) => s.state === "connected"); + if (!connected) { + const first = statuses[0]; + const detail = first + ? `"${first.name}" is not connected (state: ${first.state})` + : "is not connected"; + return { + content: `Language server ${detail}.`, + isError: true, + }; + } + const client = manager.getClient(connected.id, connected.root); + if (!client) { + return { content: "Language server client not available.", isError: true }; + } + const result = await client.waitForDiagnostics(absolutePath, { + timeoutMs: DIAGNOSTICS_TIMEOUT_MS, + }); + if (result.timedOut) { + return { + content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`, + }; + } + return { content: result.formatted || "No diagnostics found." }; + } - // Query matching servers concurrently, each capped at 10s; - // a non-responding server is skipped with a notice. - const agg = await aggregateDiagnostics( - (id, root) => manager.getClient(id, root), - matching, - absolutePath, - DIAGNOSTICS_TIMEOUT_MS, - {}, - ); - return { content: agg.formatted || "No diagnostics found." }; - } - case "hover": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/hover", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - }); - if (!result) return { content: "No hover information available." }; - const hover = result as { readonly contents?: { readonly value?: string } | string }; - const content = - typeof hover.contents === "string" - ? hover.contents - : (hover.contents?.value ?? "No hover information available."); - return { content }; - } - case "definition": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/definition", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - }); - if (!result) return { content: "No definition found." }; - return { content: JSON.stringify(result) }; - } - case "references": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/references", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - context: { includeDeclaration: true }, - }); - if (!result) return { content: "No references found." }; - return { content: JSON.stringify(result) }; - } - case "documentSymbol": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/documentSymbol", { - textDocument: { uri: `file://${absolutePath}` }, - }); - if (!result) return { content: "No symbols found." }; - return { content: JSON.stringify(result) }; - } - } - } catch (err: unknown) { - return { - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - }, - }; + // Query matching servers concurrently, each capped at 10s; + // a non-responding server is skipped with a notice. + const agg = await aggregateDiagnostics( + (id, root) => manager.getClient(id, root), + matching, + absolutePath, + DIAGNOSTICS_TIMEOUT_MS, + {}, + ); + return { content: agg.formatted || "No diagnostics found." }; + } + case "hover": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/hover", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No hover information available." }; + const hover = result as { readonly contents?: { readonly value?: string } | string }; + const content = + typeof hover.contents === "string" + ? hover.contents + : (hover.contents?.value ?? "No hover information available."); + return { content }; + } + case "definition": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/definition", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No definition found." }; + return { content: JSON.stringify(result) }; + } + case "references": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/references", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + context: { includeDeclaration: true }, + }); + if (!result) return { content: "No references found." }; + return { content: JSON.stringify(result) }; + } + case "documentSymbol": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/documentSymbol", { + textDocument: { uri: `file://${absolutePath}` }, + }); + if (!result) return { content: "No symbols found." }; + return { content: JSON.stringify(result) }; + } + } + } catch (err: unknown) { + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; } /** @@ -266,22 +266,22 @@ export function createLspTool(manager: LspManager): ToolContract { * Used by hover/definition/references/documentSymbol (single-server ops). */ async function getFirstMatchingClient( - manager: LspManager, - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: string; - }[], - fileExt: string, + manager: LspManager, + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + }[], + fileExt: string, ): Promise< - { readonly request: (method: string, params?: unknown) => Promise } | undefined + { readonly request: (method: string, params?: unknown) => Promise } | undefined > { - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); - const target = matching[0] ?? statuses.find((s) => s.state === "connected"); - if (!target) return undefined; - return manager.getClient(target.id, target.root); + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + const target = matching[0] ?? statuses.find((s) => s.state === "connected"); + if (!target) return undefined; + return manager.getClient(target.id, target.root); } diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts index 1f72bdf..40d2ced 100644 --- a/packages/lsp/src/types.ts +++ b/packages/lsp/src/types.ts @@ -5,53 +5,53 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; export interface LspServerStatus { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: LspServerState; - readonly error?: string | undefined; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). - * Mirrors the wire `LspServerInfo.configSource` so a broken config file - * names itself in the status response. - */ - readonly configSource?: string | undefined; + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: LspServerState; + readonly error?: string | undefined; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). + * Mirrors the wire `LspServerInfo.configSource` so a broken config file + * names itself in the status response. + */ + readonly configSource?: string | undefined; } export interface DiagnosticsResult { - /** Formatted diagnostic string (filtered by minSeverity). Empty if none. */ - readonly formatted: string; - /** True if diagnostics took >10s. */ - readonly slow: boolean; - /** True if the 60s timeout was hit before all servers responded. */ - readonly timedOut: boolean; + /** Formatted diagnostic string (filtered by minSeverity). Empty if none. */ + readonly formatted: string; + /** True if diagnostics took >10s. */ + readonly slow: boolean; + /** True if the 60s timeout was hit before all servers responded. */ + readonly timedOut: boolean; } export interface GetDiagnosticsOpts { - readonly filePath: string; - /** Post-edit buffer content. If omitted, the server reads from disk. */ - readonly text?: string; - readonly cwd: string; - readonly timeoutMs?: number; - /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). Omit for all. */ - readonly minSeverity?: number; + readonly filePath: string; + /** Post-edit buffer content. If omitted, the server reads from disk. */ + readonly text?: string; + readonly cwd: string; + readonly timeoutMs?: number; + /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). Omit for all. */ + readonly minSeverity?: number; } export interface LspService { - /** - * Resolve the language servers configured for `cwd`, ensure each is spawned + - * initialized (lazy connect), and report live state. Never throws for a single - * server's failure — reflect it as state:"error" with a short `error`. - */ - status(cwd: string): Promise; + /** + * Resolve the language servers configured for `cwd`, ensure each is spawned + + * initialized (lazy connect), and report live state. Never throws for a single + * server's failure — reflect it as state:"error" with a short `error`. + */ + status(cwd: string): Promise; - /** - * Query ALL connected language servers matching the file's extension for - * diagnostics. Merges results tagged by source server. Sends didOpen/didChange - * with the provided text (post-edit buffer) so the server checks the in-memory - * version, not stale disk content. - */ - getDiagnostics(opts: GetDiagnosticsOpts): Promise; + /** + * Query ALL connected language servers matching the file's extension for + * diagnostics. Merges results tagged by source server. Sends didOpen/didChange + * with the provided text (post-edit buffer) so the server checks the in-memory + * version, not stale disk content. + */ + getDiagnostics(opts: GetDiagnosticsOpts): Promise; } diff --git a/packages/lsp/src/watched-files.test.ts b/packages/lsp/src/watched-files.test.ts index 4e598e1..46298ad 100644 --- a/packages/lsp/src/watched-files.test.ts +++ b/packages/lsp/src/watched-files.test.ts @@ -2,80 +2,80 @@ import { describe, expect, it } from "vitest"; import { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; describe("watched-files", () => { - it("register stores workspace/didChangeWatchedFiles watchers", () => { - const registry = new WatchedFilesRegistry(); + it("register stores workspace/didChangeWatchedFiles watchers", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], + }, + }); - const watchers = registry.getAllWatchers(); - expect(watchers).toHaveLength(2); - expect(watchers[0]?.globPattern).toBe("**/*.luau"); - expect(watchers[1]?.globPattern).toBe("sourcemap.json"); - }); + const watchers = registry.getAllWatchers(); + expect(watchers).toHaveLength(2); + expect(watchers[0]?.globPattern).toBe("**/*.luau"); + expect(watchers[1]?.globPattern).toBe("sourcemap.json"); + }); - it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { - const registry = new WatchedFilesRegistry(); + it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); - expect(registry.matches("src/main.luau")).toBe(true); - expect(registry.matches("src/nested/deep/file.luau")).toBe(true); - expect(registry.matches("src/main.ts")).toBe(false); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/nested/deep/file.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); - expect(FileChangeType.Created).toBe(1); - expect(FileChangeType.Changed).toBe(2); - expect(FileChangeType.Deleted).toBe(3); - }); + expect(FileChangeType.Created).toBe(1); + expect(FileChangeType.Changed).toBe(2); + expect(FileChangeType.Deleted).toBe(3); + }); - it("unregisterCapability stops forwarding", () => { - const registry = new WatchedFilesRegistry(); + it("unregisterCapability stops forwarding", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); - expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.luau")).toBe(true); - registry.applyUnregister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - }); + registry.applyUnregister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + }); - expect(registry.matches("src/main.luau")).toBe(false); - expect(registry.getAllWatchers()).toHaveLength(0); - }); + expect(registry.matches("src/main.luau")).toBe(false); + expect(registry.getAllWatchers()).toHaveLength(0); + }); - it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { - // **/*.luau - expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); - expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); - expect(globMatch("**/*.luau", "file.luau")).toBe(true); - expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); + it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { + // **/*.luau + expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); + expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); + expect(globMatch("**/*.luau", "file.luau")).toBe(true); + expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); - // sourcemap.json (literal) - expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); - expect(globMatch("sourcemap.json", "other.json")).toBe(false); + // sourcemap.json (literal) + expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("sourcemap.json", "other.json")).toBe(false); - // **/sourcemap.json - expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); - }); + // **/sourcemap.json + expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); + }); }); diff --git a/packages/lsp/src/watched-files.ts b/packages/lsp/src/watched-files.ts index e23df89..db010b7 100644 --- a/packages/lsp/src/watched-files.ts +++ b/packages/lsp/src/watched-files.ts @@ -10,101 +10,101 @@ */ export interface FileSystemWatcher { - readonly globPattern: string; - readonly kind?: number; + readonly globPattern: string; + readonly kind?: number; } export interface DidChangeWatchedFilesRegistrationOptions { - readonly watchers: readonly FileSystemWatcher[]; + readonly watchers: readonly FileSystemWatcher[]; } export interface Registration { - readonly id: string; - readonly method: string; - readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; + readonly id: string; + readonly method: string; + readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; } export const FileChangeType = { - Created: 1, - Changed: 2, - Deleted: 3, + Created: 1, + Changed: 2, + Deleted: 3, } as const; export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType]; export class WatchedFilesRegistry { - private watchers = new Map(); + private watchers = new Map(); - applyRegister(registration: Registration): void { - if (registration.method !== "workspace/didChangeWatchedFiles") return; - const opts = registration.registerOptions; - if (!opts?.watchers) return; - this.watchers.set(registration.id, opts.watchers); - } + applyRegister(registration: Registration): void { + if (registration.method !== "workspace/didChangeWatchedFiles") return; + const opts = registration.registerOptions; + if (!opts?.watchers) return; + this.watchers.set(registration.id, opts.watchers); + } - applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { - if (unregistration.method !== "workspace/didChangeWatchedFiles") return; - this.watchers.delete(unregistration.id); - } + applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { + if (unregistration.method !== "workspace/didChangeWatchedFiles") return; + this.watchers.delete(unregistration.id); + } - matches(filePath: string): boolean { - for (const watchers of this.watchers.values()) { - for (const w of watchers) { - if (globMatch(w.globPattern, filePath)) return true; - } - } - return false; - } + matches(filePath: string): boolean { + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + if (globMatch(w.globPattern, filePath)) return true; + } + } + return false; + } - getAllWatchers(): readonly FileSystemWatcher[] { - const result: FileSystemWatcher[] = []; - for (const watchers of this.watchers.values()) { - for (const w of watchers) { - result.push(w); - } - } - return result; - } + getAllWatchers(): readonly FileSystemWatcher[] { + const result: FileSystemWatcher[] = []; + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + result.push(w); + } + } + return result; + } } export function globMatch(pattern: string, filePath: string): boolean { - const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); - const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); - const regex = globToRegex(normalizedPattern); - return regex.test(normalizedPath); + const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); + const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); + const regex = globToRegex(normalizedPattern); + return regex.test(normalizedPath); } function globToRegex(glob: string): RegExp { - let regex = "^"; - let i = 0; - while (i < glob.length) { - const ch = glob[i] ?? ""; - if (ch === "*" && glob[i + 1] === "*") { - if (glob[i + 2] === "/") { - regex += "(?:.+/)?"; - i += 3; - } else { - regex += ".*"; - i += 2; - } - } else if (ch === "*") { - regex += "[^/]*"; - i++; - } else if (ch === "?") { - regex += "[^/]"; - i++; - } else if (ch === ".") { - regex += "\\."; - i++; - } else { - regex += escapeRegex(ch); - i++; - } - } - regex += "$"; - return new RegExp(regex); + let regex = "^"; + let i = 0; + while (i < glob.length) { + const ch = glob[i] ?? ""; + if (ch === "*" && glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + regex += "(?:.+/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + } else if (ch === "*") { + regex += "[^/]*"; + i++; + } else if (ch === "?") { + regex += "[^/]"; + i++; + } else if (ch === ".") { + regex += "\\."; + i++; + } else { + regex += escapeRegex(ch); + i++; + } + } + regex += "$"; + return new RegExp(regex); } function escapeRegex(ch: string): string { - return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/lsp/tsconfig.json b/packages/lsp/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/lsp/tsconfig.json +++ b/packages/lsp/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 9f862fa..8a3f47d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/mcp", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*" - } + "name": "@dispatch/mcp", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*" + } } diff --git a/packages/mcp/src/client.test.ts b/packages/mcp/src/client.test.ts index 695bdcc..ca8a11b 100644 --- a/packages/mcp/src/client.test.ts +++ b/packages/mcp/src/client.test.ts @@ -3,204 +3,204 @@ import { McpClient } from "./client.js"; import type { Connection } from "./transport.js"; function makeMockConnection(): Connection & { - responses: Map; - feedResponse: (method: string, result: unknown) => void; - notifications: Array<{ method: string; params: unknown }>; + responses: Map; + feedResponse: (method: string, result: unknown) => void; + notifications: Array<{ method: string; params: unknown }>; } { - const responses = new Map(); - const pendingRequests = new Map void }>(); - let nextId = 1; - const notifications: Array<{ method: string; params: unknown }> = []; - const notificationHandlers = new Map void>(); - - return { - responses, - notifications, - feedResponse: (_method: string, _result: unknown) => {}, - send: (method: string, _params?: unknown) => { - const id = nextId++; - return new Promise((resolve) => { - pendingRequests.set(id, { method, resolve }); - // Auto-respond for initialize - if (method === "initialize") { - resolve({ - protocolVersion: "2025-11-25", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "test-server", version: "1.0.0" }, - }); - } else if (method === "tools/list") { - resolve({ - tools: [ - { - name: "test_tool", - description: "A test tool", - inputSchema: { type: "object", properties: { input: { type: "string" } } }, - }, - ], - }); - } else if (method === "tools/call") { - resolve({ - content: [{ type: "text", text: "result from tool" }], - isError: false, - }); - } - }); - }, - notify: (method: string, params?: unknown) => { - notifications.push({ method, params }); - }, - onNotification: (method: string, handler: (params: unknown) => void) => { - notificationHandlers.set(method, handler); - }, - close: () => {}, - pid: 999, - }; + const responses = new Map(); + const pendingRequests = new Map void }>(); + let nextId = 1; + const notifications: Array<{ method: string; params: unknown }> = []; + const notificationHandlers = new Map void>(); + + return { + responses, + notifications, + feedResponse: (_method: string, _result: unknown) => {}, + send: (method: string, _params?: unknown) => { + const id = nextId++; + return new Promise((resolve) => { + pendingRequests.set(id, { method, resolve }); + // Auto-respond for initialize + if (method === "initialize") { + resolve({ + protocolVersion: "2025-11-25", + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }); + } else if (method === "tools/list") { + resolve({ + tools: [ + { + name: "test_tool", + description: "A test tool", + inputSchema: { type: "object", properties: { input: { type: "string" } } }, + }, + ], + }); + } else if (method === "tools/call") { + resolve({ + content: [{ type: "text", text: "result from tool" }], + isError: false, + }); + } + }); + }, + notify: (method: string, params?: unknown) => { + notifications.push({ method, params }); + }, + onNotification: (method: string, handler: (params: unknown) => void) => { + notificationHandlers.set(method, handler); + }, + close: () => {}, + pid: 999, + }; } describe("McpClient", () => { - it("initialize sends correct protocolVersion + capabilities", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - const result = await client.initialize(); - - expect(result.protocolVersion).toBe("2025-11-25"); - expect(result.capabilities.tools?.listChanged).toBe(true); - expect(result.serverInfo.name).toBe("test-server"); - expect(client.getState()).toBe("connected"); + it("initialize sends correct protocolVersion + capabilities", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + const result = await client.initialize(); + + expect(result.protocolVersion).toBe("2025-11-25"); + expect(result.capabilities.tools?.listChanged).toBe(true); + expect(result.serverInfo.name).toBe("test-server"); + expect(client.getState()).toBe("connected"); - // Should have sent notifications/initialized - expect(conn.notifications.length).toBe(1); - expect(conn.notifications[0].method).toBe("notifications/initialized"); - }); - - it("listTools returns parsed tools", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await client.initialize(); - const tools = await client.listTools(); - - expect(tools.length).toBe(1); - expect(tools[0].name).toBe("test_tool"); - expect(tools[0].description).toBe("A test tool"); - }); - - it("callTool sends name + arguments", async () => { - const conn = makeMockConnection(); - let callParams: unknown = null; - const origSend = conn.send.bind(conn); - conn.send = (method: string, params?: unknown) => { - if (method === "tools/call") callParams = params; - return origSend(method, params); - }; - - const client = new McpClient({ connection: conn }); - - await client.initialize(); - const result = await client.callTool("test_tool", { input: "hello" }); - - expect(callParams).toEqual({ name: "test_tool", arguments: { input: "hello" } }); - expect(result.content).toEqual([{ type: "text", text: "result from tool" }]); - expect(result.isError).toBe(false); - }); - - it("list_changed triggers re-list", async () => { - const conn = makeMockConnection(); - const notificationHandlers = new Map void>(); - conn.onNotification = (method: string, handler: (params: unknown) => void) => { - notificationHandlers.set(method, handler); - }; - - const client = new McpClient({ connection: conn }); - - let toolsChangedFired = false; - client.onToolsChanged(() => { - toolsChangedFired = true; - }); - - await client.initialize(); - - // Simulate list_changed notification - const handler = notificationHandlers.get("notifications/tools/list_changed"); - expect(handler).toBeDefined(); - handler?.(undefined); - - expect(toolsChangedFired).toBe(true); - }); - - it("handles server error on initialize", async () => { - const conn = makeMockConnection(); - conn.send = (method: string) => { - if (method === "initialize") { - return Promise.reject(new Error("Server startup failed")); - } - return Promise.resolve({}); - }; - - const client = new McpClient({ connection: conn }); - - await expect(client.initialize()).rejects.toThrow("Server startup failed"); - expect(client.getState()).toBe("error"); - }); - - it("callTool rejects when not connected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await expect(client.callTool("test", {})).rejects.toThrow("Client not connected"); - }); - - it("listTools rejects when not connected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await expect(client.listTools()).rejects.toThrow("Client not connected"); - }); - - it("close sets state to disconnected", async () => { - const conn = makeMockConnection(); - const client = new McpClient({ connection: conn }); - - await client.initialize(); - expect(client.getState()).toBe("connected"); - - client.close(); - expect(client.getState()).toBe("disconnected"); - }); - - it("callTool with abort signal", async () => { - const conn = makeMockConnection(); - let resolveRequest: ((v: unknown) => void) | null = null; - conn.send = (method: string) => { - if (method === "tools/call") { - return new Promise((resolve) => { - resolveRequest = resolve; - }); - } - if (method === "initialize") { - return Promise.resolve({ - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test", version: "1.0.0" }, - }); - } - return Promise.resolve({}); - }; - - const client = new McpClient({ connection: conn }); - await client.initialize(); - - const controller = new AbortController(); - const callPromise = client.callTool("test", {}, controller.signal); - - controller.abort(); - - await expect(callPromise).rejects.toThrow("Aborted"); - - // Clean up - resolveRequest?.({ - content: [{ type: "text", text: "too late" }], - }); - }); + // Should have sent notifications/initialized + expect(conn.notifications.length).toBe(1); + expect(conn.notifications[0].method).toBe("notifications/initialized"); + }); + + it("listTools returns parsed tools", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await client.initialize(); + const tools = await client.listTools(); + + expect(tools.length).toBe(1); + expect(tools[0].name).toBe("test_tool"); + expect(tools[0].description).toBe("A test tool"); + }); + + it("callTool sends name + arguments", async () => { + const conn = makeMockConnection(); + let callParams: unknown = null; + const origSend = conn.send.bind(conn); + conn.send = (method: string, params?: unknown) => { + if (method === "tools/call") callParams = params; + return origSend(method, params); + }; + + const client = new McpClient({ connection: conn }); + + await client.initialize(); + const result = await client.callTool("test_tool", { input: "hello" }); + + expect(callParams).toEqual({ name: "test_tool", arguments: { input: "hello" } }); + expect(result.content).toEqual([{ type: "text", text: "result from tool" }]); + expect(result.isError).toBe(false); + }); + + it("list_changed triggers re-list", async () => { + const conn = makeMockConnection(); + const notificationHandlers = new Map void>(); + conn.onNotification = (method: string, handler: (params: unknown) => void) => { + notificationHandlers.set(method, handler); + }; + + const client = new McpClient({ connection: conn }); + + let toolsChangedFired = false; + client.onToolsChanged(() => { + toolsChangedFired = true; + }); + + await client.initialize(); + + // Simulate list_changed notification + const handler = notificationHandlers.get("notifications/tools/list_changed"); + expect(handler).toBeDefined(); + handler?.(undefined); + + expect(toolsChangedFired).toBe(true); + }); + + it("handles server error on initialize", async () => { + const conn = makeMockConnection(); + conn.send = (method: string) => { + if (method === "initialize") { + return Promise.reject(new Error("Server startup failed")); + } + return Promise.resolve({}); + }; + + const client = new McpClient({ connection: conn }); + + await expect(client.initialize()).rejects.toThrow("Server startup failed"); + expect(client.getState()).toBe("error"); + }); + + it("callTool rejects when not connected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await expect(client.callTool("test", {})).rejects.toThrow("Client not connected"); + }); + + it("listTools rejects when not connected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await expect(client.listTools()).rejects.toThrow("Client not connected"); + }); + + it("close sets state to disconnected", async () => { + const conn = makeMockConnection(); + const client = new McpClient({ connection: conn }); + + await client.initialize(); + expect(client.getState()).toBe("connected"); + + client.close(); + expect(client.getState()).toBe("disconnected"); + }); + + it("callTool with abort signal", async () => { + const conn = makeMockConnection(); + let resolveRequest: ((v: unknown) => void) | null = null; + conn.send = (method: string) => { + if (method === "tools/call") { + return new Promise((resolve) => { + resolveRequest = resolve; + }); + } + if (method === "initialize") { + return Promise.resolve({ + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test", version: "1.0.0" }, + }); + } + return Promise.resolve({}); + }; + + const client = new McpClient({ connection: conn }); + await client.initialize(); + + const controller = new AbortController(); + const callPromise = client.callTool("test", {}, controller.signal); + + controller.abort(); + + await expect(callPromise).rejects.toThrow("Aborted"); + + // Clean up + resolveRequest?.({ + content: [{ type: "text", text: "too late" }], + }); + }); }); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 17463d9..d5d06fc 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -7,117 +7,117 @@ import type { Connection } from "./transport.js"; import type { - McpCallResult, - McpInitializeResult, - McpListToolsResult, - McpServerCapabilities, - McpToolInfo, + McpCallResult, + McpInitializeResult, + McpListToolsResult, + McpServerCapabilities, + McpToolInfo, } from "./types.js"; export type McpClientState = "disconnected" | "connecting" | "connected" | "error"; export interface McpClientDeps { - readonly connection: Connection; + readonly connection: Connection; } export class McpClient { - private state: McpClientState = "disconnected"; - private capabilities: McpServerCapabilities = {}; - private tools: readonly McpToolInfo[] = []; - private connection: Connection; - private toolsChangedHandler: (() => void) | null = null; - - constructor(deps: McpClientDeps) { - this.connection = deps.connection; - } - - getState(): McpClientState { - return this.state; - } - - getCapabilities(): McpServerCapabilities { - return this.capabilities; - } - - getTools(): readonly McpToolInfo[] { - return this.tools; - } - - onToolsChanged(handler: () => void): void { - this.toolsChangedHandler = handler; - } - - async initialize(): Promise { - this.state = "connecting"; - try { - const result = (await this.connection.send("initialize", { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "dispatch", version: "0.0.0" }, - })) as McpInitializeResult; - - this.capabilities = result.capabilities; - this.connection.notify("notifications/initialized", {}); - - this.connection.onNotification("notifications/tools/list_changed", () => { - if (this.toolsChangedHandler) { - this.toolsChangedHandler(); - } - }); - - this.state = "connected"; - return result; - } catch (err: unknown) { - this.state = "error"; - throw err; - } - } - - async listTools(): Promise { - if (this.state !== "connected") { - throw new Error("Client not connected"); - } - const result = (await this.connection.send("tools/list")) as McpListToolsResult; - this.tools = result.tools; - return this.tools; - } - - async callTool(name: string, args: unknown, signal?: AbortSignal): Promise { - if (this.state !== "connected") { - throw new Error("Client not connected"); - } - - if (signal?.aborted) { - throw new Error("Aborted"); - } - - const resultPromise = this.connection.send("tools/call", { - name, - arguments: args, - }) as Promise; - - if (!signal) { - return resultPromise; - } - - return new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("Aborted")); - signal.addEventListener("abort", onAbort, { once: true }); - resultPromise.then( - (result) => { - signal.removeEventListener("abort", onAbort); - resolve(result); - }, - (err) => { - signal.removeEventListener("abort", onAbort); - reject(err); - }, - ); - }); - } - - close(): void { - this.state = "disconnected"; - this.connection.close(); - } + private state: McpClientState = "disconnected"; + private capabilities: McpServerCapabilities = {}; + private tools: readonly McpToolInfo[] = []; + private connection: Connection; + private toolsChangedHandler: (() => void) | null = null; + + constructor(deps: McpClientDeps) { + this.connection = deps.connection; + } + + getState(): McpClientState { + return this.state; + } + + getCapabilities(): McpServerCapabilities { + return this.capabilities; + } + + getTools(): readonly McpToolInfo[] { + return this.tools; + } + + onToolsChanged(handler: () => void): void { + this.toolsChangedHandler = handler; + } + + async initialize(): Promise { + this.state = "connecting"; + try { + const result = (await this.connection.send("initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "dispatch", version: "0.0.0" }, + })) as McpInitializeResult; + + this.capabilities = result.capabilities; + this.connection.notify("notifications/initialized", {}); + + this.connection.onNotification("notifications/tools/list_changed", () => { + if (this.toolsChangedHandler) { + this.toolsChangedHandler(); + } + }); + + this.state = "connected"; + return result; + } catch (err: unknown) { + this.state = "error"; + throw err; + } + } + + async listTools(): Promise { + if (this.state !== "connected") { + throw new Error("Client not connected"); + } + const result = (await this.connection.send("tools/list")) as McpListToolsResult; + this.tools = result.tools; + return this.tools; + } + + async callTool(name: string, args: unknown, signal?: AbortSignal): Promise { + if (this.state !== "connected") { + throw new Error("Client not connected"); + } + + if (signal?.aborted) { + throw new Error("Aborted"); + } + + const resultPromise = this.connection.send("tools/call", { + name, + arguments: args, + }) as Promise; + + if (!signal) { + return resultPromise; + } + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("Aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + resultPromise.then( + (result) => { + signal.removeEventListener("abort", onAbort); + resolve(result); + }, + (err) => { + signal.removeEventListener("abort", onAbort); + reject(err); + }, + ); + }); + } + + close(): void { + this.state = "disconnected"; + this.connection.close(); + } } diff --git a/packages/mcp/src/config.test.ts b/packages/mcp/src/config.test.ts index 38c9b50..c83061f 100644 --- a/packages/mcp/src/config.test.ts +++ b/packages/mcp/src/config.test.ts @@ -2,113 +2,113 @@ import { describe, expect, it } from "vitest"; import { resolveServers } from "./config.js"; describe("resolveServers", () => { - it("resolves from .dispatch/mcp.json", () => { - const dispatchConfig = JSON.stringify({ - servers: { - freecad: { command: "uvx", args: ["freecad-mcp"], env: { KEY: "val" } }, - }, - }); - - const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); - - expect(result.servers.length).toBe(1); - expect(result.servers[0].id).toBe("freecad"); - expect(result.servers[0].command).toEqual(["uvx", "freecad-mcp"]); - expect(result.servers[0].env).toEqual({ KEY: "val" }); - expect(result.servers[0].configSource).toBe(".dispatch/mcp.json"); - expect(result.shadowed).toBe(false); - }); - - it("falls back to opencode.json mcp key", () => { - const opencodeConfig = JSON.stringify({ - mcp: { - chrome: { command: "npx", args: ["chrome-devtools-mcp@latest"] }, - }, - }); - - const result = resolveServers({ dispatchMcpJson: null, opencodeJson: opencodeConfig }); - - expect(result.servers.length).toBe(1); - expect(result.servers[0].id).toBe("chrome"); - expect(result.servers[0].command).toEqual(["npx", "chrome-devtools-mcp@latest"]); - expect(result.servers[0].configSource).toBe("opencode.json"); - expect(result.shadowed).toBe(false); - }); - - it("shadow warning when both present", () => { - const dispatchConfig = JSON.stringify({ - servers: { - freecad: { command: "uvx", args: ["freecad-mcp"] }, - }, - }); - const opencodeConfig = JSON.stringify({ - mcp: { - chrome: { command: "npx", args: ["chrome-devtools-mcp@latest"] }, - }, - }); - - const result = resolveServers({ - dispatchMcpJson: dispatchConfig, - opencodeJson: opencodeConfig, - }); - - expect(result.servers.length).toBe(1); - expect(result.servers[0].id).toBe("freecad"); - expect(result.shadowed).toBe(true); - }); - - it("empty when neither present", () => { - const result = resolveServers({ dispatchMcpJson: null, opencodeJson: null }); - - expect(result.servers.length).toBe(0); - expect(result.shadowed).toBe(false); - }); - - it("empty when dispatch has no servers key", () => { - const result = resolveServers({ - dispatchMcpJson: JSON.stringify({}), - opencodeJson: null, - }); - - expect(result.servers.length).toBe(0); - expect(result.shadowed).toBe(false); - }); - - it("handles malformed JSON gracefully", () => { - const result = resolveServers({ - dispatchMcpJson: "not valid json", - opencodeJson: "{ also bad", - }); - - expect(result.servers.length).toBe(0); - expect(result.shadowed).toBe(false); - }); - - it("server without args", () => { - const dispatchConfig = JSON.stringify({ - servers: { - simple: { command: "my-server" }, - }, - }); - - const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); - - expect(result.servers.length).toBe(1); - expect(result.servers[0].command).toEqual(["my-server"]); - }); - - it("multiple servers from dispatch", () => { - const dispatchConfig = JSON.stringify({ - servers: { - a: { command: "server-a" }, - b: { command: "server-b", args: ["--port", "3000"] }, - }, - }); - - const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); - - expect(result.servers.length).toBe(2); - const ids = result.servers.map((s) => s.id).sort(); - expect(ids).toEqual(["a", "b"]); - }); + it("resolves from .dispatch/mcp.json", () => { + const dispatchConfig = JSON.stringify({ + servers: { + freecad: { command: "uvx", args: ["freecad-mcp"], env: { KEY: "val" } }, + }, + }); + + const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); + + expect(result.servers.length).toBe(1); + expect(result.servers[0].id).toBe("freecad"); + expect(result.servers[0].command).toEqual(["uvx", "freecad-mcp"]); + expect(result.servers[0].env).toEqual({ KEY: "val" }); + expect(result.servers[0].configSource).toBe(".dispatch/mcp.json"); + expect(result.shadowed).toBe(false); + }); + + it("falls back to opencode.json mcp key", () => { + const opencodeConfig = JSON.stringify({ + mcp: { + chrome: { command: "npx", args: ["chrome-devtools-mcp@latest"] }, + }, + }); + + const result = resolveServers({ dispatchMcpJson: null, opencodeJson: opencodeConfig }); + + expect(result.servers.length).toBe(1); + expect(result.servers[0].id).toBe("chrome"); + expect(result.servers[0].command).toEqual(["npx", "chrome-devtools-mcp@latest"]); + expect(result.servers[0].configSource).toBe("opencode.json"); + expect(result.shadowed).toBe(false); + }); + + it("shadow warning when both present", () => { + const dispatchConfig = JSON.stringify({ + servers: { + freecad: { command: "uvx", args: ["freecad-mcp"] }, + }, + }); + const opencodeConfig = JSON.stringify({ + mcp: { + chrome: { command: "npx", args: ["chrome-devtools-mcp@latest"] }, + }, + }); + + const result = resolveServers({ + dispatchMcpJson: dispatchConfig, + opencodeJson: opencodeConfig, + }); + + expect(result.servers.length).toBe(1); + expect(result.servers[0].id).toBe("freecad"); + expect(result.shadowed).toBe(true); + }); + + it("empty when neither present", () => { + const result = resolveServers({ dispatchMcpJson: null, opencodeJson: null }); + + expect(result.servers.length).toBe(0); + expect(result.shadowed).toBe(false); + }); + + it("empty when dispatch has no servers key", () => { + const result = resolveServers({ + dispatchMcpJson: JSON.stringify({}), + opencodeJson: null, + }); + + expect(result.servers.length).toBe(0); + expect(result.shadowed).toBe(false); + }); + + it("handles malformed JSON gracefully", () => { + const result = resolveServers({ + dispatchMcpJson: "not valid json", + opencodeJson: "{ also bad", + }); + + expect(result.servers.length).toBe(0); + expect(result.shadowed).toBe(false); + }); + + it("server without args", () => { + const dispatchConfig = JSON.stringify({ + servers: { + simple: { command: "my-server" }, + }, + }); + + const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); + + expect(result.servers.length).toBe(1); + expect(result.servers[0].command).toEqual(["my-server"]); + }); + + it("multiple servers from dispatch", () => { + const dispatchConfig = JSON.stringify({ + servers: { + a: { command: "server-a" }, + b: { command: "server-b", args: ["--port", "3000"] }, + }, + }); + + const result = resolveServers({ dispatchMcpJson: dispatchConfig, opencodeJson: null }); + + expect(result.servers.length).toBe(2); + const ids = result.servers.map((s) => s.id).sort(); + expect(ids).toEqual(["a", "b"]); + }); }); diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts index 20ed749..599f414 100644 --- a/packages/mcp/src/config.ts +++ b/packages/mcp/src/config.ts @@ -11,79 +11,79 @@ import type { McpServerConfig, ResolvedMcpServer, ResolveResult } from "./types.js"; export interface ResolveServersDeps { - readonly dispatchMcpJson: string | null; - readonly opencodeJson: string | null; + readonly dispatchMcpJson: string | null; + readonly opencodeJson: string | null; } export interface DispatchMcpConfig { - readonly servers?: Readonly>; + readonly servers?: Readonly>; } export interface OpencodeJsonConfig { - readonly mcp?: Readonly>; + readonly mcp?: Readonly>; } export function resolveServers(deps: ResolveServersDeps): ResolveResult { - const result = new Map(); + const result = new Map(); - // Parse opencode.json once — used both as the fallback source and to detect - // whether a present `.dispatch/mcp.json` silently shadows its `mcp` key. - let opencodeConfig: OpencodeJsonConfig | null = null; - if (deps.opencodeJson) { - try { - opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; - } catch { - // ignore parse errors - } - } - const opencodeHasMcp = !!opencodeConfig?.mcp && Object.keys(opencodeConfig.mcp).length > 0; + // Parse opencode.json once — used both as the fallback source and to detect + // whether a present `.dispatch/mcp.json` silently shadows its `mcp` key. + let opencodeConfig: OpencodeJsonConfig | null = null; + if (deps.opencodeJson) { + try { + opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; + } catch { + // ignore parse errors + } + } + const opencodeHasMcp = !!opencodeConfig?.mcp && Object.keys(opencodeConfig.mcp).length > 0; - // 1. cwd/.dispatch/mcp.json (highest precedence) - let dispatchHadServers = false; - if (deps.dispatchMcpJson) { - try { - const config = JSON.parse(deps.dispatchMcpJson) as DispatchMcpConfig; - if (config.servers) { - for (const [key, server] of Object.entries(config.servers)) { - const resolved = resolveServer(key, server, ".dispatch/mcp.json"); - result.set(resolved.id, resolved); - } - dispatchHadServers = result.size > 0; - } - } catch { - // ignore parse errors - } - } + // 1. cwd/.dispatch/mcp.json (highest precedence) + let dispatchHadServers = false; + if (deps.dispatchMcpJson) { + try { + const config = JSON.parse(deps.dispatchMcpJson) as DispatchMcpConfig; + if (config.servers) { + for (const [key, server] of Object.entries(config.servers)) { + const resolved = resolveServer(key, server, ".dispatch/mcp.json"); + result.set(resolved.id, resolved); + } + dispatchHadServers = result.size > 0; + } + } catch { + // ignore parse errors + } + } - // 2. fallback cwd/opencode.json mcp key (only when dispatch yielded nothing) - if (result.size === 0 && opencodeConfig?.mcp) { - for (const [key, server] of Object.entries(opencodeConfig.mcp)) { - const resolved = resolveServer(key, server, "opencode.json"); - result.set(resolved.id, resolved); - } - } + // 2. fallback cwd/opencode.json mcp key (only when dispatch yielded nothing) + if (result.size === 0 && opencodeConfig?.mcp) { + for (const [key, server] of Object.entries(opencodeConfig.mcp)) { + const resolved = resolveServer(key, server, "opencode.json"); + result.set(resolved.id, resolved); + } + } - // No built-in servers — MCP has no built-in registry. + // No built-in servers — MCP has no built-in registry. - // `.dispatch/mcp.json` silently shadows `opencode.json`'s mcp key when both - // declare servers — the opencode entry is skipped with no warning otherwise. - const shadowed = dispatchHadServers && opencodeHasMcp; - return { servers: [...result.values()], shadowed }; + // `.dispatch/mcp.json` silently shadows `opencode.json`'s mcp key when both + // declare servers — the opencode entry is skipped with no warning otherwise. + const shadowed = dispatchHadServers && opencodeHasMcp; + return { servers: [...result.values()], shadowed }; } function resolveServer( - key: string, - config: McpServerConfig, - configSource: ".dispatch/mcp.json" | "opencode.json", + key: string, + config: McpServerConfig, + configSource: ".dispatch/mcp.json" | "opencode.json", ): ResolvedMcpServer { - const command = [config.command, ...(config.args ?? [])]; - const result: ResolvedMcpServer = { - id: key, - command, - configSource, - }; - if (config.env) { - (result as { env?: Readonly> }).env = config.env; - } - return result; + const command = [config.command, ...(config.args ?? [])]; + const result: ResolvedMcpServer = { + id: key, + command, + configSource, + }; + if (config.env) { + (result as { env?: Readonly> }).env = config.env; + } + return result; } diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts index 75515fb..b937c2b 100644 --- a/packages/mcp/src/extension.test.ts +++ b/packages/mcp/src/extension.test.ts @@ -11,67 +11,67 @@ import type { McpToolInfo } from "./types.js"; // --------------------------------------------------------------------------- const stubTool = (name: string): ToolContract => ({ - name, - description: "", - parameters: { type: "object" }, - execute: async () => ({ content: "" }), + name, + description: "", + parameters: { type: "object" }, + execute: async () => ({ content: "" }), }); describe("filterMcpTools (pure)", () => { - it("keeps non-MCP tools and connected-server tools, removes disconnected-server tools", () => { - const toolToServer = new Map([ - ["a__x", "a"], - ["b__y", "b"], - ]); - const connected = new Set(["a"]); - - const result = filterMcpTools( - { - tools: [stubTool("a__x"), stubTool("b__y"), stubTool("other")], - cwd: "/p", - conversationId: "c", - }, - toolToServer, - connected, - ); - - expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); - expect(result.cwd).toBe("/p"); - expect(result.conversationId).toBe("c"); - }); - - it("removes all MCP tools when no server is connected", () => { - const result = filterMcpTools( - { tools: [stubTool("a__x")], conversationId: "c" }, - new Map([["a__x", "a"]]), - new Set(), - ); - expect(result.tools).toHaveLength(0); - expect(result.conversationId).toBe("c"); - expect(result.cwd).toBeUndefined(); - expect(result.computerId).toBeUndefined(); - }); - - it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => { - const toolToServer = new Map([["a__x", "a"]]); - const connected = new Set(["a"]); - - const result = filterMcpTools( - { - tools: [stubTool("a__x"), stubTool("other")], - cwd: "/p", - computerId: "ssh-host", - conversationId: "c", - }, - toolToServer, - connected, - ); - - expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); - expect(result.computerId).toBe("ssh-host"); - expect(result.cwd).toBe("/p"); - expect(result.conversationId).toBe("c"); - }); + it("keeps non-MCP tools and connected-server tools, removes disconnected-server tools", () => { + const toolToServer = new Map([ + ["a__x", "a"], + ["b__y", "b"], + ]); + const connected = new Set(["a"]); + + const result = filterMcpTools( + { + tools: [stubTool("a__x"), stubTool("b__y"), stubTool("other")], + cwd: "/p", + conversationId: "c", + }, + toolToServer, + connected, + ); + + expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); + expect(result.cwd).toBe("/p"); + expect(result.conversationId).toBe("c"); + }); + + it("removes all MCP tools when no server is connected", () => { + const result = filterMcpTools( + { tools: [stubTool("a__x")], conversationId: "c" }, + new Map([["a__x", "a"]]), + new Set(), + ); + expect(result.tools).toHaveLength(0); + expect(result.conversationId).toBe("c"); + expect(result.cwd).toBeUndefined(); + expect(result.computerId).toBeUndefined(); + }); + + it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => { + const toolToServer = new Map([["a__x", "a"]]); + const connected = new Set(["a"]); + + const result = filterMcpTools( + { + tools: [stubTool("a__x"), stubTool("other")], + cwd: "/p", + computerId: "ssh-host", + conversationId: "c", + }, + toolToServer, + connected, + ); + + expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/p"); + expect(result.conversationId).toBe("c"); + }); }); // --------------------------------------------------------------------------- @@ -79,9 +79,9 @@ describe("filterMcpTools (pure)", () => { // --------------------------------------------------------------------------- interface FakeServer { - tools: McpToolInfo[]; - failInitialize: boolean; - emitListChanged: () => void; + tools: McpToolInfo[]; + failInitialize: boolean; + emitListChanged: () => void; } /** @@ -90,87 +90,87 @@ interface FakeServer { * transport → framing → rpc → client → manager end to end. */ function makeFakeSpawn(server: FakeServer): SpawnProcess { - const decoder = new FrameDecoder(); - let dataListeners: Array<(data: Uint8Array) => void> = []; - - const emit = (frame: Uint8Array) => { - for (const cb of dataListeners) cb(frame); - }; - - const spawn: SpawnProcess = (_command, _opts) => { - // Each spawn is a fresh process; reset listeners so a reconnect (after - // shutdown) doesn't feed closed rpc instances. - dataListeners = []; - const process: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - for (const msg of decoder.decode(bytes)) { - const parsed = JSON.parse(msg) as { - id?: number; - method?: string; - params?: unknown; - }; - const id = parsed.id ?? 0; - const method = parsed.method; - if (method === "initialize") { - if (server.failInitialize) { - emit( - encode( - JSON.stringify({ - jsonrpc: "2.0", - id, - error: { code: -32603, message: "initialize failed" }, - }), - ), - ); - } else { - emit( - encode( - JSON.stringify({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2025-11-25", - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: "fake", version: "0.0.0" }, - }, - }), - ), - ); - } - } else if (method === "tools/list") { - emit(encode(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: server.tools } }))); - } else if (method === "tools/call") { - emit( - encode( - JSON.stringify({ - jsonrpc: "2.0", - id, - result: { content: [{ type: "text", text: "ok" }], isError: false }, - }), - ), - ); - } - // notifications (notifications/initialized): no response. - } - }, - }, - stdout: { - on: (event: string, cb: (data: Uint8Array) => void) => { - if (event === "data") dataListeners.push(cb); - }, - }, - pid: 7000, - kill: () => {}, - }; - return process; - }; - - server.emitListChanged = () => { - emit(encode(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }))); - }; - - return spawn; + const decoder = new FrameDecoder(); + let dataListeners: Array<(data: Uint8Array) => void> = []; + + const emit = (frame: Uint8Array) => { + for (const cb of dataListeners) cb(frame); + }; + + const spawn: SpawnProcess = (_command, _opts) => { + // Each spawn is a fresh process; reset listeners so a reconnect (after + // shutdown) doesn't feed closed rpc instances. + dataListeners = []; + const process: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + for (const msg of decoder.decode(bytes)) { + const parsed = JSON.parse(msg) as { + id?: number; + method?: string; + params?: unknown; + }; + const id = parsed.id ?? 0; + const method = parsed.method; + if (method === "initialize") { + if (server.failInitialize) { + emit( + encode( + JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: -32603, message: "initialize failed" }, + }), + ), + ); + } else { + emit( + encode( + JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: "fake", version: "0.0.0" }, + }, + }), + ), + ); + } + } else if (method === "tools/list") { + emit(encode(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: server.tools } }))); + } else if (method === "tools/call") { + emit( + encode( + JSON.stringify({ + jsonrpc: "2.0", + id, + result: { content: [{ type: "text", text: "ok" }], isError: false }, + }), + ), + ); + } + // notifications (notifications/initialized): no response. + } + }, + }, + stdout: { + on: (event: string, cb: (data: Uint8Array) => void) => { + if (event === "data") dataListeners.push(cb); + }, + }, + pid: 7000, + kill: () => {}, + }; + return process; + }; + + server.emitListChanged = () => { + emit(encode(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }))); + }; + + return spawn; } // --------------------------------------------------------------------------- @@ -178,51 +178,51 @@ function makeFakeSpawn(server: FakeServer): SpawnProcess { // --------------------------------------------------------------------------- function makeFakeHost(): { - host: HostAPI; - tools: Map; - getFilter: () => ((a: ToolAssembly) => Promise) | null; - getService: () => unknown; + host: HostAPI; + tools: Map; + getFilter: () => ((a: ToolAssembly) => Promise) | null; + getService: () => unknown; } { - const tools = new Map(); - let filterFn: ((a: ToolAssembly) => Promise) | null = null; - let service: unknown = null; - - const noopSpan = { - id: "s", - log: {} as Logger, - setAttributes: () => {}, - addLink: () => {}, - child: () => noopSpan, - end: () => {}, - }; - const noopLogger: Logger = { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - child: () => noopLogger, - span: () => noopSpan, - }; - - const host = { - defineTool: (t: ToolContract) => { - tools.set(t.name, t); - }, - addFilter: (_hook: typeof toolsFilter, fn: (a: ToolAssembly) => Promise) => { - filterFn = fn; - return () => { - filterFn = null; - }; - }, - provideService: (_handle: unknown, impl: unknown) => { - service = impl; - }, - getService: () => service, - getTools: () => tools, - logger: noopLogger, - } as unknown as HostAPI; - - return { host, tools, getFilter: () => filterFn, getService: () => service }; + const tools = new Map(); + let filterFn: ((a: ToolAssembly) => Promise) | null = null; + let service: unknown = null; + + const noopSpan = { + id: "s", + log: {} as Logger, + setAttributes: () => {}, + addLink: () => {}, + child: () => noopSpan, + end: () => {}, + }; + const noopLogger: Logger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => noopLogger, + span: () => noopSpan, + }; + + const host = { + defineTool: (t: ToolContract) => { + tools.set(t.name, t); + }, + addFilter: (_hook: typeof toolsFilter, fn: (a: ToolAssembly) => Promise) => { + filterFn = fn; + return () => { + filterFn = null; + }; + }, + provideService: (_handle: unknown, impl: unknown) => { + service = impl; + }, + getService: () => service, + getTools: () => tools, + logger: noopLogger, + } as unknown as HostAPI; + + return { host, tools, getFilter: () => filterFn, getService: () => service }; } // --------------------------------------------------------------------------- @@ -232,132 +232,132 @@ function makeFakeHost(): { const dispatchConfig = (servers: Record): string => JSON.stringify({ servers }); const tool = (name: string, description = name): McpToolInfo => ({ - name, - description, - inputSchema: { type: "object" }, + name, + description, + inputSchema: { type: "object" }, }); const assembly = (tools: ToolContract[], cwd = "/proj"): ToolAssembly => ({ - tools, - cwd, - conversationId: "conv-1", + tools, + cwd, + conversationId: "conv-1", }); const flush = () => new Promise((r) => setTimeout(r, 0)); function makeServer(initialTools: McpToolInfo[]): FakeServer { - return { tools: [...initialTools], failInitialize: false, emitListChanged: () => {} }; + return { tools: [...initialTools], failInitialize: false, emitListChanged: () => {} }; } function makeExt(server: FakeServer, configJson: string): Extension { - return makeMcpExtension({ - spawn: makeFakeSpawn(server), - readFile: async (path) => (path.endsWith(".dispatch/mcp.json") ? configJson : null), - getCwd: () => "/proj", - }); + return makeMcpExtension({ + spawn: makeFakeSpawn(server), + readFile: async (path) => (path.endsWith(".dispatch/mcp.json") ? configJson : null), + getCwd: () => "/proj", + }); } // --------------------------------------------------------------------------- // Lifecycle tests // --------------------------------------------------------------------------- describe("mcp extension lifecycle", () => { - /** Get the registered filter, throwing if activation did not register one. */ - function requireFilter(getFilter: () => ((a: ToolAssembly) => Promise) | null) { - const filter = getFilter(); - if (!filter) throw new Error("toolsFilter was not registered"); - return filter; - } - - /** Look up a registered tool, throwing if absent. */ - function requireTool(tools: Map, name: string) { - const t = tools.get(name); - if (!t) throw new Error(`tool ${name} not registered`); - return t; - } - - it("registers tools on connect", async () => { - const server = makeServer([tool("create_object", "Create an object")]); - const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); - const { host, tools, getFilter } = makeFakeHost(); - - ext.activate(host); - const filter = requireFilter(getFilter); - - // Running the filter triggers lazy connect + register. - await filter(assembly([])); - - expect(tools.has("freecad__create_object")).toBe(true); - const t = requireTool(tools, "freecad__create_object"); - expect(t.description).toBe("[freecad] Create an object"); - expect(t.concurrencySafe).toBe(false); - ext.deactivate?.(); - }); - - it("toolsFilter keeps connected-server tools and removes disconnected-server tools", async () => { - const server = makeServer([tool("create_object")]); - const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); - const { host, tools, getFilter } = makeFakeHost(); - ext.activate(host); - const filter = requireFilter(getFilter); - - // Connect + register the tool. - await filter(assembly([])); - const registered = requireTool(tools, "freecad__create_object"); - - // Connected server → tool passes through the filter. - const kept = await filter(assembly([registered])); - expect(kept.tools.map((t) => t.name)).toContain("freecad__create_object"); - - // Disconnect: deactivate shuts down the client (clearing it), then make - // the server fail to reconnect. toolToServer still maps the tool, so the - // filter drops it because the server is no longer connected. - ext.deactivate?.(); - server.failInitialize = true; - - const removed = await filter(assembly([registered])); - expect(removed.tools.map((t) => t.name)).not.toContain("freecad__create_object"); - }); - - it("re-registers tools on list_changed", async () => { - const server = makeServer([tool("first_tool")]); - const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); - const { host, tools, getFilter } = makeFakeHost(); - ext.activate(host); - const filter = requireFilter(getFilter); - - await filter(assembly([])); - expect(tools.has("freecad__first_tool")).toBe(true); - - // Server changes its tool set, then announces list_changed. - server.tools = [tool("first_tool"), tool("second_tool", "The second")]; - server.emitListChanged(); - - // Let the async onToolsChanged handler (re-list + re-register) flush. - await flush(); - - expect(tools.has("freecad__second_tool")).toBe(true); - expect(requireTool(tools, "freecad__second_tool").description).toBe("[freecad] The second"); - ext.deactivate?.(); - }); - - it("deactivate shuts down all clients", async () => { - const server = makeServer([tool("create_object")]); - const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); - const { host, getFilter, getService } = makeFakeHost(); - ext.activate(host); - const filter = requireFilter(getFilter); - - await filter(assembly([])); - - const service = getService() as { - status: (cwd: string) => Promise; - }; - const before = await service.status("/proj"); - expect(before[0].state).toBe("connected"); - - ext.deactivate?.(); - - const after = await service.status("/proj"); - expect(after[0].state).toBe("disconnected"); - }); + /** Get the registered filter, throwing if activation did not register one. */ + function requireFilter(getFilter: () => ((a: ToolAssembly) => Promise) | null) { + const filter = getFilter(); + if (!filter) throw new Error("toolsFilter was not registered"); + return filter; + } + + /** Look up a registered tool, throwing if absent. */ + function requireTool(tools: Map, name: string) { + const t = tools.get(name); + if (!t) throw new Error(`tool ${name} not registered`); + return t; + } + + it("registers tools on connect", async () => { + const server = makeServer([tool("create_object", "Create an object")]); + const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); + const { host, tools, getFilter } = makeFakeHost(); + + ext.activate(host); + const filter = requireFilter(getFilter); + + // Running the filter triggers lazy connect + register. + await filter(assembly([])); + + expect(tools.has("freecad__create_object")).toBe(true); + const t = requireTool(tools, "freecad__create_object"); + expect(t.description).toBe("[freecad] Create an object"); + expect(t.concurrencySafe).toBe(false); + ext.deactivate?.(); + }); + + it("toolsFilter keeps connected-server tools and removes disconnected-server tools", async () => { + const server = makeServer([tool("create_object")]); + const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); + const { host, tools, getFilter } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + // Connect + register the tool. + await filter(assembly([])); + const registered = requireTool(tools, "freecad__create_object"); + + // Connected server → tool passes through the filter. + const kept = await filter(assembly([registered])); + expect(kept.tools.map((t) => t.name)).toContain("freecad__create_object"); + + // Disconnect: deactivate shuts down the client (clearing it), then make + // the server fail to reconnect. toolToServer still maps the tool, so the + // filter drops it because the server is no longer connected. + ext.deactivate?.(); + server.failInitialize = true; + + const removed = await filter(assembly([registered])); + expect(removed.tools.map((t) => t.name)).not.toContain("freecad__create_object"); + }); + + it("re-registers tools on list_changed", async () => { + const server = makeServer([tool("first_tool")]); + const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); + const { host, tools, getFilter } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + await filter(assembly([])); + expect(tools.has("freecad__first_tool")).toBe(true); + + // Server changes its tool set, then announces list_changed. + server.tools = [tool("first_tool"), tool("second_tool", "The second")]; + server.emitListChanged(); + + // Let the async onToolsChanged handler (re-list + re-register) flush. + await flush(); + + expect(tools.has("freecad__second_tool")).toBe(true); + expect(requireTool(tools, "freecad__second_tool").description).toBe("[freecad] The second"); + ext.deactivate?.(); + }); + + it("deactivate shuts down all clients", async () => { + const server = makeServer([tool("create_object")]); + const ext = makeExt(server, dispatchConfig({ freecad: { command: "fake" } })); + const { host, getFilter, getService } = makeFakeHost(); + ext.activate(host); + const filter = requireFilter(getFilter); + + await filter(assembly([])); + + const service = getService() as { + status: (cwd: string) => Promise; + }; + const before = await service.status("/proj"); + expect(before[0].state).toBe("connected"); + + ext.deactivate?.(); + + const after = await service.status("/proj"); + expect(after[0].state).toBe("disconnected"); + }); }); diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts index e1c4d52..d12d420 100644 --- a/packages/mcp/src/extension.ts +++ b/packages/mcp/src/extension.ts @@ -26,9 +26,9 @@ export const mcpServiceHandle: ServiceHandle = defineService Promise; - readonly getCwd: () => string; + readonly spawn: SpawnProcess; + readonly readFile: (path: string) => Promise; + readonly getCwd: () => string; } /** @@ -37,192 +37,192 @@ export interface McpExtensionDeps { * Extracted from the filter handler so it is unit-testable without I/O. */ export function filterMcpTools( - assembly: ToolAssembly, - toolToServer: ReadonlyMap, - connectedServerIds: ReadonlySet, + assembly: ToolAssembly, + toolToServer: ReadonlyMap, + connectedServerIds: ReadonlySet, ): ToolAssembly { - const filtered = assembly.tools.filter((tool) => { - const serverId = toolToServer.get(tool.name); - if (serverId === undefined) return true; - return connectedServerIds.has(serverId); - }); - return { - tools: filtered, - ...(assembly.cwd !== undefined && { cwd: assembly.cwd }), - ...(assembly.computerId !== undefined && { computerId: assembly.computerId }), - conversationId: assembly.conversationId, - }; + const filtered = assembly.tools.filter((tool) => { + const serverId = toolToServer.get(tool.name); + if (serverId === undefined) return true; + return connectedServerIds.has(serverId); + }); + return { + tools: filtered, + ...(assembly.cwd !== undefined && { cwd: assembly.cwd }), + ...(assembly.computerId !== undefined && { computerId: assembly.computerId }), + conversationId: assembly.conversationId, + }; } /** Map a host Logger to the manager's narrower Logger surface. */ function wrapLogger(logger: HostAPI["logger"]): Logger { - return { - info: (msg, attrs) => logger.info(msg, attrs), - warn: (msg, attrs) => logger.warn(msg, attrs), - error: (msg, attrs) => logger.error(msg, attrs), - }; + return { + info: (msg, attrs) => logger.info(msg, attrs), + warn: (msg, attrs) => logger.warn(msg, attrs), + error: (msg, attrs) => logger.error(msg, attrs), + }; } export function makeMcpExtension(deps: McpExtensionDeps): Extension { - // Module-scoped store so deactivate can reach the manager. Lives in the - // factory closure so each built extension has its own. - const store: { manager: McpManager | null } = { manager: null }; - - return { - manifest: { - id: "mcp", - name: "Model Context Protocol", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["session-orchestrator"], - capabilities: { spawn: true }, - contributes: { tools: [], services: ["mcp"] }, - }, - activate(host: HostAPI) { - const logger = host.logger; - - const connectionFactory = (server: ResolvedMcpServer, cwd: string) => { - return createStdioTransport( - { - spawn: deps.spawn, - command: server.command, - ...(server.env !== undefined && { env: server.env }), - }, - cwd, - ); - }; - - const manager = new McpManager( - { spawn: deps.spawn, logger: wrapLogger(logger) }, - connectionFactory, - ); - - // Track which tool names belong to which server for the filter. - const toolToServer = new Map(); - - function registerToolsFromClient(serverId: string, client: McpClient): void { - const tools = client.getTools(); - for (const mcpTool of tools) { - const name = namespace(serverId, mcpTool.name); - toolToServer.set(name, serverId); - host.defineTool(adaptTool(serverId, mcpTool, client)); - } - } - - async function connectAndRegister(server: ResolvedMcpServer, cwd: string): Promise { - const client = await manager.ensureConnected(server, cwd); - registerToolsFromClient(server.id, client); - - // Wire list_changed → re-list → re-register. onToolsChanged replaces - // the handler; ensureConnected returns the same cached client so this - // is idempotent across turns. - client.onToolsChanged(async () => { - try { - await client.listTools(); - registerToolsFromClient(server.id, client); - } catch (err: unknown) { - logger.error("MCP tools re-list failed", { - serverId: server.id, - error: err instanceof Error ? err.message : String(err), - }); - } - }); - } - - // Resolve config + ensure servers connected, then drop tools whose - // server is not connected. Lazy-spawn happens here (first turn). - host.addFilter(toolsFilter, async (assembly: ToolAssembly): Promise => { - const cwd = assembly.cwd ?? deps.getCwd(); - const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); - const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); - const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); - - for (const server of servers) { - try { - await connectAndRegister(server, cwd); - } catch { - // Connection failure — the manager tracks broken state. - } - } - - const statuses = manager.status(servers); - const connectedIds = new Set( - statuses.filter((s) => s.state === "connected").map((s) => s.id), - ); - - return filterMcpTools(assembly, toolToServer, connectedIds); - }); - - // Provide the MCP service (status introspection). - const service: McpService = { - async status(cwd: string): Promise { - const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); - const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); - const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); - return manager.status(servers); - }, - }; - host.provideService(mcpServiceHandle, service); - - store.manager = manager; - logger.info("MCP extension activated"); - }, - deactivate() { - store.manager?.shutdownAll(); - store.manager = null; - }, - }; + // Module-scoped store so deactivate can reach the manager. Lives in the + // factory closure so each built extension has its own. + const store: { manager: McpManager | null } = { manager: null }; + + return { + manifest: { + id: "mcp", + name: "Model Context Protocol", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["session-orchestrator"], + capabilities: { spawn: true }, + contributes: { tools: [], services: ["mcp"] }, + }, + activate(host: HostAPI) { + const logger = host.logger; + + const connectionFactory = (server: ResolvedMcpServer, cwd: string) => { + return createStdioTransport( + { + spawn: deps.spawn, + command: server.command, + ...(server.env !== undefined && { env: server.env }), + }, + cwd, + ); + }; + + const manager = new McpManager( + { spawn: deps.spawn, logger: wrapLogger(logger) }, + connectionFactory, + ); + + // Track which tool names belong to which server for the filter. + const toolToServer = new Map(); + + function registerToolsFromClient(serverId: string, client: McpClient): void { + const tools = client.getTools(); + for (const mcpTool of tools) { + const name = namespace(serverId, mcpTool.name); + toolToServer.set(name, serverId); + host.defineTool(adaptTool(serverId, mcpTool, client)); + } + } + + async function connectAndRegister(server: ResolvedMcpServer, cwd: string): Promise { + const client = await manager.ensureConnected(server, cwd); + registerToolsFromClient(server.id, client); + + // Wire list_changed → re-list → re-register. onToolsChanged replaces + // the handler; ensureConnected returns the same cached client so this + // is idempotent across turns. + client.onToolsChanged(async () => { + try { + await client.listTools(); + registerToolsFromClient(server.id, client); + } catch (err: unknown) { + logger.error("MCP tools re-list failed", { + serverId: server.id, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + } + + // Resolve config + ensure servers connected, then drop tools whose + // server is not connected. Lazy-spawn happens here (first turn). + host.addFilter(toolsFilter, async (assembly: ToolAssembly): Promise => { + const cwd = assembly.cwd ?? deps.getCwd(); + const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); + const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); + const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); + + for (const server of servers) { + try { + await connectAndRegister(server, cwd); + } catch { + // Connection failure — the manager tracks broken state. + } + } + + const statuses = manager.status(servers); + const connectedIds = new Set( + statuses.filter((s) => s.state === "connected").map((s) => s.id), + ); + + return filterMcpTools(assembly, toolToServer, connectedIds); + }); + + // Provide the MCP service (status introspection). + const service: McpService = { + async status(cwd: string): Promise { + const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); + const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); + const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); + return manager.status(servers); + }, + }; + host.provideService(mcpServiceHandle, service); + + store.manager = manager; + logger.info("MCP extension activated"); + }, + deactivate() { + store.manager?.shutdownAll(); + store.manager = null; + }, + }; } // --- real Bun-backed adapters (production wiring) --- function realSpawn( - command: readonly string[], - opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, + command: readonly string[], + opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, ): SpawnedProcess { - const env: Record = { ...process.env }; - if (opts.env) { - for (const [key, value] of Object.entries(opts.env)) { - env[key] = value; - } - } - const proc = Bun.spawn(command as string[], { - cwd: opts.cwd, - env: env as Record, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); - return { - stdin: proc.stdin, - stdout: proc.stdout, - stderr: proc.stderr, - pid: proc.pid, - kill: () => proc.kill(), - }; + const env: Record = { ...process.env }; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + env[key] = value; + } + } + const proc = Bun.spawn(command as string[], { + cwd: opts.cwd, + env: env as Record, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid, + kill: () => proc.kill(), + }; } async function realReadFile(path: string): Promise { - try { - const file = Bun.file(path); - if (await file.exists()) { - return file.text(); - } - return null; - } catch { - return null; - } + try { + const file = Bun.file(path); + if (await file.exists()) { + return file.text(); + } + return null; + } catch { + return null; + } } function joinPath(...parts: readonly string[]): string { - return parts.join("/"); + return parts.join("/"); } /** Production extension: real Bun spawn + filesystem reads. */ export const extension: Extension = makeMcpExtension({ - spawn: realSpawn, - readFile: realReadFile, - getCwd: () => process.cwd(), + spawn: realSpawn, + readFile: realReadFile, + getCwd: () => process.cwd(), }); diff --git a/packages/mcp/src/framing.test.ts b/packages/mcp/src/framing.test.ts index be8cb8e..3b207a3 100644 --- a/packages/mcp/src/framing.test.ts +++ b/packages/mcp/src/framing.test.ts @@ -2,91 +2,91 @@ import { describe, expect, it } from "vitest"; import { encode, FrameDecoder } from "./framing.js"; describe("encode", () => { - it("produces correct Content-Length header", () => { - const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; - const encoded = encode(msg); - const text = new TextDecoder().decode(encoded); - expect(text).toBe(`Content-Length: ${new TextEncoder().encode(msg).length}\r\n\r\n${msg}`); - }); + it("produces correct Content-Length header", () => { + const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; + const encoded = encode(msg); + const text = new TextDecoder().decode(encoded); + expect(text).toBe(`Content-Length: ${new TextEncoder().encode(msg).length}\r\n\r\n${msg}`); + }); - it("handles empty message", () => { - const encoded = encode(""); - const text = new TextDecoder().decode(encoded); - expect(text).toBe("Content-Length: 0\r\n\r\n"); - }); + it("handles empty message", () => { + const encoded = encode(""); + const text = new TextDecoder().decode(encoded); + expect(text).toBe("Content-Length: 0\r\n\r\n"); + }); }); describe("FrameDecoder", () => { - it("reassembles a complete message from one chunk", () => { - const msg = '{"jsonrpc":"2.0","id":1}'; - const encoded = encode(msg); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([msg]); - }); + it("reassembles a complete message from one chunk", () => { + const msg = '{"jsonrpc":"2.0","id":1}'; + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toEqual([msg]); + }); - it("handles split across chunks", () => { - const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; - const encoded = encode(msg); - const mid = Math.floor(encoded.length / 2); - const chunk1 = encoded.slice(0, mid); - const chunk2 = encoded.slice(mid); + it("handles split across chunks", () => { + const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}'; + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const chunk1 = encoded.slice(0, mid); + const chunk2 = encoded.slice(mid); - const decoder = new FrameDecoder(); - const result1 = decoder.decode(chunk1); - expect(result1).toEqual([]); + const decoder = new FrameDecoder(); + const result1 = decoder.decode(chunk1); + expect(result1).toEqual([]); - const result2 = decoder.decode(chunk2); - expect(result2).toEqual([msg]); - }); + const result2 = decoder.decode(chunk2); + expect(result2).toEqual([msg]); + }); - it("handles two messages in one chunk", () => { - const msg1 = '{"jsonrpc":"2.0","id":1}'; - const msg2 = '{"jsonrpc":"2.0","id":2}'; - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); + it("handles two messages in one chunk", () => { + const msg1 = '{"jsonrpc":"2.0","id":1}'; + const msg2 = '{"jsonrpc":"2.0","id":2}'; + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); - const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toEqual([msg1, msg2]); - }); + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toEqual([msg1, msg2]); + }); - it("rejects negative Content-Length by skipping header", () => { - const header = "Content-Length: -5\r\n\r\n"; - const encoded = new TextEncoder().encode(`${header}extra`); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - // Negative length does not match the digit capture, so the header is skipped. - expect(messages).toEqual([]); - }); + it("rejects negative Content-Length by skipping header", () => { + const header = "Content-Length: -5\r\n\r\n"; + const encoded = new TextEncoder().encode(`${header}extra`); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + // Negative length does not match the digit capture, so the header is skipped. + expect(messages).toEqual([]); + }); - it("rejects zero Content-Length", () => { - const encoded = encode(""); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([""]); - }); + it("rejects zero Content-Length", () => { + const encoded = encode(""); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toEqual([""]); + }); - it("reassembles multi-byte UTF-8 content (byte-length, not char-length)", () => { - // "héllo" — é is two UTF-8 bytes; Content-Length counts bytes. - const msg = '{"text":"héllo 🚀"}'; - const encoded = encode(msg); - expect(new TextEncoder().encode(msg).length).toBeGreaterThan(msg.length); + it("reassembles multi-byte UTF-8 content (byte-length, not char-length)", () => { + // "héllo" — é is two UTF-8 bytes; Content-Length counts bytes. + const msg = '{"text":"héllo 🚀"}'; + const encoded = encode(msg); + expect(new TextEncoder().encode(msg).length).toBeGreaterThan(msg.length); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toEqual([msg]); - }); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toEqual([msg]); + }); - it("reassembles multi-byte content split across a chunk boundary", () => { - const msg = '{"text":"日本語のテスト"}'; - const encoded = encode(msg); - const mid = Math.floor(encoded.length / 2); - const decoder = new FrameDecoder(); - expect(decoder.decode(encoded.slice(0, mid))).toEqual([]); - expect(decoder.decode(encoded.slice(mid))).toEqual([msg]); - }); + it("reassembles multi-byte content split across a chunk boundary", () => { + const msg = '{"text":"日本語のテスト"}'; + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const decoder = new FrameDecoder(); + expect(decoder.decode(encoded.slice(0, mid))).toEqual([]); + expect(decoder.decode(encoded.slice(mid))).toEqual([msg]); + }); }); diff --git a/packages/mcp/src/framing.ts b/packages/mcp/src/framing.ts index a6e8b99..5f818e2 100644 --- a/packages/mcp/src/framing.ts +++ b/packages/mcp/src/framing.ts @@ -20,30 +20,30 @@ const SEP_BYTES = new TextEncoder().encode(HEADER_SEP); * Returns the full frame (header + blank line + body) as bytes. */ export function encode(msg: string): Uint8Array { - const body = new TextEncoder().encode(msg); - const header = `Content-Length: ${body.length}\r\n\r\n`; - const frame = new TextEncoder().encode(header); - const result = new Uint8Array(frame.length + body.length); - result.set(frame); - result.set(body, frame.length); - return result; + const body = new TextEncoder().encode(msg); + const header = `Content-Length: ${body.length}\r\n\r\n`; + const frame = new TextEncoder().encode(header); + const result = new Uint8Array(frame.length + body.length); + result.set(frame); + result.set(body, frame.length); + return result; } /** Find the first occurrence of `needle` in `haystack` at or after `from`. -1 if absent. */ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number { - if (needle.length === 0) return from; - const max = haystack.length - needle.length; - for (let i = from; i <= max; i++) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (haystack[i + j] !== needle[j]) { - match = false; - break; - } - } - if (match) return i; - } - return -1; + if (needle.length === 0) return from; + const max = haystack.length - needle.length; + for (let i = from; i <= max; i++) { + let match = true; + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; } /** @@ -51,50 +51,50 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n * be extracted from the accumulated buffer. Buffers partial frames across calls. */ export class FrameDecoder { - private buf: Uint8Array = new Uint8Array(0); - private readonly decoder = new TextDecoder(); + private buf: Uint8Array = new Uint8Array(0); + private readonly decoder = new TextDecoder(); - decode(chunk: Uint8Array): string[] { - // Append the incoming chunk to the internal byte buffer. - const next = new Uint8Array(this.buf.length + chunk.length); - next.set(this.buf); - next.set(chunk, this.buf.length); - this.buf = next; + decode(chunk: Uint8Array): string[] { + // Append the incoming chunk to the internal byte buffer. + const next = new Uint8Array(this.buf.length + chunk.length); + next.set(this.buf); + next.set(chunk, this.buf.length); + this.buf = next; - const messages: string[] = []; + const messages: string[] = []; - while (true) { - const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0); - if (sepIdx === -1) break; + while (true) { + const sepIdx = indexOfBytes(this.buf, SEP_BYTES, 0); + if (sepIdx === -1) break; - // The header block is everything before the separator; parse - // Content-Length from it (ASCII, so decoding the slice is safe). - const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx)); - const match = CONTENT_LENGTH_RE.exec(headerText); - const bodyStart = sepIdx + SEP_BYTES.length; + // The header block is everything before the separator; parse + // Content-Length from it (ASCII, so decoding the slice is safe). + const headerText = this.decoder.decode(this.buf.subarray(0, sepIdx)); + const match = CONTENT_LENGTH_RE.exec(headerText); + const bodyStart = sepIdx + SEP_BYTES.length; - if (!match?.[1]) { - // No usable Content-Length — drop this header and continue scanning. - this.buf = this.buf.subarray(bodyStart); - continue; - } + if (!match?.[1]) { + // No usable Content-Length — drop this header and continue scanning. + this.buf = this.buf.subarray(bodyStart); + continue; + } - const length = Number.parseInt(match[1], 10); - if (length < 0) { - this.buf = this.buf.subarray(bodyStart); - continue; - } + const length = Number.parseInt(match[1], 10); + if (length < 0) { + this.buf = this.buf.subarray(bodyStart); + continue; + } - if (this.buf.length - bodyStart < length) { - // Body not fully received yet; wait for more bytes. - break; - } + if (this.buf.length - bodyStart < length) { + // Body not fully received yet; wait for more bytes. + break; + } - // Decode exactly `length` body bytes (preserves multi-byte UTF-8). - messages.push(this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length))); - this.buf = this.buf.subarray(bodyStart + length); - } + // Decode exactly `length` body bytes (preserves multi-byte UTF-8). + messages.push(this.decoder.decode(this.buf.subarray(bodyStart, bodyStart + length))); + this.buf = this.buf.subarray(bodyStart + length); + } - return messages; - } + return messages; + } } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1f2667d..6902244 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,31 +1,31 @@ export { McpClient, type McpClientState } from "./client.js"; export { type ResolveServersDeps, resolveServers } from "./config.js"; export { - extension, - filterMcpTools, - type McpExtensionDeps, - makeMcpExtension, - mcpServiceHandle, + extension, + filterMcpTools, + type McpExtensionDeps, + makeMcpExtension, + mcpServiceHandle, } from "./extension.js"; export { encode, FrameDecoder } from "./framing.js"; export { type Logger, McpManager, type McpManagerDeps } from "./manager.js"; export { adaptTool, flattenContent, namespace } from "./registry.js"; export { - type Connection, - createStdioTransport, - type SpawnedProcess, - type SpawnProcess, + type Connection, + createStdioTransport, + type SpawnedProcess, + type SpawnProcess, } from "./transport.js"; export type { - McpCallResult, - McpContentItem, - McpServerCapabilities, - McpServerConfig, - McpServerState, - McpServerStatus, - McpService, - McpToolCaller, - McpToolInfo, - ResolvedMcpServer, - ResolveResult, + McpCallResult, + McpContentItem, + McpServerCapabilities, + McpServerConfig, + McpServerState, + McpServerStatus, + McpService, + McpToolCaller, + McpToolInfo, + ResolvedMcpServer, + ResolveResult, } from "./types.js"; diff --git a/packages/mcp/src/manager.test.ts b/packages/mcp/src/manager.test.ts index 275b078..7cc53b8 100644 --- a/packages/mcp/src/manager.test.ts +++ b/packages/mcp/src/manager.test.ts @@ -4,213 +4,213 @@ import type { Connection } from "./transport.js"; import type { ResolvedMcpServer } from "./types.js"; function makeMockConnection(): Connection { - return { - send: async (method: string) => { - if (method === "initialize") { - return { - protocolVersion: "2025-11-25", - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "test", version: "1.0.0" }, - }; - } - if (method === "tools/list") { - return { - tools: [ - { - name: "tool_a", - description: "Tool A", - inputSchema: { type: "object" }, - }, - ], - }; - } - return {}; - }, - notify: () => {}, - onNotification: () => {}, - close: () => {}, - pid: 100, - }; + return { + send: async (method: string) => { + if (method === "initialize") { + return { + protocolVersion: "2025-11-25", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "test", version: "1.0.0" }, + }; + } + if (method === "tools/list") { + return { + tools: [ + { + name: "tool_a", + description: "Tool A", + inputSchema: { type: "object" }, + }, + ], + }; + } + return {}; + }, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 100, + }; } function makeBrokenConnection(): Connection { - return { - send: async () => { - throw new Error("Connection refused"); - }, - notify: () => {}, - onNotification: () => {}, - close: () => {}, - pid: 101, - }; + return { + send: async () => { + throw new Error("Connection refused"); + }, + notify: () => {}, + onNotification: () => {}, + close: () => {}, + pid: 101, + }; } function makeManager( - connectionFactory: (_server: ResolvedMcpServer) => { - connection: Connection; - promise: Promise; - }, + connectionFactory: (_server: ResolvedMcpServer) => { + connection: Connection; + promise: Promise; + }, ): McpManager { - const currentTime = 1000; - const deps: McpManagerDeps = { - spawn: () => ({ - stdin: { write: () => {} }, - stdout: { on: () => {} }, - pid: 1, - kill: () => {}, - }), - now: () => currentTime, - }; - - const factory = (_server: ResolvedMcpServer, _cwd: string) => connectionFactory(_server); - - const manager = new McpManager(deps, factory); - return manager; + const currentTime = 1000; + const deps: McpManagerDeps = { + spawn: () => ({ + stdin: { write: () => {} }, + stdout: { on: () => {} }, + pid: 1, + kill: () => {}, + }), + now: () => currentTime, + }; + + const factory = (_server: ResolvedMcpServer, _cwd: string) => connectionFactory(_server); + + const manager = new McpManager(deps, factory); + return manager; } const testServer: ResolvedMcpServer = { - id: "test-server", - command: ["test-cmd"], - configSource: ".dispatch/mcp.json", + id: "test-server", + command: ["test-cmd"], + configSource: ".dispatch/mcp.json", }; describe("McpManager", () => { - it("lazy-spawn on first access", async () => { - const conn = makeMockConnection(); - let spawnCount = 0; - const manager = makeManager((_server) => { - spawnCount++; - return { connection: conn, promise: Promise.resolve() }; - }); - - const client = await manager.ensureConnected(testServer, "/tmp"); - expect(client).toBeDefined(); - expect(spawnCount).toBe(1); - }); - - it("reuses existing client on second access", async () => { - const conn = makeMockConnection(); - let spawnCount = 0; - const manager = makeManager(() => { - spawnCount++; - return { connection: conn, promise: Promise.resolve() }; - }); - - const client1 = await manager.ensureConnected(testServer, "/tmp"); - const client2 = await manager.ensureConnected(testServer, "/tmp"); - expect(client1).toBe(client2); - expect(spawnCount).toBe(1); - }); - - it("status returns server states", async () => { - const conn = makeMockConnection(); - const manager = makeManager(() => { - return { connection: conn, promise: Promise.resolve() }; - }); - - // Before connecting - let statuses = manager.status([testServer]); - expect(statuses.length).toBe(1); - expect(statuses[0].state).toBe("disconnected"); - - // After connecting - await manager.ensureConnected(testServer, "/tmp"); - statuses = manager.status([testServer]); - expect(statuses.length).toBe(1); - expect(statuses[0].state).toBe("connected"); - expect(statuses[0].toolCount).toBe(1); - }); - - it("shutdownAll kills all clients", async () => { - let closed = false; - const conn: Connection = { - send: async (method: string) => { - if (method === "initialize") { - return { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test", version: "1.0.0" }, - }; - } - if (method === "tools/list") return { tools: [] }; - return {}; - }, - notify: () => {}, - onNotification: () => {}, - close: () => { - closed = true; - }, - pid: 200, - }; - - const manager = makeManager(() => { - return { connection: conn, promise: Promise.resolve() }; - }); - - await manager.ensureConnected(testServer, "/tmp"); - manager.shutdownAll(); - - expect(closed).toBe(true); - const statuses = manager.status([testServer]); - expect(statuses[0].state).toBe("disconnected"); - }); - - it("broken server reports error state", async () => { - const manager = makeManager(() => { - return { connection: makeBrokenConnection(), promise: Promise.resolve() }; - }); - - await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); - - const statuses = manager.status([testServer]); - expect(statuses[0].state).toBe("error"); - expect(statuses[0].error).toContain("test-server"); - }); - - it("broken server retries after backoff", async () => { - let currentTime = 1000; - const brokenConn = makeBrokenConnection(); - const goodConn = makeMockConnection(); - let useGood = false; - - const deps: McpManagerDeps = { - spawn: () => ({ - stdin: { write: () => {} }, - stdout: { on: () => {} }, - pid: 1, - kill: () => {}, - }), - now: () => currentTime, - }; - - const factory = (_server: ResolvedMcpServer, _cwd: string) => { - const conn = useGood ? goodConn : brokenConn; - return { connection: conn, promise: Promise.resolve() }; - }; - - const manager = new McpManager(deps, factory); - - // First attempt fails - await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); - expect(manager.status([testServer])[0].state).toBe("error"); - - // Not enough time passed — still broken - currentTime += 29_000; - expect(manager.status([testServer])[0].state).toBe("error"); - - // After backoff — should allow retry - useGood = true; - currentTime += 2_000; - const statuses = manager.status([testServer]); - // After backoff, status() clears the broken entry - expect(statuses[0].state).toBe("disconnected"); - }); - - it("getClient returns undefined for unknown server", () => { - const manager = makeManager(() => { - return { connection: makeMockConnection(), promise: Promise.resolve() }; - }); - - expect(manager.getClient("nonexistent")).toBeUndefined(); - }); + it("lazy-spawn on first access", async () => { + const conn = makeMockConnection(); + let spawnCount = 0; + const manager = makeManager((_server) => { + spawnCount++; + return { connection: conn, promise: Promise.resolve() }; + }); + + const client = await manager.ensureConnected(testServer, "/tmp"); + expect(client).toBeDefined(); + expect(spawnCount).toBe(1); + }); + + it("reuses existing client on second access", async () => { + const conn = makeMockConnection(); + let spawnCount = 0; + const manager = makeManager(() => { + spawnCount++; + return { connection: conn, promise: Promise.resolve() }; + }); + + const client1 = await manager.ensureConnected(testServer, "/tmp"); + const client2 = await manager.ensureConnected(testServer, "/tmp"); + expect(client1).toBe(client2); + expect(spawnCount).toBe(1); + }); + + it("status returns server states", async () => { + const conn = makeMockConnection(); + const manager = makeManager(() => { + return { connection: conn, promise: Promise.resolve() }; + }); + + // Before connecting + let statuses = manager.status([testServer]); + expect(statuses.length).toBe(1); + expect(statuses[0].state).toBe("disconnected"); + + // After connecting + await manager.ensureConnected(testServer, "/tmp"); + statuses = manager.status([testServer]); + expect(statuses.length).toBe(1); + expect(statuses[0].state).toBe("connected"); + expect(statuses[0].toolCount).toBe(1); + }); + + it("shutdownAll kills all clients", async () => { + let closed = false; + const conn: Connection = { + send: async (method: string) => { + if (method === "initialize") { + return { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test", version: "1.0.0" }, + }; + } + if (method === "tools/list") return { tools: [] }; + return {}; + }, + notify: () => {}, + onNotification: () => {}, + close: () => { + closed = true; + }, + pid: 200, + }; + + const manager = makeManager(() => { + return { connection: conn, promise: Promise.resolve() }; + }); + + await manager.ensureConnected(testServer, "/tmp"); + manager.shutdownAll(); + + expect(closed).toBe(true); + const statuses = manager.status([testServer]); + expect(statuses[0].state).toBe("disconnected"); + }); + + it("broken server reports error state", async () => { + const manager = makeManager(() => { + return { connection: makeBrokenConnection(), promise: Promise.resolve() }; + }); + + await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); + + const statuses = manager.status([testServer]); + expect(statuses[0].state).toBe("error"); + expect(statuses[0].error).toContain("test-server"); + }); + + it("broken server retries after backoff", async () => { + let currentTime = 1000; + const brokenConn = makeBrokenConnection(); + const goodConn = makeMockConnection(); + let useGood = false; + + const deps: McpManagerDeps = { + spawn: () => ({ + stdin: { write: () => {} }, + stdout: { on: () => {} }, + pid: 1, + kill: () => {}, + }), + now: () => currentTime, + }; + + const factory = (_server: ResolvedMcpServer, _cwd: string) => { + const conn = useGood ? goodConn : brokenConn; + return { connection: conn, promise: Promise.resolve() }; + }; + + const manager = new McpManager(deps, factory); + + // First attempt fails + await expect(manager.ensureConnected(testServer, "/tmp")).rejects.toThrow(); + expect(manager.status([testServer])[0].state).toBe("error"); + + // Not enough time passed — still broken + currentTime += 29_000; + expect(manager.status([testServer])[0].state).toBe("error"); + + // After backoff — should allow retry + useGood = true; + currentTime += 2_000; + const statuses = manager.status([testServer]); + // After backoff, status() clears the broken entry + expect(statuses[0].state).toBe("disconnected"); + }); + + it("getClient returns undefined for unknown server", () => { + const manager = makeManager(() => { + return { connection: makeMockConnection(), promise: Promise.resolve() }; + }); + + expect(manager.getClient("nonexistent")).toBeUndefined(); + }); }); diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index a9c06de..08ad389 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -12,194 +12,194 @@ import type { Connection, SpawnProcess } from "./transport.js"; import type { McpServerState, McpServerStatus, ResolvedMcpServer } from "./types.js"; export interface Logger { - readonly info: (msg: string, attrs?: Record) => void; - readonly warn: (msg: string, attrs?: Record) => void; - readonly error: (msg: string, attrs?: Record) => void; + readonly info: (msg: string, attrs?: Record) => void; + readonly warn: (msg: string, attrs?: Record) => void; + readonly error: (msg: string, attrs?: Record) => void; } export interface McpManagerDeps { - readonly spawn: SpawnProcess; - readonly logger?: Logger; - readonly now?: () => number; + readonly spawn: SpawnProcess; + readonly logger?: Logger; + readonly now?: () => number; } export type ConnectionFactory = ( - server: ResolvedMcpServer, - cwd: string, + server: ResolvedMcpServer, + cwd: string, ) => { connection: Connection; promise: Promise }; type ClientEntry = { - readonly client: McpClient; - readonly server: ResolvedMcpServer; - readonly promise: Promise; + readonly client: McpClient; + readonly server: ResolvedMcpServer; + readonly promise: Promise; }; type BrokenEntry = { - readonly brokenAt: number; - readonly error: string; + readonly brokenAt: number; + readonly error: string; }; const BACKOFF_MS = 30_000; export class McpManager { - private clients = new Map(); - private broken = new Map(); - private spawning = new Map>(); - private readonly deps: McpManagerDeps; - private readonly connectionFactory: ConnectionFactory; - private readonly now: () => number; - - constructor(deps: McpManagerDeps, connectionFactory: ConnectionFactory) { - this.deps = deps; - this.connectionFactory = connectionFactory; - this.now = deps.now ?? Date.now; - } - - getClient(serverId: string): McpClient | undefined { - return this.clients.get(serverId)?.client; - } - - getServerState(serverId: string): McpServerState { - const brokenEntry = this.broken.get(serverId); - if (brokenEntry) { - const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; - if (backoffElapsed) { - this.broken.delete(serverId); - } else { - return "error"; - } - } - - const entry = this.clients.get(serverId); - if (!entry) return "disconnected"; - - const state = entry.client.getState(); - if (state === "error") return "error"; - if (state === "connecting") return "connecting"; - if (state === "connected") return "connected"; - return "disconnected"; - } - - status(servers: readonly ResolvedMcpServer[]): McpServerStatus[] { - const results: McpServerStatus[] = []; - - for (const server of servers) { - const state = this.getServerState(server.id); - const entry = this.clients.get(server.id); - const brokenEntry = this.broken.get(server.id); - - const status: McpServerStatus = { - id: server.id, - state, - toolCount: entry?.client.getTools().length ?? 0, - }; - if (state === "error" && brokenEntry) { - (status as { error?: string }).error = brokenEntry.error; - } else if (state === "error" && entry?.client.getState() === "error") { - (status as { error?: string }).error = brokenEntry?.error ?? `${server.id}: client error`; - } - results.push(status); - } - - return results; - } - - async ensureConnected(server: ResolvedMcpServer, cwd: string): Promise { - const existing = this.clients.get(server.id); - if (existing && existing.client.getState() === "connected") { - return existing.client; - } - - const brokenEntry = this.broken.get(server.id); - if (brokenEntry) { - const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; - if (!backoffElapsed) { - throw new Error(brokenEntry.error); - } - this.broken.delete(server.id); - } - - await this.spawnClient(server, cwd); - const entry = this.clients.get(server.id); - if (!entry) { - throw new Error(`Failed to spawn MCP client for ${server.id}`); - } - if (entry.client.getState() === "error") { - const brokenNow = this.broken.get(server.id); - throw new Error(brokenNow?.error ?? `${server.id}: client error`); - } - return entry.client; - } - - private async spawnClient(server: ResolvedMcpServer, cwd: string): Promise { - const existingSpawn = this.spawning.get(server.id); - if (existingSpawn) return existingSpawn; - - const spawnPromise = this.doSpawn(server, cwd); - this.spawning.set(server.id, spawnPromise); - - try { - await spawnPromise; - } finally { - this.spawning.delete(server.id); - } - } - - private async doSpawn(server: ResolvedMcpServer, cwd: string): Promise { - const { connection, promise } = this.connectionFactory(server, cwd); - - const client = new McpClient({ connection }); - - const entry: ClientEntry = { - client, - server, - promise: this.initClient(client, server, promise), - }; - - this.clients.set(server.id, entry); - await entry.promise; - - // If initialization failed, the client is in an error state and broken[] - // is already populated. Drop the half-created client (and reap its child - // process) so a later retry spawns fresh instead of returning a dead entry. - if (client.getState() === "error") { - this.clients.delete(server.id); - client.close(); - } - } - - private async initClient( - client: McpClient, - server: ResolvedMcpServer, - _transportPromise: Promise, - ): Promise { - try { - await client.initialize(); - await client.listTools(); - this.deps.logger?.info("MCP server connected", { - serverId: server.id, - toolCount: String(client.getTools().length), - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.broken.set(server.id, { - brokenAt: this.now(), - error: `${server.id}: ${message}`, - }); - this.deps.logger?.warn("MCP server failed to connect", { - serverId: server.id, - error: message, - }); - } - } - - shutdownAll(): void { - for (const [, entry] of this.clients) { - entry.client.close(); - } - this.clients.clear(); - this.broken.clear(); - this.spawning.clear(); - this.deps.logger?.info("All MCP servers shut down"); - } + private clients = new Map(); + private broken = new Map(); + private spawning = new Map>(); + private readonly deps: McpManagerDeps; + private readonly connectionFactory: ConnectionFactory; + private readonly now: () => number; + + constructor(deps: McpManagerDeps, connectionFactory: ConnectionFactory) { + this.deps = deps; + this.connectionFactory = connectionFactory; + this.now = deps.now ?? Date.now; + } + + getClient(serverId: string): McpClient | undefined { + return this.clients.get(serverId)?.client; + } + + getServerState(serverId: string): McpServerState { + const brokenEntry = this.broken.get(serverId); + if (brokenEntry) { + const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; + if (backoffElapsed) { + this.broken.delete(serverId); + } else { + return "error"; + } + } + + const entry = this.clients.get(serverId); + if (!entry) return "disconnected"; + + const state = entry.client.getState(); + if (state === "error") return "error"; + if (state === "connecting") return "connecting"; + if (state === "connected") return "connected"; + return "disconnected"; + } + + status(servers: readonly ResolvedMcpServer[]): McpServerStatus[] { + const results: McpServerStatus[] = []; + + for (const server of servers) { + const state = this.getServerState(server.id); + const entry = this.clients.get(server.id); + const brokenEntry = this.broken.get(server.id); + + const status: McpServerStatus = { + id: server.id, + state, + toolCount: entry?.client.getTools().length ?? 0, + }; + if (state === "error" && brokenEntry) { + (status as { error?: string }).error = brokenEntry.error; + } else if (state === "error" && entry?.client.getState() === "error") { + (status as { error?: string }).error = brokenEntry?.error ?? `${server.id}: client error`; + } + results.push(status); + } + + return results; + } + + async ensureConnected(server: ResolvedMcpServer, cwd: string): Promise { + const existing = this.clients.get(server.id); + if (existing && existing.client.getState() === "connected") { + return existing.client; + } + + const brokenEntry = this.broken.get(server.id); + if (brokenEntry) { + const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; + if (!backoffElapsed) { + throw new Error(brokenEntry.error); + } + this.broken.delete(server.id); + } + + await this.spawnClient(server, cwd); + const entry = this.clients.get(server.id); + if (!entry) { + throw new Error(`Failed to spawn MCP client for ${server.id}`); + } + if (entry.client.getState() === "error") { + const brokenNow = this.broken.get(server.id); + throw new Error(brokenNow?.error ?? `${server.id}: client error`); + } + return entry.client; + } + + private async spawnClient(server: ResolvedMcpServer, cwd: string): Promise { + const existingSpawn = this.spawning.get(server.id); + if (existingSpawn) return existingSpawn; + + const spawnPromise = this.doSpawn(server, cwd); + this.spawning.set(server.id, spawnPromise); + + try { + await spawnPromise; + } finally { + this.spawning.delete(server.id); + } + } + + private async doSpawn(server: ResolvedMcpServer, cwd: string): Promise { + const { connection, promise } = this.connectionFactory(server, cwd); + + const client = new McpClient({ connection }); + + const entry: ClientEntry = { + client, + server, + promise: this.initClient(client, server, promise), + }; + + this.clients.set(server.id, entry); + await entry.promise; + + // If initialization failed, the client is in an error state and broken[] + // is already populated. Drop the half-created client (and reap its child + // process) so a later retry spawns fresh instead of returning a dead entry. + if (client.getState() === "error") { + this.clients.delete(server.id); + client.close(); + } + } + + private async initClient( + client: McpClient, + server: ResolvedMcpServer, + _transportPromise: Promise, + ): Promise { + try { + await client.initialize(); + await client.listTools(); + this.deps.logger?.info("MCP server connected", { + serverId: server.id, + toolCount: String(client.getTools().length), + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.broken.set(server.id, { + brokenAt: this.now(), + error: `${server.id}: ${message}`, + }); + this.deps.logger?.warn("MCP server failed to connect", { + serverId: server.id, + error: message, + }); + } + } + + shutdownAll(): void { + for (const [, entry] of this.clients) { + entry.client.close(); + } + this.clients.clear(); + this.broken.clear(); + this.spawning.clear(); + this.deps.logger?.info("All MCP servers shut down"); + } } diff --git a/packages/mcp/src/registry.test.ts b/packages/mcp/src/registry.test.ts index 4c88828..37cfea5 100644 --- a/packages/mcp/src/registry.test.ts +++ b/packages/mcp/src/registry.test.ts @@ -4,21 +4,21 @@ import { adaptTool, flattenContent, namespace } from "./registry.js"; import type { McpCallResult, McpContentItem, McpToolCaller, McpToolInfo } from "./types.js"; const mockSpan = { - id: "span-1", - log: {} as Logger, - setAttributes: () => {}, - addLink: () => {}, - child: () => mockSpan, - end: () => {}, + id: "span-1", + log: {} as Logger, + setAttributes: () => {}, + addLink: () => {}, + child: () => mockSpan, + end: () => {}, }; const mockLogger: Logger = { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - child: () => mockLogger, - span: () => mockSpan, + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => mockLogger, + span: () => mockSpan, }; /** @@ -26,212 +26,212 @@ const mockLogger: Logger = { * It records calls and returns a configurable result. */ function makeCaller( - result: McpCallResult, + result: McpCallResult, ): McpToolCaller & { calls: Array<{ name: string; args: unknown }> } { - const calls: Array<{ name: string; args: unknown }> = []; - return { - calls, - callTool: async (name: string, args: unknown) => { - calls.push({ name, args }); - return result; - }, - }; + const calls: Array<{ name: string; args: unknown }> = []; + return { + calls, + callTool: async (name: string, args: unknown) => { + calls.push({ name, args }); + return result; + }, + }; } describe("namespace", () => { - it("produces __", () => { - expect(namespace("freecad", "create_object")).toBe("freecad__create_object"); - }); + it("produces __", () => { + expect(namespace("freecad", "create_object")).toBe("freecad__create_object"); + }); - it("handles serverId with special chars", () => { - expect(namespace("chrome-devtools", "navigate")).toBe("chrome-devtools__navigate"); - }); + it("handles serverId with special chars", () => { + expect(namespace("chrome-devtools", "navigate")).toBe("chrome-devtools__navigate"); + }); - it("handles empty toolName", () => { - expect(namespace("server", "")).toBe("server__"); - }); + it("handles empty toolName", () => { + expect(namespace("server", "")).toBe("server__"); + }); }); describe("flattenContent", () => { - it("flattens text content", () => { - const content: McpContentItem[] = [{ type: "text", text: "hello world" }]; - expect(flattenContent(content)).toBe("hello world"); - }); - - it("flattens image content", () => { - const content: McpContentItem[] = [ - { type: "image", data: "base64data", mimeType: "image/png" }, - ]; - expect(flattenContent(content)).toBe("[image: image/png]"); - }); - - it("flattens resource content with text", () => { - const content: McpContentItem[] = [ - { type: "resource", resource: { uri: "file:///test", text: "resource text" } }, - ]; - expect(flattenContent(content)).toBe("resource text"); - }); - - it("flattens resource content without text", () => { - const content: McpContentItem[] = [{ type: "resource", resource: { uri: "file:///test" } }]; - expect(flattenContent(content)).toBe("[resource: file:///test]"); - }); - - it("joins multiple items with newline", () => { - const content: McpContentItem[] = [ - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ]; - expect(flattenContent(content)).toBe("first\nsecond"); - }); - - it("returns empty string for empty content", () => { - expect(flattenContent([])).toBe(""); - }); - - it("handles mixed content types", () => { - const content: McpContentItem[] = [ - { type: "text", text: "here is an image:" }, - { type: "image", data: "data", mimeType: "image/jpeg" }, - { type: "resource", resource: { uri: "file:///x", text: "some data" } }, - ]; - expect(flattenContent(content)).toBe("here is an image:\n[image: image/jpeg]\nsome data"); - }); + it("flattens text content", () => { + const content: McpContentItem[] = [{ type: "text", text: "hello world" }]; + expect(flattenContent(content)).toBe("hello world"); + }); + + it("flattens image content", () => { + const content: McpContentItem[] = [ + { type: "image", data: "base64data", mimeType: "image/png" }, + ]; + expect(flattenContent(content)).toBe("[image: image/png]"); + }); + + it("flattens resource content with text", () => { + const content: McpContentItem[] = [ + { type: "resource", resource: { uri: "file:///test", text: "resource text" } }, + ]; + expect(flattenContent(content)).toBe("resource text"); + }); + + it("flattens resource content without text", () => { + const content: McpContentItem[] = [{ type: "resource", resource: { uri: "file:///test" } }]; + expect(flattenContent(content)).toBe("[resource: file:///test]"); + }); + + it("joins multiple items with newline", () => { + const content: McpContentItem[] = [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]; + expect(flattenContent(content)).toBe("first\nsecond"); + }); + + it("returns empty string for empty content", () => { + expect(flattenContent([])).toBe(""); + }); + + it("handles mixed content types", () => { + const content: McpContentItem[] = [ + { type: "text", text: "here is an image:" }, + { type: "image", data: "data", mimeType: "image/jpeg" }, + { type: "resource", resource: { uri: "file:///x", text: "some data" } }, + ]; + expect(flattenContent(content)).toBe("here is an image:\n[image: image/jpeg]\nsome data"); + }); }); describe("adaptTool", () => { - it("maps inputSchema to ToolParameterSchema", () => { - const mcpTool: McpToolInfo = { - name: "create_obj", - description: "Create an object", - inputSchema: { - type: "object", - properties: { name: { type: "string", description: "Object name" } }, - required: ["name"], - }, - }; - const caller = makeCaller({ content: [] }); - const adapted = adaptTool("freecad", mcpTool, caller); - - expect(adapted.name).toBe("freecad__create_obj"); - expect(adapted.description).toBe("[freecad] Create an object"); - expect(adapted.parameters.type).toBe("object"); - expect(adapted.parameters.properties).toEqual({ - name: { type: "string", description: "Object name" }, - }); - expect(adapted.parameters.required).toEqual(["name"]); - expect(adapted.concurrencySafe).toBe(false); - }); - - it("execute proxies to callTool", async () => { - const mcpTool: McpToolInfo = { - name: "test_tool", - description: "Test", - inputSchema: { type: "object" }, - }; - const caller = makeCaller({ - content: [{ type: "text", text: "called" }], - isError: false, - }); - const adapted = adaptTool("server", mcpTool, caller); - - const result = await adapted.execute( - { input: "value" }, - { - toolCallId: "call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: mockLogger, - }, - ); - - expect(result.content).toBe("called"); - expect(result.isError).toBe(false); - expect(caller.calls).toEqual([{ name: "test_tool", args: { input: "value" } }]); - }); - - it("execute propagates isError", async () => { - const caller = makeCaller({ - content: [{ type: "text", text: "error occurred" }], - isError: true, - }); - - const mcpTool: McpToolInfo = { - name: "fail_tool", - description: "Fails", - inputSchema: { type: "object" }, - }; - const adapted = adaptTool("server", mcpTool, caller); - - const result = await adapted.execute( - {}, - { - toolCallId: "call-2", - onOutput: () => {}, - signal: new AbortController().signal, - log: mockLogger, - }, - ); - - expect(result.isError).toBe(true); - expect(result.content).toBe("error occurred"); - }); - - it("handles inputSchema without optional fields", () => { - const mcpTool: McpToolInfo = { - name: "simple", - description: "Simple tool", - inputSchema: { type: "object" }, - }; - const caller = makeCaller({ content: [] }); - const adapted = adaptTool("server", mcpTool, caller); - - expect(adapted.parameters.type).toBe("object"); - expect(adapted.parameters.properties).toBeUndefined(); - expect(adapted.parameters.required).toBeUndefined(); - expect(adapted.parameters.additionalProperties).toBeUndefined(); - }); - - it("preserves additionalProperties", () => { - const mcpTool: McpToolInfo = { - name: "flex", - description: "Flexible", - inputSchema: { - type: "object", - additionalProperties: true, - }, - }; - const caller = makeCaller({ content: [] }); - const adapted = adaptTool("server", mcpTool, caller); - - expect(adapted.parameters.additionalProperties).toBe(true); - }); - - it("execute flattens multi-item content", async () => { - const caller = makeCaller({ - content: [ - { type: "text", text: "summary" }, - { type: "image", data: "d", mimeType: "image/png" }, - ], - isError: false, - }); - const mcpTool: McpToolInfo = { - name: "multi", - description: "Multi", - inputSchema: { type: "object" }, - }; - const adapted = adaptTool("srv", mcpTool, caller); - - const result = await adapted.execute( - {}, - { - toolCallId: "c", - onOutput: () => {}, - signal: new AbortController().signal, - log: mockLogger, - }, - ); - - expect(result.content).toBe("summary\n[image: image/png]"); - }); + it("maps inputSchema to ToolParameterSchema", () => { + const mcpTool: McpToolInfo = { + name: "create_obj", + description: "Create an object", + inputSchema: { + type: "object", + properties: { name: { type: "string", description: "Object name" } }, + required: ["name"], + }, + }; + const caller = makeCaller({ content: [] }); + const adapted = adaptTool("freecad", mcpTool, caller); + + expect(adapted.name).toBe("freecad__create_obj"); + expect(adapted.description).toBe("[freecad] Create an object"); + expect(adapted.parameters.type).toBe("object"); + expect(adapted.parameters.properties).toEqual({ + name: { type: "string", description: "Object name" }, + }); + expect(adapted.parameters.required).toEqual(["name"]); + expect(adapted.concurrencySafe).toBe(false); + }); + + it("execute proxies to callTool", async () => { + const mcpTool: McpToolInfo = { + name: "test_tool", + description: "Test", + inputSchema: { type: "object" }, + }; + const caller = makeCaller({ + content: [{ type: "text", text: "called" }], + isError: false, + }); + const adapted = adaptTool("server", mcpTool, caller); + + const result = await adapted.execute( + { input: "value" }, + { + toolCallId: "call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: mockLogger, + }, + ); + + expect(result.content).toBe("called"); + expect(result.isError).toBe(false); + expect(caller.calls).toEqual([{ name: "test_tool", args: { input: "value" } }]); + }); + + it("execute propagates isError", async () => { + const caller = makeCaller({ + content: [{ type: "text", text: "error occurred" }], + isError: true, + }); + + const mcpTool: McpToolInfo = { + name: "fail_tool", + description: "Fails", + inputSchema: { type: "object" }, + }; + const adapted = adaptTool("server", mcpTool, caller); + + const result = await adapted.execute( + {}, + { + toolCallId: "call-2", + onOutput: () => {}, + signal: new AbortController().signal, + log: mockLogger, + }, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBe("error occurred"); + }); + + it("handles inputSchema without optional fields", () => { + const mcpTool: McpToolInfo = { + name: "simple", + description: "Simple tool", + inputSchema: { type: "object" }, + }; + const caller = makeCaller({ content: [] }); + const adapted = adaptTool("server", mcpTool, caller); + + expect(adapted.parameters.type).toBe("object"); + expect(adapted.parameters.properties).toBeUndefined(); + expect(adapted.parameters.required).toBeUndefined(); + expect(adapted.parameters.additionalProperties).toBeUndefined(); + }); + + it("preserves additionalProperties", () => { + const mcpTool: McpToolInfo = { + name: "flex", + description: "Flexible", + inputSchema: { + type: "object", + additionalProperties: true, + }, + }; + const caller = makeCaller({ content: [] }); + const adapted = adaptTool("server", mcpTool, caller); + + expect(adapted.parameters.additionalProperties).toBe(true); + }); + + it("execute flattens multi-item content", async () => { + const caller = makeCaller({ + content: [ + { type: "text", text: "summary" }, + { type: "image", data: "d", mimeType: "image/png" }, + ], + isError: false, + }); + const mcpTool: McpToolInfo = { + name: "multi", + description: "Multi", + inputSchema: { type: "object" }, + }; + const adapted = adaptTool("srv", mcpTool, caller); + + const result = await adapted.execute( + {}, + { + toolCallId: "c", + onOutput: () => {}, + signal: new AbortController().signal, + log: mockLogger, + }, + ); + + expect(result.content).toBe("summary\n[image: image/png]"); + }); }); diff --git a/packages/mcp/src/registry.ts b/packages/mcp/src/registry.ts index 7c31c88..e1c95e9 100644 --- a/packages/mcp/src/registry.ts +++ b/packages/mcp/src/registry.ts @@ -9,71 +9,71 @@ */ import type { - ToolContract, - ToolExecuteContext, - ToolParameterSchema, - ToolResult, + ToolContract, + ToolExecuteContext, + ToolParameterSchema, + ToolResult, } from "@dispatch/kernel"; import type { McpContentItem, McpToolCaller, McpToolInfo } from "./types.js"; const NAMESPACE_SEP = "__"; export function namespace(serverId: string, toolName: string): string { - return `${serverId}${NAMESPACE_SEP}${toolName}`; + return `${serverId}${NAMESPACE_SEP}${toolName}`; } export function adaptTool( - serverId: string, - mcpTool: McpToolInfo, - caller: McpToolCaller, + serverId: string, + mcpTool: McpToolInfo, + caller: McpToolCaller, ): ToolContract { - const parameters: ToolParameterSchema = { - type: "object", - ...(mcpTool.inputSchema.properties !== undefined && { - properties: mcpTool.inputSchema.properties, - }), - ...(mcpTool.inputSchema.required !== undefined && { - required: mcpTool.inputSchema.required, - }), - ...(mcpTool.inputSchema.additionalProperties !== undefined && { - additionalProperties: mcpTool.inputSchema.additionalProperties, - }), - }; + const parameters: ToolParameterSchema = { + type: "object", + ...(mcpTool.inputSchema.properties !== undefined && { + properties: mcpTool.inputSchema.properties, + }), + ...(mcpTool.inputSchema.required !== undefined && { + required: mcpTool.inputSchema.required, + }), + ...(mcpTool.inputSchema.additionalProperties !== undefined && { + additionalProperties: mcpTool.inputSchema.additionalProperties, + }), + }; - return { - name: namespace(serverId, mcpTool.name), - description: `[${serverId}] ${mcpTool.description}`, - parameters, - concurrencySafe: false, - execute: async (args: unknown, ctx: ToolExecuteContext): Promise => { - const result = await caller.callTool(mcpTool.name, args, ctx.signal); - const toolResult: ToolResult = { - content: flattenContent(result.content), - }; - if (result.isError !== undefined) { - (toolResult as { isError?: boolean }).isError = result.isError; - } - return toolResult; - }, - }; + return { + name: namespace(serverId, mcpTool.name), + description: `[${serverId}] ${mcpTool.description}`, + parameters, + concurrencySafe: false, + execute: async (args: unknown, ctx: ToolExecuteContext): Promise => { + const result = await caller.callTool(mcpTool.name, args, ctx.signal); + const toolResult: ToolResult = { + content: flattenContent(result.content), + }; + if (result.isError !== undefined) { + (toolResult as { isError?: boolean }).isError = result.isError; + } + return toolResult; + }, + }; } export function flattenContent(content: readonly McpContentItem[]): string { - if (content.length === 0) return ""; + if (content.length === 0) return ""; - const parts: string[] = []; - for (const item of content) { - if (item.type === "text" && item.text !== undefined) { - parts.push(item.text); - } else if (item.type === "image" && item.mimeType !== undefined) { - parts.push(`[image: ${item.mimeType}]`); - } else if (item.type === "resource" && item.resource !== undefined) { - if (item.resource.text !== undefined) { - parts.push(item.resource.text); - } else { - parts.push(`[resource: ${item.resource.uri}]`); - } - } - } - return parts.join("\n"); + const parts: string[] = []; + for (const item of content) { + if (item.type === "text" && item.text !== undefined) { + parts.push(item.text); + } else if (item.type === "image" && item.mimeType !== undefined) { + parts.push(`[image: ${item.mimeType}]`); + } else if (item.type === "resource" && item.resource !== undefined) { + if (item.resource.text !== undefined) { + parts.push(item.resource.text); + } else { + parts.push(`[resource: ${item.resource.uri}]`); + } + } + } + return parts.join("\n"); } diff --git a/packages/mcp/src/rpc.test.ts b/packages/mcp/src/rpc.test.ts index 9ffb121..dc295af 100644 --- a/packages/mcp/src/rpc.test.ts +++ b/packages/mcp/src/rpc.test.ts @@ -2,116 +2,116 @@ import { describe, expect, it } from "vitest"; import { JsonRpcClient } from "./rpc.js"; function makeClient(): { - client: JsonRpcClient; - written: Uint8Array[]; - feedMessage: (msg: unknown) => void; + client: JsonRpcClient; + written: Uint8Array[]; + feedMessage: (msg: unknown) => void; } { - const written: Uint8Array[] = []; - const client = new JsonRpcClient((bytes) => { - written.push(bytes); - }); - return { - client, - written, - feedMessage: (msg: unknown) => { - client.handleMessage(JSON.stringify(msg)); - }, - }; + const written: Uint8Array[] = []; + const client = new JsonRpcClient((bytes) => { + written.push(bytes); + }); + return { + client, + written, + feedMessage: (msg: unknown) => { + client.handleMessage(JSON.stringify(msg)); + }, + }; } describe("JsonRpcClient", () => { - it("request returns result", async () => { - const { client, feedMessage } = makeClient(); + it("request returns result", async () => { + const { client, feedMessage } = makeClient(); - const resultPromise = client.request("initialize", { protocolVersion: "2025-11-25" }); + const resultPromise = client.request("initialize", { protocolVersion: "2025-11-25" }); - feedMessage({ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2025-11-25" } }); + feedMessage({ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2025-11-25" } }); - const result = await resultPromise; - expect(result).toEqual({ protocolVersion: "2025-11-25" }); - }); + const result = await resultPromise; + expect(result).toEqual({ protocolVersion: "2025-11-25" }); + }); - it("request rejects on error response", async () => { - const { client, feedMessage } = makeClient(); + it("request rejects on error response", async () => { + const { client, feedMessage } = makeClient(); - const resultPromise = client.request("bad-method"); + const resultPromise = client.request("bad-method"); - feedMessage({ - jsonrpc: "2.0", - id: 1, - error: { code: -32601, message: "Method not found" }, - }); + feedMessage({ + jsonrpc: "2.0", + id: 1, + error: { code: -32601, message: "Method not found" }, + }); - await expect(resultPromise).rejects.toThrow("Method not found"); - }); + await expect(resultPromise).rejects.toThrow("Method not found"); + }); - it("notify sends without expecting response", () => { - const { client, written } = makeClient(); + it("notify sends without expecting response", () => { + const { client, written } = makeClient(); - client.notify("notifications/initialized", {}); + client.notify("notifications/initialized", {}); - expect(written.length).toBe(1); - const sent = new TextDecoder().decode(written[0]); - expect(sent).toContain('"method":"notifications/initialized"'); - expect(sent).not.toContain('"id"'); - }); + expect(written.length).toBe(1); + const sent = new TextDecoder().decode(written[0]); + expect(sent).toContain('"method":"notifications/initialized"'); + expect(sent).not.toContain('"id"'); + }); - it("onNotification fires for matching method", () => { - const { client, feedMessage } = makeClient(); + it("onNotification fires for matching method", () => { + const { client, feedMessage } = makeClient(); - let received: unknown = "unset"; - client.onNotification("notifications/tools/list_changed", (params) => { - received = params; - }); + let received: unknown = "unset"; + client.onNotification("notifications/tools/list_changed", (params) => { + received = params; + }); - feedMessage({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: { a: 1 } }); + feedMessage({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: { a: 1 } }); - expect(received).toEqual({ a: 1 }); - }); + expect(received).toEqual({ a: 1 }); + }); - it("pending request rejected on close", async () => { - const { client } = makeClient(); + it("pending request rejected on close", async () => { + const { client } = makeClient(); - const resultPromise = client.request("slow-method"); + const resultPromise = client.request("slow-method"); - client.close(); + client.close(); - await expect(resultPromise).rejects.toThrow("Connection closed"); - }); + await expect(resultPromise).rejects.toThrow("Connection closed"); + }); - it("incremental request ids", () => { - const { client, written } = makeClient(); + it("incremental request ids", () => { + const { client, written } = makeClient(); - client.request("a"); - client.request("b"); - client.request("c"); + client.request("a"); + client.request("b"); + client.request("c"); - expect(written.length).toBe(3); - // Extract JSON body from Content-Length framed messages - const parse = (bytes: Uint8Array): { id: number } => { - const text = new TextDecoder().decode(bytes); - const bodyStart = text.indexOf("\r\n\r\n") + 4; - return JSON.parse(text.slice(bodyStart)) as { id: number }; - }; - const msg1 = parse(written[0]); - const msg2 = parse(written[1]); - const msg3 = parse(written[2]); - expect(msg1.id).toBe(1); - expect(msg2.id).toBe(2); - expect(msg3.id).toBe(3); - }); + expect(written.length).toBe(3); + // Extract JSON body from Content-Length framed messages + const parse = (bytes: Uint8Array): { id: number } => { + const text = new TextDecoder().decode(bytes); + const bodyStart = text.indexOf("\r\n\r\n") + 4; + return JSON.parse(text.slice(bodyStart)) as { id: number }; + }; + const msg1 = parse(written[0]); + const msg2 = parse(written[1]); + const msg3 = parse(written[2]); + expect(msg1.id).toBe(1); + expect(msg2.id).toBe(2); + expect(msg3.id).toBe(3); + }); - it("notify after close is silently dropped", () => { - const { client, written } = makeClient(); - client.close(); - const count = written.length; - client.notify("test"); - expect(written.length).toBe(count); - }); + it("notify after close is silently dropped", () => { + const { client, written } = makeClient(); + client.close(); + const count = written.length; + client.notify("test"); + expect(written.length).toBe(count); + }); - it("request after close rejects immediately", async () => { - const { client } = makeClient(); - client.close(); - await expect(client.request("test")).rejects.toThrow("Connection closed"); - }); + it("request after close rejects immediately", async () => { + const { client } = makeClient(); + client.close(); + await expect(client.request("test")).rejects.toThrow("Connection closed"); + }); }); diff --git a/packages/mcp/src/rpc.ts b/packages/mcp/src/rpc.ts index eef8d73..aa89159 100644 --- a/packages/mcp/src/rpc.ts +++ b/packages/mcp/src/rpc.ts @@ -10,94 +10,94 @@ import { encode } from "./framing.js"; export type WriteFn = (bytes: Uint8Array) => void; export interface PendingRequest { - readonly resolve: (value: unknown) => void; - readonly reject: (reason: unknown) => void; + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; } export type NotificationHandler = (params: unknown) => void; export interface JsonRpcMessage { - readonly jsonrpc: "2.0"; - readonly id?: number | string | undefined; - readonly method?: string | undefined; - readonly params?: unknown; - readonly result?: unknown; - readonly error?: - | { readonly code: number; readonly message: string; readonly data?: unknown } - | undefined; + readonly jsonrpc: "2.0"; + readonly id?: number | string | undefined; + readonly method?: string | undefined; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: + | { readonly code: number; readonly message: string; readonly data?: unknown } + | undefined; } export class JsonRpcClient { - private nextId = 1; - private pending = new Map(); - private notificationHandlers = new Map(); - private write: WriteFn; - private closed = false; - - constructor(write: WriteFn) { - this.write = write; - } - - request(method: string, params?: unknown): Promise { - if (this.closed) { - return Promise.reject(new Error("Connection closed")); - } - const id = this.nextId++; - const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.sendMessage(msg); - }); - } - - notify(method: string, params?: unknown): void { - if (this.closed) return; - const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; - this.sendMessage(msg); - } - - onNotification(method: string, handler: NotificationHandler): void { - this.notificationHandlers.set(method, handler); - } - - handleMessage(json: string): void { - const msg = JSON.parse(json) as JsonRpcMessage; - const { id, method } = msg; - - if (method !== undefined && id === undefined) { - this.handleIncomingNotification(method, msg.params); - } else if (id !== undefined) { - this.handleResponse(id, msg); - } - } - - private sendMessage(msg: JsonRpcMessage): void { - this.write(encode(JSON.stringify(msg))); - } - - private handleResponse(id: number | string, msg: JsonRpcMessage): void { - const entry = this.pending.get(id); - if (!entry) return; - this.pending.delete(id); - if (msg.error) { - entry.reject(new Error(msg.error.message)); - } else { - entry.resolve(msg.result); - } - } - - private handleIncomingNotification(method: string, params: unknown): void { - const handler = this.notificationHandlers.get(method); - if (handler) { - handler(params); - } - } - - close(): void { - this.closed = true; - for (const entry of this.pending.values()) { - entry.reject(new Error("Connection closed")); - } - this.pending.clear(); - } + private nextId = 1; + private pending = new Map(); + private notificationHandlers = new Map(); + private write: WriteFn; + private closed = false; + + constructor(write: WriteFn) { + this.write = write; + } + + request(method: string, params?: unknown): Promise { + if (this.closed) { + return Promise.reject(new Error("Connection closed")); + } + const id = this.nextId++; + const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.sendMessage(msg); + }); + } + + notify(method: string, params?: unknown): void { + if (this.closed) return; + const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; + this.sendMessage(msg); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + handleMessage(json: string): void { + const msg = JSON.parse(json) as JsonRpcMessage; + const { id, method } = msg; + + if (method !== undefined && id === undefined) { + this.handleIncomingNotification(method, msg.params); + } else if (id !== undefined) { + this.handleResponse(id, msg); + } + } + + private sendMessage(msg: JsonRpcMessage): void { + this.write(encode(JSON.stringify(msg))); + } + + private handleResponse(id: number | string, msg: JsonRpcMessage): void { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + + private handleIncomingNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (handler) { + handler(params); + } + } + + close(): void { + this.closed = true; + for (const entry of this.pending.values()) { + entry.reject(new Error("Connection closed")); + } + this.pending.clear(); + } } diff --git a/packages/mcp/src/transport.test.ts b/packages/mcp/src/transport.test.ts index b369e74..e828340 100644 --- a/packages/mcp/src/transport.test.ts +++ b/packages/mcp/src/transport.test.ts @@ -9,146 +9,146 @@ import { createStdioTransport } from "./transport.js"; * what we wrote to the child's stdin (our outgoing framed messages). */ function makePipe(): { - process: SpawnedProcess; - emitStdout: (data: Uint8Array) => void; - emitEnd: () => void; - writtenToStdin: () => Uint8Array[]; - killed: () => boolean; + process: SpawnedProcess; + emitStdout: (data: Uint8Array) => void; + emitEnd: () => void; + writtenToStdin: () => Uint8Array[]; + killed: () => boolean; } { - const dataListeners: Array<(data: Uint8Array) => void> = []; - const endListeners: Array<() => void> = []; - const stdinWrites: Uint8Array[] = []; - let killed = false; - - const process: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - stdinWrites.push(bytes); - }, - }, - stdout: { - on: (event: string, cb: (data: Uint8Array) => void) => { - if (event === "data") dataListeners.push(cb); - else if (event === "end") endListeners.push(cb as unknown as () => void); - }, - }, - pid: 12345, - kill: () => { - killed = true; - }, - }; - - return { - process, - emitStdout: (data: Uint8Array) => { - for (const cb of dataListeners) cb(data); - }, - emitEnd: () => { - for (const cb of endListeners) cb(); - }, - writtenToStdin: () => stdinWrites, - killed: () => killed, - }; + const dataListeners: Array<(data: Uint8Array) => void> = []; + const endListeners: Array<() => void> = []; + const stdinWrites: Uint8Array[] = []; + let killed = false; + + const process: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + stdinWrites.push(bytes); + }, + }, + stdout: { + on: (event: string, cb: (data: Uint8Array) => void) => { + if (event === "data") dataListeners.push(cb); + else if (event === "end") endListeners.push(cb as unknown as () => void); + }, + }, + pid: 12345, + kill: () => { + killed = true; + }, + }; + + return { + process, + emitStdout: (data: Uint8Array) => { + for (const cb of dataListeners) cb(data); + }, + emitEnd: () => { + for (const cb of endListeners) cb(); + }, + writtenToStdin: () => stdinWrites, + killed: () => killed, + }; } describe("createStdioTransport", () => { - it("creates connection with correct pid", () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("creates connection with correct pid", () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test-server"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test-server"] }, "/tmp"); - expect(connection.pid).toBe(12345); - connection.close(); - }); + expect(connection.pid).toBe(12345); + connection.close(); + }); - it("connection sends framed messages via stdin", () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("connection sends framed messages via stdin", () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); - connection.notify("test/method", { key: "value" }); + connection.notify("test/method", { key: "value" }); - const writes = pair.writtenToStdin(); - expect(writes.length).toBe(1); - const text = new TextDecoder().decode(writes[0]); - expect(text).toContain("Content-Length:"); - expect(text).toContain('"method":"test/method"'); - connection.close(); - }); + const writes = pair.writtenToStdin(); + expect(writes.length).toBe(1); + const text = new TextDecoder().decode(writes[0]); + expect(text).toContain("Content-Length:"); + expect(text).toContain('"method":"test/method"'); + connection.close(); + }); - it("close kills the child process", () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("close kills the child process", () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); - connection.close(); - expect(pair.killed()).toBe(true); - }); + connection.close(); + expect(pair.killed()).toBe(true); + }); - it("pipes stdout through framing: a notification triggers onNotification", async () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("pipes stdout through framing: a notification triggers onNotification", async () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); - let received: unknown = null; - connection.onNotification("notifications/tools/list_changed", (params) => { - received = params; - }); + let received: unknown = null; + connection.onNotification("notifications/tools/list_changed", (params) => { + received = params; + }); - // Simulate the server writing a framed notification to stdout. - const notification = JSON.stringify({ - jsonrpc: "2.0", - method: "notifications/tools/list_changed", - params: { reason: "tools added" }, - }); - pair.emitStdout(encode(notification)); + // Simulate the server writing a framed notification to stdout. + const notification = JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/tools/list_changed", + params: { reason: "tools added" }, + }); + pair.emitStdout(encode(notification)); - // onNotification is invoked synchronously inside the data handler. - expect(received).toEqual({ reason: "tools added" }); - connection.close(); - }); + // onNotification is invoked synchronously inside the data handler. + expect(received).toEqual({ reason: "tools added" }); + connection.close(); + }); - it("pipes stdout through framing: a response resolves a request", async () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("pipes stdout through framing: a response resolves a request", async () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); - const resultPromise = connection.send("tools/list"); + const resultPromise = connection.send("tools/list"); - // The request was framed and written to stdin; respond via stdout. - const response = JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }] }, - }); - pair.emitStdout(encode(response)); + // The request was framed and written to stdin; respond via stdout. + const response = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }] }, + }); + pair.emitStdout(encode(response)); - const result = await resultPromise; - expect(result).toEqual({ - tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }], - }); - connection.close(); - }); + const result = await resultPromise; + expect(result).toEqual({ + tools: [{ name: "t", description: "d", inputSchema: { type: "object" } }], + }); + connection.close(); + }); - it("handles a frame split across two stdout chunks", async () => { - const pair = makePipe(); - const spawn: SpawnProcess = () => pair.process; + it("handles a frame split across two stdout chunks", async () => { + const pair = makePipe(); + const spawn: SpawnProcess = () => pair.process; - const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); + const { connection } = createStdioTransport({ spawn, command: ["test"] }, "/tmp"); - const resultPromise = connection.send("ping"); + const resultPromise = connection.send("ping"); - const response = encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } })); - const mid = Math.floor(response.length / 2); - pair.emitStdout(response.slice(0, mid)); - pair.emitStdout(response.slice(mid)); + const response = encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } })); + const mid = Math.floor(response.length / 2); + pair.emitStdout(response.slice(0, mid)); + pair.emitStdout(response.slice(mid)); - await expect(resultPromise).resolves.toEqual({ ok: true }); - connection.close(); - }); + await expect(resultPromise).resolves.toEqual({ ok: true }); + connection.close(); + }); }); diff --git a/packages/mcp/src/transport.ts b/packages/mcp/src/transport.ts index b1e3ec5..492879f 100644 --- a/packages/mcp/src/transport.ts +++ b/packages/mcp/src/transport.ts @@ -7,95 +7,95 @@ import { FrameDecoder } from "./framing.js"; import { JsonRpcClient, type WriteFn } from "./rpc.js"; export interface SpawnedProcess { - readonly stdin: { readonly write: (bytes: Uint8Array) => void }; - readonly stdout: - | AsyncIterable - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; - readonly stderr?: - | AsyncIterable - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } - | undefined; - readonly pid: number | undefined; - readonly kill: () => void; + readonly stdin: { readonly write: (bytes: Uint8Array) => void }; + readonly stdout: + | AsyncIterable + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; + readonly stderr?: + | AsyncIterable + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } + | undefined; + readonly pid: number | undefined; + readonly kill: () => void; } export type SpawnProcess = ( - command: readonly string[], - opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, + command: readonly string[], + opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, ) => SpawnedProcess; export interface Connection { - readonly send: (method: string, params?: unknown) => Promise; - readonly notify: (method: string, params?: unknown) => void; - readonly onNotification: (method: string, handler: (params: unknown) => void) => void; - readonly close: () => void; - readonly pid: number | undefined; + readonly send: (method: string, params?: unknown) => Promise; + readonly notify: (method: string, params?: unknown) => void; + readonly onNotification: (method: string, handler: (params: unknown) => void) => void; + readonly close: () => void; + readonly pid: number | undefined; } export interface StdioTransportDeps { - readonly spawn: SpawnProcess; - readonly command: readonly string[]; - readonly env?: Readonly>; + readonly spawn: SpawnProcess; + readonly command: readonly string[]; + readonly env?: Readonly>; } export function createStdioTransport( - deps: StdioTransportDeps, - cwd: string, + deps: StdioTransportDeps, + cwd: string, ): { connection: Connection; promise: Promise } { - const spawnOpts: { readonly cwd: string; readonly env?: Readonly> } = { - cwd, - }; - if (deps.env) { - (spawnOpts as { env?: Readonly> }).env = deps.env; - } + const spawnOpts: { readonly cwd: string; readonly env?: Readonly> } = { + cwd, + }; + if (deps.env) { + (spawnOpts as { env?: Readonly> }).env = deps.env; + } - const proc = deps.spawn(deps.command, spawnOpts); - const decoder = new FrameDecoder(); + const proc = deps.spawn(deps.command, spawnOpts); + const decoder = new FrameDecoder(); - const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); - const rpc = new JsonRpcClient(writeFn); + const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); + const rpc = new JsonRpcClient(writeFn); - const stdoutSource = proc.stdout; - const promise = new Promise((resolve, reject) => { - if (Symbol.asyncIterator in stdoutSource) { - (async () => { - try { - for await (const chunk of stdoutSource as AsyncIterable) { - const messages = decoder.decode(chunk); - for (const msg of messages) { - rpc.handleMessage(msg); - } - } - resolve(); - } catch (err: unknown) { - reject(err); - } - })(); - } else { - const source = stdoutSource as { - readonly on: (event: string, cb: (data: Uint8Array) => void) => void; - }; - source.on("data", (data: Uint8Array) => { - const messages = decoder.decode(data); - for (const msg of messages) { - rpc.handleMessage(msg); - } - }); - source.on("end", () => resolve()); - source.on("error", (err: unknown) => reject(err)); - } - }); + const stdoutSource = proc.stdout; + const promise = new Promise((resolve, reject) => { + if (Symbol.asyncIterator in stdoutSource) { + (async () => { + try { + for await (const chunk of stdoutSource as AsyncIterable) { + const messages = decoder.decode(chunk); + for (const msg of messages) { + rpc.handleMessage(msg); + } + } + resolve(); + } catch (err: unknown) { + reject(err); + } + })(); + } else { + const source = stdoutSource as { + readonly on: (event: string, cb: (data: Uint8Array) => void) => void; + }; + source.on("data", (data: Uint8Array) => { + const messages = decoder.decode(data); + for (const msg of messages) { + rpc.handleMessage(msg); + } + }); + source.on("end", () => resolve()); + source.on("error", (err: unknown) => reject(err)); + } + }); - const connection: Connection = { - send: (method, params) => rpc.request(method, params), - notify: (method, params) => rpc.notify(method, params), - onNotification: (method, handler) => rpc.onNotification(method, handler), - close: () => { - rpc.close(); - proc.kill(); - }, - pid: proc.pid, - }; + const connection: Connection = { + send: (method, params) => rpc.request(method, params), + notify: (method, params) => rpc.notify(method, params), + onNotification: (method, handler) => rpc.onNotification(method, handler), + close: () => { + rpc.close(); + proc.kill(); + }, + pid: proc.pid, + }; - return { connection, promise }; + return { connection, promise }; } diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 522511b..fb851fd 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -5,75 +5,75 @@ import type { ToolParameterSchema } from "@dispatch/kernel"; export interface McpServerConfig { - readonly command: string; - readonly args?: readonly string[]; - readonly env?: Readonly>; + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; } export interface ResolvedMcpServer { - readonly id: string; - readonly command: readonly string[]; - readonly env?: Readonly>; - readonly configSource: ".dispatch/mcp.json" | "opencode.json"; + readonly id: string; + readonly command: readonly string[]; + readonly env?: Readonly>; + readonly configSource: ".dispatch/mcp.json" | "opencode.json"; } export interface ResolveResult { - readonly servers: readonly ResolvedMcpServer[]; - readonly shadowed: boolean; + readonly servers: readonly ResolvedMcpServer[]; + readonly shadowed: boolean; } export type McpServerState = "connecting" | "connected" | "error" | "disconnected"; export interface McpServerStatus { - readonly id: string; - readonly state: McpServerState; - readonly error?: string; - readonly toolCount: number; + readonly id: string; + readonly state: McpServerState; + readonly error?: string; + readonly toolCount: number; } export interface McpToolInfo { - readonly name: string; - readonly description: string; - readonly inputSchema: ToolParameterSchema; + readonly name: string; + readonly description: string; + readonly inputSchema: ToolParameterSchema; } export interface McpContentItem { - readonly type: string; - readonly text?: string; - readonly data?: string; - readonly mimeType?: string; - readonly resource?: { - readonly uri: string; - readonly text?: string; - }; + readonly type: string; + readonly text?: string; + readonly data?: string; + readonly mimeType?: string; + readonly resource?: { + readonly uri: string; + readonly text?: string; + }; } export interface McpCallResult { - readonly content: readonly McpContentItem[]; - readonly isError?: boolean; + readonly content: readonly McpContentItem[]; + readonly isError?: boolean; } export interface McpServerCapabilities { - readonly tools?: { - readonly listChanged?: boolean; - }; + readonly tools?: { + readonly listChanged?: boolean; + }; } export interface McpInitializeResult { - readonly protocolVersion: string; - readonly capabilities: McpServerCapabilities; - readonly serverInfo: { - readonly name: string; - readonly version: string; - }; + readonly protocolVersion: string; + readonly capabilities: McpServerCapabilities; + readonly serverInfo: { + readonly name: string; + readonly version: string; + }; } export interface McpService { - readonly status: (cwd: string) => Promise; + readonly status: (cwd: string) => Promise; } export interface McpListToolsResult { - readonly tools: readonly McpToolInfo[]; + readonly tools: readonly McpToolInfo[]; } /** @@ -83,5 +83,5 @@ export interface McpListToolsResult { * `McpClient` satisfies this structurally. */ export interface McpToolCaller { - readonly callTool: (name: string, args: unknown, signal?: AbortSignal) => Promise; + readonly callTool: (name: string, args: unknown, signal?: AbortSignal) => Promise; } diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 2ae3233..0a366d6 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }] } diff --git a/packages/message-queue/package.json b/packages/message-queue/package.json index a8fcc4b..75df09e 100644 --- a/packages/message-queue/package.json +++ b/packages/message-queue/package.json @@ -1,14 +1,14 @@ { - "name": "@dispatch/message-queue", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/ui-contract": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/message-queue", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/message-queue/src/extension.ts b/packages/message-queue/src/extension.ts index 26d19ca..29979be 100644 --- a/packages/message-queue/src/extension.ts +++ b/packages/message-queue/src/extension.ts @@ -12,71 +12,71 @@ import { buildQueueSpec, MESSAGE_QUEUE_SURFACE_ID } from "./pure.js"; import { createMessageQueueService, messageQueueHandle } from "./service.js"; export const manifest: Manifest = { - id: "message-queue", - name: "Message Queue", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["surface-registry"], - capabilities: {}, - contributes: { - services: ["message-queue"], - }, + id: "message-queue", + name: "Message Queue", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["surface-registry"], + capabilities: {}, + contributes: { + services: ["message-queue"], + }, }; export function activate(host: HostAPI): void { - const registry = host.getService(surfaceRegistryHandle); + const registry = host.getService(surfaceRegistryHandle); - const subscribers = new Set<() => void>(); + const subscribers = new Set<() => void>(); - const service = createMessageQueueService({ - id: () => crypto.randomUUID(), - now: () => Date.now(), - notify: () => { - for (const sub of subscribers) { - sub(); - } - }, - logger: host.logger, - }); + const service = createMessageQueueService({ + id: () => crypto.randomUUID(), + now: () => Date.now(), + notify: () => { + for (const sub of subscribers) { + sub(); + } + }, + logger: host.logger, + }); - host.provideService(messageQueueHandle, service); + host.provideService(messageQueueHandle, service); - function getSpec(context?: SurfaceContext): SurfaceSpec { - const convId = context?.conversationId; - const messages = convId === undefined ? [] : service.getQueue(convId); - return buildQueueSpec(messages); - } + function getSpec(context?: SurfaceContext): SurfaceSpec { + const convId = context?.conversationId; + const messages = convId === undefined ? [] : service.getQueue(convId); + return buildQueueSpec(messages); + } - function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { - // The message-queue surface is read-only: a client renders the queue and - // the session-orchestrator drains it. No client-facing actions. - } + function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { + // The message-queue surface is read-only: a client renders the queue and + // the session-orchestrator drains it. No client-facing actions. + } - const provider: SurfaceProvider = { - catalogEntry: { - id: MESSAGE_QUEUE_SURFACE_ID, - region: "side", - title: "Message Queue", - scope: "conversation", - }, - getSpec, - invoke, - subscribe(onChange) { - subscribers.add(onChange); - return () => { - subscribers.delete(onChange); - }; - }, - }; + const provider: SurfaceProvider = { + catalogEntry: { + id: MESSAGE_QUEUE_SURFACE_ID, + region: "side", + title: "Message Queue", + scope: "conversation", + }, + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; + }, + }; - registry.register(provider); + registry.register(provider); - host.logger.info("message-queue: registered"); + host.logger.info("message-queue: registered"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/message-queue/src/index.ts b/packages/message-queue/src/index.ts index 79bbb25..11467e1 100644 --- a/packages/message-queue/src/index.ts +++ b/packages/message-queue/src/index.ts @@ -9,19 +9,19 @@ export type { QueuedMessage, QueuePayload } from "@dispatch/wire"; export { extension, manifest } from "./extension.js"; export { - buildQueueSpec, - combine, - drain, - enqueue, - getQueue, - MESSAGE_QUEUE_RENDERER_ID, - MESSAGE_QUEUE_SURFACE_ID, - type MessageQueueState, - type QueueDeps, + buildQueueSpec, + combine, + drain, + enqueue, + getQueue, + MESSAGE_QUEUE_RENDERER_ID, + MESSAGE_QUEUE_SURFACE_ID, + type MessageQueueState, + type QueueDeps, } from "./pure.js"; export { - createMessageQueueService, - type MessageQueueDeps, - type MessageQueueService, - messageQueueHandle, + createMessageQueueService, + type MessageQueueDeps, + type MessageQueueService, + messageQueueHandle, } from "./service.js"; diff --git a/packages/message-queue/src/pure.test.ts b/packages/message-queue/src/pure.test.ts index ff1a08f..3fd6039 100644 --- a/packages/message-queue/src/pure.test.ts +++ b/packages/message-queue/src/pure.test.ts @@ -1,144 +1,144 @@ import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - buildQueueSpec, - combine, - drain, - enqueue, - getQueue, - MESSAGE_QUEUE_RENDERER_ID, - MESSAGE_QUEUE_SURFACE_ID, - type MessageQueueState, - type QueueDeps, + buildQueueSpec, + combine, + drain, + enqueue, + getQueue, + MESSAGE_QUEUE_RENDERER_ID, + MESSAGE_QUEUE_SURFACE_ID, + type MessageQueueState, + type QueueDeps, } from "./pure.js"; function makeDeps(): QueueDeps { - let idCounter = 0; - let now = 1_000; - return { - id: () => `id-${++idCounter}`, - now: () => (now += 10), - }; + let idCounter = 0; + let now = 1_000; + return { + id: () => `id-${++idCounter}`, + now: () => (now += 10), + }; } describe("enqueue", () => { - it("enqueue appends + returns snapshot with the new message (id unique, queuedAt set)", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); - - const first = enqueue(state, "c1", "first", deps); - expect(first).toHaveLength(1); - const firstMsg = first[0]; - if (firstMsg === undefined) throw new Error("expected a message"); - expect(firstMsg.id).toBe("id-1"); - expect(firstMsg.text).toBe("first"); - expect(firstMsg.queuedAt).toBe(1_010); - - const second = enqueue(state, "c1", "second", deps); - expect(second).toHaveLength(2); - const secondMsg = second[1]; - if (secondMsg === undefined) throw new Error("expected a message"); - expect(secondMsg.id).toBe("id-2"); // unique per message - expect(secondMsg.text).toBe("second"); - expect(secondMsg.queuedAt).toBe(1_020); - - // a separate conversation does not share state - const other = enqueue(state, "c2", "other", deps); - expect(other).toHaveLength(1); - expect(getQueue(state, "c2")).toHaveLength(1); - }); + it("enqueue appends + returns snapshot with the new message (id unique, queuedAt set)", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + + const first = enqueue(state, "c1", "first", deps); + expect(first).toHaveLength(1); + const firstMsg = first[0]; + if (firstMsg === undefined) throw new Error("expected a message"); + expect(firstMsg.id).toBe("id-1"); + expect(firstMsg.text).toBe("first"); + expect(firstMsg.queuedAt).toBe(1_010); + + const second = enqueue(state, "c1", "second", deps); + expect(second).toHaveLength(2); + const secondMsg = second[1]; + if (secondMsg === undefined) throw new Error("expected a message"); + expect(secondMsg.id).toBe("id-2"); // unique per message + expect(secondMsg.text).toBe("second"); + expect(secondMsg.queuedAt).toBe(1_020); + + // a separate conversation does not share state + const other = enqueue(state, "c2", "other", deps); + expect(other).toHaveLength(1); + expect(getQueue(state, "c2")).toHaveLength(1); + }); }); describe("getQueue", () => { - it("getQueue returns current snapshot; empty array when none", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); + it("getQueue returns current snapshot; empty array when none", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); - expect(getQueue(state, "unknown")).toEqual([]); + expect(getQueue(state, "unknown")).toEqual([]); - enqueue(state, "c1", "x", deps); - enqueue(state, "c1", "y", deps); - const snap = getQueue(state, "c1"); - expect(snap.map((m) => m.text)).toEqual(["x", "y"]); + enqueue(state, "c1", "x", deps); + enqueue(state, "c1", "y", deps); + const snap = getQueue(state, "c1"); + expect(snap.map((m) => m.text)).toEqual(["x", "y"]); - // returns a COPY — mutating it does not affect live state - snap.push({ id: "evil", text: "mutate", queuedAt: 0 }); - expect(getQueue(state, "c1")).toHaveLength(2); - }); + // returns a COPY — mutating it does not affect live state + snap.push({ id: "evil", text: "mutate", queuedAt: 0 }); + expect(getQueue(state, "c1")).toHaveLength(2); + }); }); describe("drain", () => { - it("drain returns all + clears; second drain returns []", () => { - const state: MessageQueueState = new Map(); - const deps = makeDeps(); - enqueue(state, "c1", "a", deps); - enqueue(state, "c1", "b", deps); - - const drained = drain(state, "c1"); - expect(drained.map((m) => m.text)).toEqual(["a", "b"]); - - // cleared - expect(getQueue(state, "c1")).toEqual([]); - expect(drain(state, "c1")).toEqual([]); - }); - - it("drain on an empty queue returns [] and is a no-op", () => { - const state: MessageQueueState = new Map(); - - // never had a queue - expect(drain(state, "c1")).toEqual([]); - expect(getQueue(state, "c1")).toEqual([]); - - // and after a drain that emptied it, draining again is a no-op - const deps = makeDeps(); - enqueue(state, "c1", "once", deps); - drain(state, "c1"); - expect(drain(state, "c1")).toEqual([]); - expect(getQueue(state, "c1")).toEqual([]); - }); + it("drain returns all + clears; second drain returns []", () => { + const state: MessageQueueState = new Map(); + const deps = makeDeps(); + enqueue(state, "c1", "a", deps); + enqueue(state, "c1", "b", deps); + + const drained = drain(state, "c1"); + expect(drained.map((m) => m.text)).toEqual(["a", "b"]); + + // cleared + expect(getQueue(state, "c1")).toEqual([]); + expect(drain(state, "c1")).toEqual([]); + }); + + it("drain on an empty queue returns [] and is a no-op", () => { + const state: MessageQueueState = new Map(); + + // never had a queue + expect(drain(state, "c1")).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + + // and after a drain that emptied it, draining again is a no-op + const deps = makeDeps(); + enqueue(state, "c1", "once", deps); + drain(state, "c1"); + expect(drain(state, "c1")).toEqual([]); + expect(getQueue(state, "c1")).toEqual([]); + }); }); describe("combine", () => { - it("combine joins texts with blank-line separator", () => { - const msgs: QueuedMessage[] = [ - { id: "1", text: "alpha", queuedAt: 1 }, - { id: "2", text: "beta", queuedAt: 2 }, - ]; - expect(combine(msgs)).toBe("alpha\n\nbeta"); - expect(combine([])).toBe(""); - expect(combine([{ id: "1", text: "solo", queuedAt: 1 }])).toBe("solo"); - }); + it("combine joins texts with blank-line separator", () => { + const msgs: QueuedMessage[] = [ + { id: "1", text: "alpha", queuedAt: 1 }, + { id: "2", text: "beta", queuedAt: 2 }, + ]; + expect(combine(msgs)).toBe("alpha\n\nbeta"); + expect(combine([])).toBe(""); + expect(combine([{ id: "1", text: "solo", queuedAt: 1 }])).toBe("solo"); + }); }); describe("buildQueueSpec", () => { - it("renders a custom field with the queue snapshot", () => { - const msgs: QueuedMessage[] = [ - { id: "1", text: "a", queuedAt: 1 }, - { id: "2", text: "b", queuedAt: 2 }, - ]; - const spec = buildQueueSpec(msgs); - expect(spec.id).toBe(MESSAGE_QUEUE_SURFACE_ID); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Message Queue"); - expect(spec.fields).toHaveLength(1); - - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - expect(field.rendererId).toBe(MESSAGE_QUEUE_RENDERER_ID); - const payload = field.payload as { messages: readonly QueuedMessage[] }; - expect(payload.messages).toHaveLength(2); - expect(payload.messages.map((m) => m.text)).toEqual(["a", "b"]); - }); - - it("renders an empty list for an empty queue", () => { - const spec = buildQueueSpec([]); - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - const payload = field.payload as { messages: readonly QueuedMessage[] }; - expect(payload.messages).toEqual([]); - }); + it("renders a custom field with the queue snapshot", () => { + const msgs: QueuedMessage[] = [ + { id: "1", text: "a", queuedAt: 1 }, + { id: "2", text: "b", queuedAt: 2 }, + ]; + const spec = buildQueueSpec(msgs); + expect(spec.id).toBe(MESSAGE_QUEUE_SURFACE_ID); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Message Queue"); + expect(spec.fields).toHaveLength(1); + + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + expect(field.rendererId).toBe(MESSAGE_QUEUE_RENDERER_ID); + const payload = field.payload as { messages: readonly QueuedMessage[] }; + expect(payload.messages).toHaveLength(2); + expect(payload.messages.map((m) => m.text)).toEqual(["a", "b"]); + }); + + it("renders an empty list for an empty queue", () => { + const spec = buildQueueSpec([]); + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + const payload = field.payload as { messages: readonly QueuedMessage[] }; + expect(payload.messages).toEqual([]); + }); }); diff --git a/packages/message-queue/src/pure.ts b/packages/message-queue/src/pure.ts index 834018b..981e005 100644 --- a/packages/message-queue/src/pure.ts +++ b/packages/message-queue/src/pure.ts @@ -17,10 +17,10 @@ export type MessageQueueState = Map; /** Injected effectful factories kept out of the pure core. */ export interface QueueDeps { - /** Stable (client-visible) id factory for UI keying + dedup. */ - readonly id: () => string; - /** Clock returning epoch-ms for `queuedAt`. */ - readonly now: () => number; + /** Stable (client-visible) id factory for UI keying + dedup. */ + readonly id: () => string; + /** Clock returning epoch-ms for `queuedAt`. */ + readonly now: () => number; } /** Surface id this extension contributes (also the manifest + catalog id). */ @@ -34,19 +34,19 @@ export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; * live state through the returned value. */ export function enqueue( - state: MessageQueueState, - conversationId: string, - text: string, - deps: QueueDeps, + state: MessageQueueState, + conversationId: string, + text: string, + deps: QueueDeps, ): QueuedMessage[] { - const message: QueuedMessage = { id: deps.id(), text, queuedAt: deps.now() }; - const existing = state.get(conversationId); - if (existing === undefined) { - state.set(conversationId, [message]); - } else { - existing.push(message); - } - return getQueue(state, conversationId); + const message: QueuedMessage = { id: deps.id(), text, queuedAt: deps.now() }; + const existing = state.get(conversationId); + if (existing === undefined) { + state.set(conversationId, [message]); + } else { + existing.push(message); + } + return getQueue(state, conversationId); } /** @@ -54,9 +54,9 @@ export function enqueue( * if the conversation has no queue / is unknown. */ export function getQueue(state: MessageQueueState, conversationId: string): QueuedMessage[] { - const existing = state.get(conversationId); - if (existing === undefined) return []; - return [...existing]; + const existing = state.get(conversationId); + if (existing === undefined) return []; + return [...existing]; } /** @@ -67,11 +67,11 @@ export function getQueue(state: MessageQueueState, conversationId: string): Queu * ChatMessage. */ export function drain(state: MessageQueueState, conversationId: string): QueuedMessage[] { - const existing = state.get(conversationId); - if (existing === undefined || existing.length === 0) return []; - const drained = [...existing]; - state.delete(conversationId); - return drained; + const existing = state.get(conversationId); + if (existing === undefined || existing.length === 0) return []; + const drained = [...existing]; + state.delete(conversationId); + return drained; } /** @@ -80,7 +80,7 @@ export function drain(state: MessageQueueState, conversationId: string): QueuedM * ChatMessage from this. */ export function combine(messages: readonly QueuedMessage[]): string { - return messages.map((m) => m.text).join("\n\n"); + return messages.map((m) => m.text).join("\n\n"); } /** @@ -90,16 +90,16 @@ export function combine(messages: readonly QueuedMessage[]): string { * I/O; the surface-registry re-fetches this on every notify. */ export function buildQueueSpec(messages: readonly QueuedMessage[]): SurfaceSpec { - const payload: QueuePayload = { messages }; - const field: CustomField = { - kind: "custom", - rendererId: MESSAGE_QUEUE_RENDERER_ID, - payload, - }; - return { - id: MESSAGE_QUEUE_SURFACE_ID, - region: "side", - title: "Message Queue", - fields: [field], - }; + const payload: QueuePayload = { messages }; + const field: CustomField = { + kind: "custom", + rendererId: MESSAGE_QUEUE_RENDERER_ID, + payload, + }; + return { + id: MESSAGE_QUEUE_SURFACE_ID, + region: "side", + title: "Message Queue", + fields: [field], + }; } diff --git a/packages/message-queue/src/service.test.ts b/packages/message-queue/src/service.test.ts index 04cde91..aa59dd3 100644 --- a/packages/message-queue/src/service.test.ts +++ b/packages/message-queue/src/service.test.ts @@ -5,97 +5,97 @@ import { buildQueueSpec, combine } from "./pure.js"; import { createMessageQueueService, type MessageQueueDeps } from "./service.js"; interface Recorder extends MessageQueueDeps { - readonly calls: { value: number }; + readonly calls: { value: number }; } function makeDeps(): Recorder { - let idCounter = 0; - let now = 1_000; - const calls = { value: 0 }; - return { - id: () => `q-${++idCounter}`, - now: () => (now += 10), - notify: () => { - calls.value += 1; - }, - calls, - }; + let idCounter = 0; + let now = 1_000; + const calls = { value: 0 }; + return { + id: () => `q-${++idCounter}`, + now: () => (now += 10), + notify: () => { + calls.value += 1; + }, + calls, + }; } function queueMessages(spec: SurfaceSpec): readonly QueuedMessage[] { - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - return (field.payload as QueuePayload).messages; + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + return (field.payload as QueuePayload).messages; } describe("message-queue service", () => { - it("enqueue pushes a surface update (snapshot grew)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - expect(deps.calls.value).toBe(0); + it("enqueue pushes a surface update (snapshot grew)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + expect(deps.calls.value).toBe(0); - const snapshot = svc.enqueue("c1", "hello"); - expect(deps.calls.value).toBe(1); // surface update pushed - expect(snapshot).toHaveLength(1); - const first = snapshot[0]; - if (first === undefined) throw new Error("expected a message"); - expect(first.text).toBe("hello"); - expect(typeof first.id).toBe("string"); - expect(first.queuedAt).toBe(1_010); + const snapshot = svc.enqueue("c1", "hello"); + expect(deps.calls.value).toBe(1); // surface update pushed + expect(snapshot).toHaveLength(1); + const first = snapshot[0]; + if (first === undefined) throw new Error("expected a message"); + expect(first.text).toBe("hello"); + expect(typeof first.id).toBe("string"); + expect(first.queuedAt).toBe(1_010); - // the surface spec reflects the grown snapshot - expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toHaveLength(1); - }); + // the surface spec reflects the grown snapshot + expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toHaveLength(1); + }); - it("drain pushes a surface update (empty)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - svc.enqueue("c1", "a"); - svc.enqueue("c1", "b"); - expect(deps.calls.value).toBe(2); // two enqueues notified + it("drain pushes a surface update (empty)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "a"); + svc.enqueue("c1", "b"); + expect(deps.calls.value).toBe(2); // two enqueues notified - const drained = svc.drain("c1"); - expect(deps.calls.value).toBe(3); // drain pushed a surface update - expect(drained).toHaveLength(2); - expect(drained.map((m) => m.text)).toEqual(["a", "b"]); + const drained = svc.drain("c1"); + expect(deps.calls.value).toBe(3); // drain pushed a surface update + expect(drained).toHaveLength(2); + expect(drained.map((m) => m.text)).toEqual(["a", "b"]); - // cleared + surface now shows empty - expect(svc.getQueue("c1")).toEqual([]); - expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toEqual([]); - }); + // cleared + surface now shows empty + expect(svc.getQueue("c1")).toEqual([]); + expect(queueMessages(buildQueueSpec(svc.getQueue("c1")))).toEqual([]); + }); - it("drain on empty does NOT push a surface update (already empty)", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); + it("drain on empty does NOT push a surface update (already empty)", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); - expect(svc.drain("c1")).toEqual([]); - expect(deps.calls.value).toBe(0); // no notify — no change + expect(svc.drain("c1")).toEqual([]); + expect(deps.calls.value).toBe(0); // no notify — no change - // after a real drain empties it, a further drain is a no-op (no notify) - svc.enqueue("c1", "x"); - expect(deps.calls.value).toBe(1); - svc.drain("c1"); - expect(deps.calls.value).toBe(2); - svc.drain("c1"); - expect(deps.calls.value).toBe(2); // unchanged — queue was already empty - }); + // after a real drain empties it, a further drain is a no-op (no notify) + svc.enqueue("c1", "x"); + expect(deps.calls.value).toBe(1); + svc.drain("c1"); + expect(deps.calls.value).toBe(2); + svc.drain("c1"); + expect(deps.calls.value).toBe(2); // unchanged — queue was already empty + }); - it("getQueue returns current snapshot; empty for unknown conversation", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - expect(svc.getQueue("nope")).toEqual([]); - svc.enqueue("c1", "hi"); - expect(svc.getQueue("c1")).toHaveLength(1); - }); + it("getQueue returns current snapshot; empty for unknown conversation", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + expect(svc.getQueue("nope")).toEqual([]); + svc.enqueue("c1", "hi"); + expect(svc.getQueue("c1")).toHaveLength(1); + }); - it("drain returns the raw QueuedMessage[] the orchestrator combines", () => { - const deps = makeDeps(); - const svc = createMessageQueueService(deps); - svc.enqueue("c1", "alpha"); - svc.enqueue("c1", "beta"); - const drained = svc.drain("c1"); - expect(combine(drained)).toBe("alpha\n\nbeta"); - }); + it("drain returns the raw QueuedMessage[] the orchestrator combines", () => { + const deps = makeDeps(); + const svc = createMessageQueueService(deps); + svc.enqueue("c1", "alpha"); + svc.enqueue("c1", "beta"); + const drained = svc.drain("c1"); + expect(combine(drained)).toBe("alpha\n\nbeta"); + }); }); diff --git a/packages/message-queue/src/service.ts b/packages/message-queue/src/service.ts index 91d0fc5..97e270d 100644 --- a/packages/message-queue/src/service.ts +++ b/packages/message-queue/src/service.ts @@ -18,17 +18,17 @@ import { drain as drainQueue, enqueue as enqueueMessage, getQueue as readQueue } * `host.getService(messageQueueHandle)`. */ export interface MessageQueueService { - /** Append a message; return the CURRENT queue snapshot (post-append). */ - enqueue(conversationId: string, text: string): QueuedMessage[]; - /** Current queue snapshot (empty array if none / unknown conversation). */ - getQueue(conversationId: string): QueuedMessage[]; - /** - * Drain: return all queued messages, CLEAR the queue, and push a surface - * update (empty). Returns the raw `QueuedMessage[]` (NOT a ChatMessage — - * the caller combines + builds the ChatMessage). Empty array if the queue - * was empty (and then NO surface update is pushed — no change). - */ - drain(conversationId: string): QueuedMessage[]; + /** Append a message; return the CURRENT queue snapshot (post-append). */ + enqueue(conversationId: string, text: string): QueuedMessage[]; + /** Current queue snapshot (empty array if none / unknown conversation). */ + getQueue(conversationId: string): QueuedMessage[]; + /** + * Drain: return all queued messages, CLEAR the queue, and push a surface + * update (empty). Returns the raw `QueuedMessage[]` (NOT a ChatMessage — + * the caller combines + builds the ChatMessage). Empty array if the queue + * was empty (and then NO surface update is pushed — no change). + */ + drain(conversationId: string): QueuedMessage[]; } /** @@ -36,19 +36,19 @@ export interface MessageQueueService { * session-orchestrator imports to reach the queue — no string-keyed lookup. */ export const messageQueueHandle: ServiceHandle = - defineService("message-queue"); + defineService("message-queue"); /** Deps for the service shell — the pure core's deps plus the surface notifier. */ export interface MessageQueueDeps extends QueueDeps { - /** - * Notify the surface-registry that the queue changed, so the transport - * re-fetches + pushes a full new SurfaceUpdate. Called ONLY on a real - * change (enqueue → grew; drain-non-empty → emptied); a no-op drain of an - * already-empty queue does NOT notify (no change → no surface push). - */ - readonly notify: () => void; - /** Injected host logger (optional in tests). Logs counts/lengths at debug, never bodies. */ - readonly logger?: Logger; + /** + * Notify the surface-registry that the queue changed, so the transport + * re-fetches + pushes a full new SurfaceUpdate. Called ONLY on a real + * change (enqueue → grew; drain-non-empty → emptied); a no-op drain of an + * already-empty queue does NOT notify (no change → no surface push). + */ + readonly notify: () => void; + /** Injected host logger (optional in tests). Logs counts/lengths at debug, never bodies. */ + readonly logger?: Logger; } /** @@ -58,31 +58,31 @@ export interface MessageQueueDeps extends QueueDeps { * in this closure, reachable only through the returned service. */ export function createMessageQueueService(deps: MessageQueueDeps): MessageQueueService { - const state: MessageQueueState = new Map(); + const state: MessageQueueState = new Map(); - return { - enqueue(conversationId, text) { - const snapshot = enqueueMessage(state, conversationId, text, deps); - deps.logger?.debug("message-queue: enqueued", { - conversationId, - queueLen: snapshot.length, - textLen: text.length, - }); - deps.notify(); - return snapshot; - }, - getQueue(conversationId) { - return readQueue(state, conversationId); - }, - drain(conversationId) { - const drained = drainQueue(state, conversationId); - if (drained.length === 0) return drained; - deps.logger?.debug("message-queue: drained", { - conversationId, - count: drained.length, - }); - deps.notify(); - return drained; - }, - }; + return { + enqueue(conversationId, text) { + const snapshot = enqueueMessage(state, conversationId, text, deps); + deps.logger?.debug("message-queue: enqueued", { + conversationId, + queueLen: snapshot.length, + textLen: text.length, + }); + deps.notify(); + return snapshot; + }, + getQueue(conversationId) { + return readQueue(state, conversationId); + }, + drain(conversationId) { + const drained = drainQueue(state, conversationId); + if (drained.length === 0) return drained; + deps.logger?.debug("message-queue: drained", { + conversationId, + count: drained.length, + }); + deps.notify(); + return drained; + }, + }; } diff --git a/packages/message-queue/tsconfig.json b/packages/message-queue/tsconfig.json index da5dfb7..1c0d44b 100644 --- a/packages/message-queue/tsconfig.json +++ b/packages/message-queue/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../surface-registry" }, - { "path": "../ui-contract" }, - { "path": "../wire" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../surface-registry" }, + { "path": "../ui-contract" }, + { "path": "../wire" } + ] } diff --git a/packages/observability-collector/package.json b/packages/observability-collector/package.json index b744dc7..8d3ed1a 100644 --- a/packages/observability-collector/package.json +++ b/packages/observability-collector/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/observability-collector", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/trace-store": "workspace:*" - } + "name": "@dispatch/observability-collector", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/trace-store": "workspace:*" + } } diff --git a/packages/observability-collector/src/collector.test.ts b/packages/observability-collector/src/collector.test.ts index 1dd03d4..5c76071 100644 --- a/packages/observability-collector/src/collector.test.ts +++ b/packages/observability-collector/src/collector.test.ts @@ -10,91 +10,91 @@ import { drainOnce, readOffset, shouldPrune, splitLines, writeOffset } from "./c // --- Fixtures --- const log1: LogRecord = { - kind: "log", - level: "info", - msg: "first", - timestamp: 1700000000000, - extensionId: "ext-1", - turnId: "turn-1", + kind: "log", + level: "info", + msg: "first", + timestamp: 1700000000000, + extensionId: "ext-1", + turnId: "turn-1", }; const log2: LogRecord = { - kind: "log", - level: "warn", - msg: "second", - timestamp: 1700000000100, - extensionId: "ext-1", - turnId: "turn-1", + kind: "log", + level: "warn", + msg: "second", + timestamp: 1700000000100, + extensionId: "ext-1", + turnId: "turn-1", }; const spanOpen: LogRecord = { - kind: "span-open", - spanId: "span-1", - name: "step", - timestamp: 1700000000200, - extensionId: "ext-1", - turnId: "turn-1", + kind: "span-open", + spanId: "span-1", + name: "step", + timestamp: 1700000000200, + extensionId: "ext-1", + turnId: "turn-1", }; const spanClose: LogRecord = { - kind: "span-close", - spanId: "span-1", - name: "step", - timestamp: 1700000000500, - durationMs: 300, - status: "ok", - extensionId: "ext-1", - turnId: "turn-1", + kind: "span-close", + spanId: "span-1", + name: "step", + timestamp: 1700000000500, + durationMs: 300, + status: "ok", + extensionId: "ext-1", + turnId: "turn-1", }; function toNdjson(records: LogRecord[]): string { - return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`; + return `${records.map((r) => JSON.stringify(r)).join("\n")}\n`; } // --- splitLines (pure) --- describe("splitLines", () => { - it("splits multiple lines", () => { - const { lines, remainder } = splitLines("a\nb\nc\n"); - expect(lines).toEqual(["a", "b", "c"]); - expect(remainder).toBe(""); - }); - - it("holds a torn last line as remainder (no trailing newline)", () => { - const { lines, remainder } = splitLines("a\nb\nc"); - expect(lines).toEqual(["a", "b"]); - expect(remainder).toBe("c"); - }); - - it("returns empty lines and empty remainder for empty buffer", () => { - const { lines, remainder } = splitLines(""); - expect(lines).toEqual([]); - expect(remainder).toBe(""); - }); - - it("handles a single complete line", () => { - const { lines, remainder } = splitLines("hello\n"); - expect(lines).toEqual(["hello"]); - expect(remainder).toBe(""); - }); - - it("handles a single incomplete line", () => { - const { lines, remainder } = splitLines("hello"); - expect(lines).toEqual([]); - expect(remainder).toBe("hello"); - }); - - it("handles consecutive newlines (empty lines)", () => { - const { lines, remainder } = splitLines("a\n\nb\n"); - expect(lines).toEqual(["a", "", "b"]); - expect(remainder).toBe(""); - }); - - it("handles only newlines", () => { - const { lines, remainder } = splitLines("\n\n\n"); - expect(lines).toEqual(["", "", ""]); - expect(remainder).toBe(""); - }); + it("splits multiple lines", () => { + const { lines, remainder } = splitLines("a\nb\nc\n"); + expect(lines).toEqual(["a", "b", "c"]); + expect(remainder).toBe(""); + }); + + it("holds a torn last line as remainder (no trailing newline)", () => { + const { lines, remainder } = splitLines("a\nb\nc"); + expect(lines).toEqual(["a", "b"]); + expect(remainder).toBe("c"); + }); + + it("returns empty lines and empty remainder for empty buffer", () => { + const { lines, remainder } = splitLines(""); + expect(lines).toEqual([]); + expect(remainder).toBe(""); + }); + + it("handles a single complete line", () => { + const { lines, remainder } = splitLines("hello\n"); + expect(lines).toEqual(["hello"]); + expect(remainder).toBe(""); + }); + + it("handles a single incomplete line", () => { + const { lines, remainder } = splitLines("hello"); + expect(lines).toEqual([]); + expect(remainder).toBe("hello"); + }); + + it("handles consecutive newlines (empty lines)", () => { + const { lines, remainder } = splitLines("a\n\nb\n"); + expect(lines).toEqual(["a", "", "b"]); + expect(remainder).toBe(""); + }); + + it("handles only newlines", () => { + const { lines, remainder } = splitLines("\n\n\n"); + expect(lines).toEqual(["", "", ""]); + expect(remainder).toBe(""); + }); }); // --- drainOnce (integration with real temp files + in-memory store) --- @@ -102,339 +102,339 @@ describe("splitLines", () => { let tmpDir: string; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "collector-test-")); + tmpDir = mkdtempSync(join(tmpdir(), "collector-test-")); }); afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); }); describe("drainOnce", () => { - it("reads N NDJSON records from journal and inserts into store", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, toNdjson([log1, log2, spanOpen])); - - const store = createTraceStore({ path: ":memory:" }); - const result = drainOnce({ journalPath, offset: 0, store }); - - expect(result.newOffset).toBeGreaterThan(0); - const turn = store.getTurn("turn-1"); - expect(turn).toHaveLength(3); - store.close(); - }); - - it("returns offset at EOF after draining all records", () => { - const journalPath = join(tmpDir, "journal.log"); - const content = toNdjson([log1, log2]); - writeFileSync(journalPath, content); - - const store = createTraceStore({ path: ":memory:" }); - const result = drainOnce({ journalPath, offset: 0, store }); - - expect(result.newOffset).toBe(Buffer.byteLength(content, "utf8")); - store.close(); - }); - - it("appends more lines and drains only new records from newOffset", () => { - const journalPath = join(tmpDir, "journal.log"); - const initial = toNdjson([log1]); - writeFileSync(journalPath, initial); - - const store = createTraceStore({ path: ":memory:" }); - const r1 = drainOnce({ journalPath, offset: 0, store }); - expect(store.getTurn("turn-1")).toHaveLength(1); - - // Append more - const additional = toNdjson([log2, spanOpen]); - writeFileSync(journalPath, initial + additional, { flag: "a" }); - - const r2 = drainOnce({ journalPath, offset: r1.newOffset, store }); - expect(r2.newOffset).toBeGreaterThan(r1.newOffset); - expect(store.getTurn("turn-1")).toHaveLength(3); - store.close(); - }); - - it("holds a torn last line (no trailing newline) until newline arrives", () => { - const journalPath = join(tmpDir, "journal.log"); - const full = toNdjson([log1]); - // Write log1 + partial log2 (no trailing newline) - const partial = JSON.stringify(log2); - writeFileSync(journalPath, full + partial); - - const store = createTraceStore({ path: ":memory:" }); - const r1 = drainOnce({ journalPath, offset: 0, store }); - - // Only log1 should be inserted; partial log2 is held - expect(store.getTurn("turn-1")).toHaveLength(1); - - // Now append newline to complete log2 - writeFileSync(journalPath, `${full + partial}\n`, { flag: "a" }); - drainOnce({ journalPath, offset: r1.newOffset, store }); - - expect(store.getTurn("turn-1")).toHaveLength(2); - store.close(); - }); - - it("skips malformed lines (warn, no throw)", () => { - const journalPath = join(tmpDir, "journal.log"); - const good = JSON.stringify(log1); - const bad = "this is not valid json{{{"; - const good2 = JSON.stringify(log2); - writeFileSync(journalPath, `${good}\n${bad}\n${good2}\n`); - - const store = createTraceStore({ path: ":memory:" }); - const warnCalls: unknown[][] = []; - const origWarn = console.warn; - console.warn = (...args: unknown[]) => warnCalls.push(args); - - try { - drainOnce({ journalPath, offset: 0, store }); - } finally { - console.warn = origWarn; - } - - const turn = store.getTurn("turn-1"); - expect(turn).toHaveLength(2); - expect(warnCalls.length).toBeGreaterThan(0); - expect(String(warnCalls[0]?.[0])).toContain("malformed line"); - store.close(); - }); - - it("re-draining from offset 0 inserts no duplicates (idempotent)", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, toNdjson([log1, log2, spanOpen, spanClose])); - - const store = createTraceStore({ path: ":memory:" }); - - // Drain twice from offset 0 - drainOnce({ journalPath, offset: 0, store }); - drainOnce({ journalPath, offset: 0, store }); - - const turn = store.getTurn("turn-1"); - // trace-store uses INSERT OR IGNORE, so no duplicates - expect(turn).toHaveLength(4); - store.close(); - }); - - it("returns same offset when journal is empty", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, ""); - - const store = createTraceStore({ path: ":memory:" }); - const result = drainOnce({ journalPath, offset: 0, store }); - - expect(result.newOffset).toBe(0); - store.close(); - }); - - it("returns same offset when no new content past offset", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, toNdjson([log1])); - - const store = createTraceStore({ path: ":memory:" }); - const r1 = drainOnce({ journalPath, offset: 0, store }); - const r2 = drainOnce({ journalPath, offset: r1.newOffset, store }); - - expect(r2.newOffset).toBe(r1.newOffset); - store.close(); - }); - - it("returns same offset when journal file does not exist", () => { - const journalPath = join(tmpDir, "nonexistent.log"); - const store = createTraceStore({ path: ":memory:" }); - const result = drainOnce({ journalPath, offset: 0, store }); - - expect(result.newOffset).toBe(0); - store.close(); - }); + it("reads N NDJSON records from journal and inserts into store", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, toNdjson([log1, log2, spanOpen])); + + const store = createTraceStore({ path: ":memory:" }); + const result = drainOnce({ journalPath, offset: 0, store }); + + expect(result.newOffset).toBeGreaterThan(0); + const turn = store.getTurn("turn-1"); + expect(turn).toHaveLength(3); + store.close(); + }); + + it("returns offset at EOF after draining all records", () => { + const journalPath = join(tmpDir, "journal.log"); + const content = toNdjson([log1, log2]); + writeFileSync(journalPath, content); + + const store = createTraceStore({ path: ":memory:" }); + const result = drainOnce({ journalPath, offset: 0, store }); + + expect(result.newOffset).toBe(Buffer.byteLength(content, "utf8")); + store.close(); + }); + + it("appends more lines and drains only new records from newOffset", () => { + const journalPath = join(tmpDir, "journal.log"); + const initial = toNdjson([log1]); + writeFileSync(journalPath, initial); + + const store = createTraceStore({ path: ":memory:" }); + const r1 = drainOnce({ journalPath, offset: 0, store }); + expect(store.getTurn("turn-1")).toHaveLength(1); + + // Append more + const additional = toNdjson([log2, spanOpen]); + writeFileSync(journalPath, initial + additional, { flag: "a" }); + + const r2 = drainOnce({ journalPath, offset: r1.newOffset, store }); + expect(r2.newOffset).toBeGreaterThan(r1.newOffset); + expect(store.getTurn("turn-1")).toHaveLength(3); + store.close(); + }); + + it("holds a torn last line (no trailing newline) until newline arrives", () => { + const journalPath = join(tmpDir, "journal.log"); + const full = toNdjson([log1]); + // Write log1 + partial log2 (no trailing newline) + const partial = JSON.stringify(log2); + writeFileSync(journalPath, full + partial); + + const store = createTraceStore({ path: ":memory:" }); + const r1 = drainOnce({ journalPath, offset: 0, store }); + + // Only log1 should be inserted; partial log2 is held + expect(store.getTurn("turn-1")).toHaveLength(1); + + // Now append newline to complete log2 + writeFileSync(journalPath, `${full + partial}\n`, { flag: "a" }); + drainOnce({ journalPath, offset: r1.newOffset, store }); + + expect(store.getTurn("turn-1")).toHaveLength(2); + store.close(); + }); + + it("skips malformed lines (warn, no throw)", () => { + const journalPath = join(tmpDir, "journal.log"); + const good = JSON.stringify(log1); + const bad = "this is not valid json{{{"; + const good2 = JSON.stringify(log2); + writeFileSync(journalPath, `${good}\n${bad}\n${good2}\n`); + + const store = createTraceStore({ path: ":memory:" }); + const warnCalls: unknown[][] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => warnCalls.push(args); + + try { + drainOnce({ journalPath, offset: 0, store }); + } finally { + console.warn = origWarn; + } + + const turn = store.getTurn("turn-1"); + expect(turn).toHaveLength(2); + expect(warnCalls.length).toBeGreaterThan(0); + expect(String(warnCalls[0]?.[0])).toContain("malformed line"); + store.close(); + }); + + it("re-draining from offset 0 inserts no duplicates (idempotent)", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, toNdjson([log1, log2, spanOpen, spanClose])); + + const store = createTraceStore({ path: ":memory:" }); + + // Drain twice from offset 0 + drainOnce({ journalPath, offset: 0, store }); + drainOnce({ journalPath, offset: 0, store }); + + const turn = store.getTurn("turn-1"); + // trace-store uses INSERT OR IGNORE, so no duplicates + expect(turn).toHaveLength(4); + store.close(); + }); + + it("returns same offset when journal is empty", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, ""); + + const store = createTraceStore({ path: ":memory:" }); + const result = drainOnce({ journalPath, offset: 0, store }); + + expect(result.newOffset).toBe(0); + store.close(); + }); + + it("returns same offset when no new content past offset", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, toNdjson([log1])); + + const store = createTraceStore({ path: ":memory:" }); + const r1 = drainOnce({ journalPath, offset: 0, store }); + const r2 = drainOnce({ journalPath, offset: r1.newOffset, store }); + + expect(r2.newOffset).toBe(r1.newOffset); + store.close(); + }); + + it("returns same offset when journal file does not exist", () => { + const journalPath = join(tmpDir, "nonexistent.log"); + const store = createTraceStore({ path: ":memory:" }); + const result = drainOnce({ journalPath, offset: 0, store }); + + expect(result.newOffset).toBe(0); + store.close(); + }); }); // --- Offset persistence --- describe("readOffset / writeOffset", () => { - it("returns 0 when sidecar file does not exist", () => { - expect(readOffset(join(tmpDir, "nope.offset"))).toBe(0); - }); - - it("reads back a persisted offset", () => { - const path = join(tmpDir, "test.offset"); - writeOffset(path, 42); - expect(readOffset(path)).toBe(42); - }); - - it("overwrites previous offset", () => { - const path = join(tmpDir, "test.offset"); - writeOffset(path, 100); - writeOffset(path, 200); - expect(readOffset(path)).toBe(200); - }); - - it("returns 0 for non-numeric content", () => { - const path = join(tmpDir, "bad.offset"); - writeFileSync(path, "not-a-number"); - expect(readOffset(path)).toBe(0); - }); - - it("returns 0 for negative content", () => { - const path = join(tmpDir, "neg.offset"); - writeFileSync(path, "-5"); - expect(readOffset(path)).toBe(0); - }); + it("returns 0 when sidecar file does not exist", () => { + expect(readOffset(join(tmpDir, "nope.offset"))).toBe(0); + }); + + it("reads back a persisted offset", () => { + const path = join(tmpDir, "test.offset"); + writeOffset(path, 42); + expect(readOffset(path)).toBe(42); + }); + + it("overwrites previous offset", () => { + const path = join(tmpDir, "test.offset"); + writeOffset(path, 100); + writeOffset(path, 200); + expect(readOffset(path)).toBe(200); + }); + + it("returns 0 for non-numeric content", () => { + const path = join(tmpDir, "bad.offset"); + writeFileSync(path, "not-a-number"); + expect(readOffset(path)).toBe(0); + }); + + it("returns 0 for negative content", () => { + const path = join(tmpDir, "neg.offset"); + writeFileSync(path, "-5"); + expect(readOffset(path)).toBe(0); + }); }); // --- shouldPrune (pure) --- describe("shouldPrune", () => { - it("shouldPrune is false before the interval elapses", () => { - expect(shouldPrune(1000, 1000, 60_000)).toBe(false); - expect(shouldPrune(59_999, 1000, 60_000)).toBe(false); - }); - - it("shouldPrune is true once the interval has elapsed", () => { - expect(shouldPrune(61_000, 1000, 60_000)).toBe(true); - expect(shouldPrune(70_000, 1000, 60_000)).toBe(true); - }); + it("shouldPrune is false before the interval elapses", () => { + expect(shouldPrune(1000, 1000, 60_000)).toBe(false); + expect(shouldPrune(59_999, 1000, 60_000)).toBe(false); + }); + + it("shouldPrune is true once the interval has elapsed", () => { + expect(shouldPrune(61_000, 1000, 60_000)).toBe(true); + expect(shouldPrune(70_000, 1000, 60_000)).toBe(true); + }); }); // --- Prune integration (real in-memory trace-store + injected clock) --- function runCollectorTicks(opts: { - store: TraceStore; - journalPath: string; - pruneIntervalMs: number; - now: number; - tickCount: number; - clockAdvancePerTick: number; + store: TraceStore; + journalPath: string; + pruneIntervalMs: number; + now: number; + tickCount: number; + clockAdvancePerTick: number; }): { pruneCalls: number; now: number } { - const { - store, - journalPath, - pruneIntervalMs, - now: startNow, - tickCount, - clockAdvancePerTick, - } = opts; - let now = startNow; - let lastPruneAt = now; - let pruneCalls = 0; - - for (let i = 0; i < tickCount; i++) { - drainOnce({ journalPath, offset: 0, store }); - if (shouldPrune(now, lastPruneAt, pruneIntervalMs)) { - store.prune(DEFAULT_RETENTION); - pruneCalls++; - lastPruneAt = now; - } - now += clockAdvancePerTick; - } - - return { pruneCalls, now }; + const { + store, + journalPath, + pruneIntervalMs, + now: startNow, + tickCount, + clockAdvancePerTick, + } = opts; + let now = startNow; + let lastPruneAt = now; + let pruneCalls = 0; + + for (let i = 0; i < tickCount; i++) { + drainOnce({ journalPath, offset: 0, store }); + if (shouldPrune(now, lastPruneAt, pruneIntervalMs)) { + store.prune(DEFAULT_RETENTION); + pruneCalls++; + lastPruneAt = now; + } + now += clockAdvancePerTick; + } + + return { pruneCalls, now }; } describe("prune integration", () => { - it("collector invokes store.prune once after the prune interval elapses", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, ""); - - const store = createTraceStore({ path: ":memory:" }); - const result = runCollectorTicks({ - store, - journalPath, - pruneIntervalMs: 60_000, - now: 1000, - tickCount: 13_000, - clockAdvancePerTick: 5, - }); - - expect(result.pruneCalls).toBe(1); - store.close(); - }); - - it("collector does not prune on every drain", () => { - const journalPath = join(tmpDir, "journal.log"); - writeFileSync(journalPath, ""); - - const store = createTraceStore({ path: ":memory:" }); - const result = runCollectorTicks({ - store, - journalPath, - pruneIntervalMs: 60_000, - now: 1000, - tickCount: 100, - clockAdvancePerTick: 10, - }); - - expect(result.pruneCalls).toBe(0); - store.close(); - }); - - it("a prune error is logged and does not stop draining", () => { - const journalPath = join(tmpDir, "journal.log"); - const recentTs = Date.now() - 1000; - const recentLog1: LogRecord = { ...log1, timestamp: recentTs }; - const recentLog2: LogRecord = { ...log2, timestamp: recentTs }; - writeFileSync(journalPath, toNdjson([recentLog1, recentLog2])); - - const store = createTraceStore({ path: ":memory:" }); - let pruneCalls = 0; - let nextPruneThrows = true; - const realPrune = store.prune.bind(store); - store.prune = (policy) => { - pruneCalls++; - if (nextPruneThrows) { - nextPruneThrows = false; - throw new Error("simulated prune failure"); - } - return realPrune(policy); - }; - - let drainSuccesses = 0; - let now = 1000; - let lastPruneAt = now; - const pruneIntervalMs = 60_000; - - for (let i = 0; i < 4; i++) { - const result = drainOnce({ journalPath, offset: 0, store }); - if (result.newOffset > 0) drainSuccesses++; - if (shouldPrune(now, lastPruneAt, pruneIntervalMs)) { - try { - store.prune(DEFAULT_RETENTION); - } catch { - // expected in test - } - lastPruneAt = now; - } - now += 60_000; - } - - expect(pruneCalls).toBe(3); - expect(drainSuccesses).toBe(4); - const turn = store.getTurn("turn-1"); - expect(turn).toHaveLength(2); - store.close(); - }); - - it("body inserts flow through content-addressed path unchanged", () => { - const journalPath = join(tmpDir, "journal.log"); - const bodyLog: LogRecord = { - kind: "log", - level: "info", - msg: "with-body", - timestamp: 1700000000000, - extensionId: "ext-1", - turnId: "turn-2", - body: "request payload content", - }; - writeFileSync(journalPath, toNdjson([bodyLog])); - - const store = createTraceStore({ path: ":memory:" }); - drainOnce({ journalPath, offset: 0, store }); - - const turn = store.getTurn("turn-2"); - expect(turn).toHaveLength(1); - const record = turn[0]; - if (record === undefined) throw new Error("expected record"); - expect(record.body).toBe("request payload content"); - store.close(); - }); + it("collector invokes store.prune once after the prune interval elapses", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, ""); + + const store = createTraceStore({ path: ":memory:" }); + const result = runCollectorTicks({ + store, + journalPath, + pruneIntervalMs: 60_000, + now: 1000, + tickCount: 13_000, + clockAdvancePerTick: 5, + }); + + expect(result.pruneCalls).toBe(1); + store.close(); + }); + + it("collector does not prune on every drain", () => { + const journalPath = join(tmpDir, "journal.log"); + writeFileSync(journalPath, ""); + + const store = createTraceStore({ path: ":memory:" }); + const result = runCollectorTicks({ + store, + journalPath, + pruneIntervalMs: 60_000, + now: 1000, + tickCount: 100, + clockAdvancePerTick: 10, + }); + + expect(result.pruneCalls).toBe(0); + store.close(); + }); + + it("a prune error is logged and does not stop draining", () => { + const journalPath = join(tmpDir, "journal.log"); + const recentTs = Date.now() - 1000; + const recentLog1: LogRecord = { ...log1, timestamp: recentTs }; + const recentLog2: LogRecord = { ...log2, timestamp: recentTs }; + writeFileSync(journalPath, toNdjson([recentLog1, recentLog2])); + + const store = createTraceStore({ path: ":memory:" }); + let pruneCalls = 0; + let nextPruneThrows = true; + const realPrune = store.prune.bind(store); + store.prune = (policy) => { + pruneCalls++; + if (nextPruneThrows) { + nextPruneThrows = false; + throw new Error("simulated prune failure"); + } + return realPrune(policy); + }; + + let drainSuccesses = 0; + let now = 1000; + let lastPruneAt = now; + const pruneIntervalMs = 60_000; + + for (let i = 0; i < 4; i++) { + const result = drainOnce({ journalPath, offset: 0, store }); + if (result.newOffset > 0) drainSuccesses++; + if (shouldPrune(now, lastPruneAt, pruneIntervalMs)) { + try { + store.prune(DEFAULT_RETENTION); + } catch { + // expected in test + } + lastPruneAt = now; + } + now += 60_000; + } + + expect(pruneCalls).toBe(3); + expect(drainSuccesses).toBe(4); + const turn = store.getTurn("turn-1"); + expect(turn).toHaveLength(2); + store.close(); + }); + + it("body inserts flow through content-addressed path unchanged", () => { + const journalPath = join(tmpDir, "journal.log"); + const bodyLog: LogRecord = { + kind: "log", + level: "info", + msg: "with-body", + timestamp: 1700000000000, + extensionId: "ext-1", + turnId: "turn-2", + body: "request payload content", + }; + writeFileSync(journalPath, toNdjson([bodyLog])); + + const store = createTraceStore({ path: ":memory:" }); + drainOnce({ journalPath, offset: 0, store }); + + const turn = store.getTurn("turn-2"); + expect(turn).toHaveLength(1); + const record = turn[0]; + if (record === undefined) throw new Error("expected record"); + expect(record.body).toBe("request payload content"); + store.close(); + }); }); diff --git a/packages/observability-collector/src/collector.ts b/packages/observability-collector/src/collector.ts index 157c379..2842915 100644 --- a/packages/observability-collector/src/collector.ts +++ b/packages/observability-collector/src/collector.ts @@ -4,39 +4,39 @@ import type { TraceStore } from "@dispatch/trace-store"; // --- Pure core (no I/O) --- export function shouldPrune(now: number, lastPruneAt: number, intervalMs: number): boolean { - return now - lastPruneAt >= intervalMs; + return now - lastPruneAt >= intervalMs; } export interface Logger { - readonly info: (...args: readonly unknown[]) => void; - readonly debug: (...args: readonly unknown[]) => void; + readonly info: (...args: readonly unknown[]) => void; + readonly debug: (...args: readonly unknown[]) => void; } export function splitLines(buffer: string): { lines: string[]; remainder: string } { - const lines: string[] = []; - let start = 0; - - for (let i = 0; i < buffer.length; i++) { - if (buffer[i] === "\n") { - lines.push(buffer.slice(start, i)); - start = i + 1; - } - } - - const remainder = buffer.slice(start); - return { lines, remainder }; + const lines: string[] = []; + let start = 0; + + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] === "\n") { + lines.push(buffer.slice(start, i)); + start = i + 1; + } + } + + const remainder = buffer.slice(start); + return { lines, remainder }; } // --- Drain step (the unit of work) --- export interface DrainOpts { - readonly journalPath: string; - readonly offset: number; - readonly store: TraceStore; + readonly journalPath: string; + readonly offset: number; + readonly store: TraceStore; } export interface DrainResult { - readonly newOffset: number; + readonly newOffset: number; } /** @@ -46,46 +46,46 @@ export interface DrainResult { * consumed complete lines (excluding any held remainder). */ export function drainOnce(opts: DrainOpts): DrainResult { - const { journalPath, offset, store } = opts; - - let content: string; - try { - content = readFileFromOffset(journalPath, offset); - } catch { - return { newOffset: offset }; - } - - if (content.length === 0) { - return { newOffset: offset }; - } - - const { lines } = splitLines(content); - - if (lines.length === 0) { - return { newOffset: offset }; - } - - const records: LogRecord[] = []; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - try { - const parsed: LogRecord = JSON.parse(trimmed); - records.push(parsed); - } catch (err) { - console.warn("[observability-collector] skipping malformed line:", err); - } - } - - if (records.length > 0) { - store.insertRecords(records); - } - - const consumedBytes = Buffer.byteLength( - lines.join("\n") + (lines.length > 0 ? "\n" : ""), - "utf8", - ); - return { newOffset: offset + consumedBytes }; + const { journalPath, offset, store } = opts; + + let content: string; + try { + content = readFileFromOffset(journalPath, offset); + } catch { + return { newOffset: offset }; + } + + if (content.length === 0) { + return { newOffset: offset }; + } + + const { lines } = splitLines(content); + + if (lines.length === 0) { + return { newOffset: offset }; + } + + const records: LogRecord[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + const parsed: LogRecord = JSON.parse(trimmed); + records.push(parsed); + } catch (err) { + console.warn("[observability-collector] skipping malformed line:", err); + } + } + + if (records.length > 0) { + store.insertRecords(records); + } + + const consumedBytes = Buffer.byteLength( + lines.join("\n") + (lines.length > 0 ? "\n" : ""), + "utf8", + ); + return { newOffset: offset + consumedBytes }; } // --- Offset persistence --- @@ -94,73 +94,73 @@ export function drainOnce(opts: DrainOpts): DrainResult { * Read the resume offset from a sidecar file. Returns 0 if missing. */ export function readOffset(sidecarPath: string): number { - try { - const content = readFileUtf8(sidecarPath).trim(); - const parsed = Number(content); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; - } catch { - return 0; - } + try { + const content = readFileUtf8(sidecarPath).trim(); + const parsed = Number(content); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + } catch { + return 0; + } } /** * Persist the resume offset to a sidecar file. */ export function writeOffset(sidecarPath: string, offset: number): void { - writeFileUtf8(sidecarPath, String(offset)); + writeFileUtf8(sidecarPath, String(offset)); } // --- I/O abstraction (injected at edges, default = real fs) --- export interface FsOps { - readonly readFileFromOffset: (path: string, offset: number) => string; - readonly readFileUtf8: (path: string) => string; - readonly writeFileUtf8: (path: string, data: string) => void; + readonly readFileFromOffset: (path: string, offset: number) => string; + readonly readFileUtf8: (path: string) => string; + readonly writeFileUtf8: (path: string, data: string) => void; } let fsOps: FsOps = createDefaultFsOps(); export function setFsOps(ops: FsOps): void { - fsOps = ops; + fsOps = ops; } export function resetFsOps(): void { - fsOps = createDefaultFsOps(); + fsOps = createDefaultFsOps(); } function readFileFromOffset(path: string, offset: number): string { - return fsOps.readFileFromOffset(path, offset); + return fsOps.readFileFromOffset(path, offset); } function readFileUtf8(path: string): string { - return fsOps.readFileUtf8(path); + return fsOps.readFileUtf8(path); } function writeFileUtf8(path: string, data: string): void { - fsOps.writeFileUtf8(path, data); + fsOps.writeFileUtf8(path, data); } function createDefaultFsOps(): FsOps { - const fs = require("node:fs") as typeof import("node:fs"); - return { - readFileFromOffset(path: string, offset: number): string { - const fd = fs.openSync(path, "r"); - try { - const stat = fs.fstatSync(fd); - const bytesToRead = stat.size - offset; - if (bytesToRead <= 0) return ""; - const buf = Buffer.alloc(bytesToRead); - fs.readSync(fd, buf, 0, bytesToRead, offset); - return buf.toString("utf8"); - } finally { - fs.closeSync(fd); - } - }, - readFileUtf8(path: string): string { - return fs.readFileSync(path, "utf8"); - }, - writeFileUtf8(path: string, data: string): void { - fs.writeFileSync(path, data, "utf8"); - }, - }; + const fs = require("node:fs") as typeof import("node:fs"); + return { + readFileFromOffset(path: string, offset: number): string { + const fd = fs.openSync(path, "r"); + try { + const stat = fs.fstatSync(fd); + const bytesToRead = stat.size - offset; + if (bytesToRead <= 0) return ""; + const buf = Buffer.alloc(bytesToRead); + fs.readSync(fd, buf, 0, bytesToRead, offset); + return buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + }, + readFileUtf8(path: string): string { + return fs.readFileSync(path, "utf8"); + }, + writeFileUtf8(path: string, data: string): void { + fs.writeFileSync(path, data, "utf8"); + }, + }; } diff --git a/packages/observability-collector/src/index.ts b/packages/observability-collector/src/index.ts index a0e6e50..a606fa5 100644 --- a/packages/observability-collector/src/index.ts +++ b/packages/observability-collector/src/index.ts @@ -1,10 +1,10 @@ export type { DrainOpts, DrainResult, FsOps, Logger } from "./collector.js"; export { - drainOnce, - readOffset, - resetFsOps, - setFsOps, - shouldPrune, - splitLines, - writeOffset, + drainOnce, + readOffset, + resetFsOps, + setFsOps, + shouldPrune, + splitLines, + writeOffset, } from "./collector.js"; diff --git a/packages/observability-collector/src/main.ts b/packages/observability-collector/src/main.ts index 828b837..6d03092 100644 --- a/packages/observability-collector/src/main.ts +++ b/packages/observability-collector/src/main.ts @@ -5,110 +5,110 @@ import { drainOnce, readOffset, shouldPrune, writeOffset } from "./collector.js" // --- Argv parsing --- interface CliArgs { - readonly journal: string; - readonly db: string; - readonly interval: number; - readonly pruneIntervalMs: number; + readonly journal: string; + readonly db: string; + readonly interval: number; + readonly pruneIntervalMs: number; } function parseArgs(argv: string[]): CliArgs { - let journal = ""; - let db = "./.dispatch-data/traces.db"; - let interval = 250; - let pruneIntervalMs = 60_000; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--journal" && i + 1 < argv.length) { - journal = argv[i + 1] ?? ""; - i++; - } else if (arg === "--db" && i + 1 < argv.length) { - db = argv[i + 1] ?? db; - i++; - } else if (arg === "--interval" && i + 1 < argv.length) { - const val = Number(argv[i + 1]); - if (Number.isFinite(val) && val > 0) interval = val; - i++; - } else if (arg === "--prune-interval-ms" && i + 1 < argv.length) { - const val = Number(argv[i + 1]); - if (Number.isFinite(val) && val > 0) pruneIntervalMs = val; - i++; - } - } - - if (!journal) { - console.error( - "Usage: observability-collector --journal [--db ] [--interval ] [--prune-interval-ms ]", - ); - process.exit(1); - } - - return { journal, db, interval, pruneIntervalMs }; + let journal = ""; + let db = "./.dispatch-data/traces.db"; + let interval = 250; + let pruneIntervalMs = 60_000; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--journal" && i + 1 < argv.length) { + journal = argv[i + 1] ?? ""; + i++; + } else if (arg === "--db" && i + 1 < argv.length) { + db = argv[i + 1] ?? db; + i++; + } else if (arg === "--interval" && i + 1 < argv.length) { + const val = Number(argv[i + 1]); + if (Number.isFinite(val) && val > 0) interval = val; + i++; + } else if (arg === "--prune-interval-ms" && i + 1 < argv.length) { + const val = Number(argv[i + 1]); + if (Number.isFinite(val) && val > 0) pruneIntervalMs = val; + i++; + } + } + + if (!journal) { + console.error( + "Usage: observability-collector --journal [--db ] [--interval ] [--prune-interval-ms ]", + ); + process.exit(1); + } + + return { journal, db, interval, pruneIntervalMs }; } // --- Logger --- const logger: Logger = { - info: (...args: readonly unknown[]) => console.log("[observability-collector]", ...args), - debug: (...args: readonly unknown[]) => console.debug("[observability-collector]", ...args), + info: (...args: readonly unknown[]) => console.log("[observability-collector]", ...args), + debug: (...args: readonly unknown[]) => console.debug("[observability-collector]", ...args), }; // --- Main loop --- async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const sidecarPath = `${args.journal}.collector-offset`; - const store = createTraceStore({ path: args.db }); - - let offset = readOffset(sidecarPath); - let lastPruneAt = Date.now(); - - let shuttingDown = false; - - function onSignal(): void { - if (shuttingDown) return; - shuttingDown = true; - } - - process.on("SIGINT", onSignal); - process.on("SIGTERM", onSignal); - - while (!shuttingDown) { - const result = drainOnce({ journalPath: args.journal, offset, store }); - if (result.newOffset !== offset) { - offset = result.newOffset; - writeOffset(sidecarPath, offset); - } - - const now = Date.now(); - if (shouldPrune(now, lastPruneAt, args.pruneIntervalMs)) { - lastPruneAt = now; - try { - const summary = store.prune(DEFAULT_RETENTION); - logger.debug("prune completed", summary); - } catch (err) { - logger.info("prune failed (non-fatal)", err); - } - } - - await sleep(args.interval); - } - - // Final drain on shutdown - const finalResult = drainOnce({ journalPath: args.journal, offset, store }); - if (finalResult.newOffset !== offset) { - writeOffset(sidecarPath, finalResult.newOffset); - } - - store.close(); - process.exit(0); + const args = parseArgs(process.argv.slice(2)); + const sidecarPath = `${args.journal}.collector-offset`; + const store = createTraceStore({ path: args.db }); + + let offset = readOffset(sidecarPath); + let lastPruneAt = Date.now(); + + let shuttingDown = false; + + function onSignal(): void { + if (shuttingDown) return; + shuttingDown = true; + } + + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + while (!shuttingDown) { + const result = drainOnce({ journalPath: args.journal, offset, store }); + if (result.newOffset !== offset) { + offset = result.newOffset; + writeOffset(sidecarPath, offset); + } + + const now = Date.now(); + if (shouldPrune(now, lastPruneAt, args.pruneIntervalMs)) { + lastPruneAt = now; + try { + const summary = store.prune(DEFAULT_RETENTION); + logger.debug("prune completed", summary); + } catch (err) { + logger.info("prune failed (non-fatal)", err); + } + } + + await sleep(args.interval); + } + + // Final drain on shutdown + const finalResult = drainOnce({ journalPath: args.journal, offset, store }); + if (finalResult.newOffset !== offset) { + writeOffset(sidecarPath, finalResult.newOffset); + } + + store.close(); + process.exit(0); } function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } main().catch((err) => { - console.error("[observability-collector] fatal:", err); - process.exit(1); + console.error("[observability-collector] fatal:", err); + process.exit(1); }); diff --git a/packages/observability-collector/tsconfig.json b/packages/observability-collector/tsconfig.json index 7bfb36e..8379442 100644 --- a/packages/observability-collector/tsconfig.json +++ b/packages/observability-collector/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../trace-store" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../trace-store" }] } diff --git a/packages/openai-stream/package.json b/packages/openai-stream/package.json index 5b35ff7..2c2c918 100644 --- a/packages/openai-stream/package.json +++ b/packages/openai-stream/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/openai-stream", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/trace-replay": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/openai-stream", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/trace-replay": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/openai-stream/src/__fixtures__/flash-text-turn.json b/packages/openai-stream/src/__fixtures__/flash-text-turn.json index d7e71cb..34d0d33 100644 --- a/packages/openai-stream/src/__fixtures__/flash-text-turn.json +++ b/packages/openai-stream/src/__fixtures__/flash-text-turn.json @@ -1,26 +1,26 @@ { - "request": { - "method": "POST", - "url": "https://opencode.ai/zen/go/v1/chat/completions", - "headers": { - "Content-Type": "application/json", - "Authorization": "Bearer sk-…redacted…UN0" - }, - "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"},{\"role\":\"assistant\",\"content\":\"The user asks: \\\"Say hello in exactly three words.\\\" So I need to respond with exactly three words. The obvious answer is \\\"Hello, how are you?\\\" But that's four words. Actually \\\"Hello there friend\\\" is three words. Or \\\"Hello, my friend\\\"? That's three words if we count \\\"Hello,\\\" as one word? Better to be simple: \\\"Hello, world!\\\" is two words. \\\"Hello everyone here\\\" is three. I'll go with \\\"Hello there friend.\\\" But let's make sure it's exactly three words. I'll respond: \\\"Hello, my friend.\\\" That's three words: Hello, my, friend. But includes a comma? Usually words mean space-separated tokens. So \\\"Hello my friend\\\" is three words. I'll respond with that.\\n\\nBut the instruction is to say hello in exactly three words. I'll just output: \\\"Hello my friend\\\"Hello my friend\"},{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"description\":\"Read the contents of a file. Returns lines with 1-indexed line numbers. Supports offset/limit for reading specific sections of large files.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file, relative to the working directory.\"},\"offset\":{\"type\":\"number\",\"description\":\"1-indexed start line number (default: 1).\",\"default\":1},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to return (default: 500, hard cap: 5000).\",\"default\":500}},\"required\":[\"path\"]}}}]}" - }, - "response": { - "status": 200, - "statusText": "OK", - "headers": { - "cache-control": "no-cache", - "cf-placement": "remote-ORD", - "cf-ray": "a06dc6711bb51f51-DEN", - "connection": "keep-alive", - "content-type": "text/event-stream; charset=utf-8", - "date": "Fri, 05 Jun 2026 08:23:26 GMT", - "server": "cloudflare", - "transfer-encoding": "chunked" - }, - "body": "data: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" asks\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Say\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" already\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" said\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" my\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" but\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" that\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" might\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" have\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" been\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" cut\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" off\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" Let\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" me\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" again\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" world\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"!\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" everyone\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"'ll\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" use\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" there\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" friend\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":665,\"completion_tokens\":90,\"total_tokens\":755,\"prompt_tokens_details\":{\"cached_tokens\":384},\"completion_tokens_details\":{\"reasoning_tokens\":86},\"prompt_cache_hit_tokens\":384,\"prompt_cache_miss_tokens\":281}}\n\ndata: [DONE]\n\ndata: {\"choices\":[],\"cost\":\"0\"}\n\n" - } + "request": { + "method": "POST", + "url": "https://opencode.ai/zen/go/v1/chat/completions", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer sk-…redacted…UN0" + }, + "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"},{\"role\":\"assistant\",\"content\":\"The user asks: \\\"Say hello in exactly three words.\\\" So I need to respond with exactly three words. The obvious answer is \\\"Hello, how are you?\\\" But that's four words. Actually \\\"Hello there friend\\\" is three words. Or \\\"Hello, my friend\\\"? That's three words if we count \\\"Hello,\\\" as one word? Better to be simple: \\\"Hello, world!\\\" is two words. \\\"Hello everyone here\\\" is three. I'll go with \\\"Hello there friend.\\\" But let's make sure it's exactly three words. I'll respond: \\\"Hello, my friend.\\\" That's three words: Hello, my, friend. But includes a comma? Usually words mean space-separated tokens. So \\\"Hello my friend\\\" is three words. I'll respond with that.\\n\\nBut the instruction is to say hello in exactly three words. I'll just output: \\\"Hello my friend\\\"Hello my friend\"},{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"description\":\"Read the contents of a file. Returns lines with 1-indexed line numbers. Supports offset/limit for reading specific sections of large files.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file, relative to the working directory.\"},\"offset\":{\"type\":\"number\",\"description\":\"1-indexed start line number (default: 1).\",\"default\":1},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to return (default: 500, hard cap: 5000).\",\"default\":500}},\"required\":[\"path\"]}}}]}" + }, + "response": { + "status": 200, + "statusText": "OK", + "headers": { + "cache-control": "no-cache", + "cf-placement": "remote-ORD", + "cf-ray": "a06dc6711bb51f51-DEN", + "connection": "keep-alive", + "content-type": "text/event-stream; charset=utf-8", + "date": "Fri, 05 Jun 2026 08:23:26 GMT", + "server": "cloudflare", + "transfer-encoding": "chunked" + }, + "body": "data: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" asks\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Say\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" already\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" said\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" my\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" but\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" that\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" might\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" have\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" been\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" cut\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" off\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" Let\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" me\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" again\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" world\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"!\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" everyone\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"'ll\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" use\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" there\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" friend\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":665,\"completion_tokens\":90,\"total_tokens\":755,\"prompt_tokens_details\":{\"cached_tokens\":384},\"completion_tokens_details\":{\"reasoning_tokens\":86},\"prompt_cache_hit_tokens\":384,\"prompt_cache_miss_tokens\":281}}\n\ndata: [DONE]\n\ndata: {\"choices\":[],\"cost\":\"0\"}\n\n" + } } diff --git a/packages/openai-stream/src/__fixtures__/tool-call-turn.json b/packages/openai-stream/src/__fixtures__/tool-call-turn.json index 48bdb8d..6f61ac9 100644 --- a/packages/openai-stream/src/__fixtures__/tool-call-turn.json +++ b/packages/openai-stream/src/__fixtures__/tool-call-turn.json @@ -1,25 +1,25 @@ { - "request": { - "method": "POST", - "url": "https://api.example.com/v1/chat/completions", - "headers": { - "content-type": "application/json", - "authorization": "Bearer sk-…redacted…xyz" - }, - "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"What is the weather in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a location\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}}}],\"stream\":true}" - }, - "response": { - "status": 200, - "statusText": "OK", - "headers": { - "content-type": "text/event-stream", - "cache-control": "no-cache" - }, - "body": "data: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"locat\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ion\\\":\\\"Tokyo\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":12,\"cache_read_tokens\":30,\"cache_write_tokens\":5}}\n\ndata: [DONE]\n" - }, - "meta": { - "description": "Tool-call fixture: user asks about weather → model calls get_weather tool with split argument chunks.", - "captured_by": "provider-openai-compat record mode", - "version": 1 - } + "request": { + "method": "POST", + "url": "https://api.example.com/v1/chat/completions", + "headers": { + "content-type": "application/json", + "authorization": "Bearer sk-…redacted…xyz" + }, + "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"What is the weather in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a location\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}}}],\"stream\":true}" + }, + "response": { + "status": 200, + "statusText": "OK", + "headers": { + "content-type": "text/event-stream", + "cache-control": "no-cache" + }, + "body": "data: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"locat\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ion\\\":\\\"Tokyo\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":12,\"cache_read_tokens\":30,\"cache_write_tokens\":5}}\n\ndata: [DONE]\n" + }, + "meta": { + "description": "Tool-call fixture: user asks about weather → model calls get_weather tool with split argument chunks.", + "captured_by": "provider-openai-compat record mode", + "version": 1 + } } diff --git a/packages/openai-stream/src/convert-messages.test.ts b/packages/openai-stream/src/convert-messages.test.ts index 004a6b7..3520eb5 100644 --- a/packages/openai-stream/src/convert-messages.test.ts +++ b/packages/openai-stream/src/convert-messages.test.ts @@ -3,362 +3,362 @@ import { describe, expect, it } from "vitest"; import { convertMessages } from "./convert-messages.js"; describe("convertMessages", () => { - it("converts a system message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [ - { type: "system", text: "You are a helpful assistant." }, - { type: "text", text: " Additional context." }, - ], - }, - ]; + it("converts a system message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [ + { type: "system", text: "You are a helpful assistant." }, + { type: "text", text: " Additional context." }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are a helpful assistant. Additional context." }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are a helpful assistant. Additional context." }, + ]); + }); - it("converts a user message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "user", - chunks: [ - { type: "text", text: "Hello, " }, - { type: "text", text: "world!" }, - ], - }, - ]; + it("converts a user message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Hello, " }, + { type: "text", text: "world!" }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); - }); + const result = convertMessages(messages); + expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); + }); - it("converts an assistant message with text only", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "I can help " }, - { type: "text", text: "with that." }, - ], - }, - ]; + it("converts an assistant message with text only", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "I can help " }, + { type: "text", text: "with that." }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); - }); + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); + }); - it("converts an assistant message with tool calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "Let me check that." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "/src/main.ts" }, - }, - ], - }, - ]; + it("converts an assistant message with tool calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "Let me check that." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "/src/main.ts" }, + }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: "Let me check that.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "/src/main.ts" }), - }, - }, - ], - }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: "Let me check that.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "/src/main.ts" }), + }, + }, + ], + }, + ]); + }); - it("converts an assistant message with tool calls but no text", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_2", - toolName: "run_shell", - input: { command: "ls" }, - }, - ], - }, - ]; + it("converts an assistant message with tool calls but no text", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_2", + toolName: "run_shell", + input: { command: "ls" }, + }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_2", - type: "function", - function: { - name: "run_shell", - arguments: JSON.stringify({ command: "ls" }), - }, - }, - ], - }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_2", + type: "function", + function: { + name: "run_shell", + arguments: JSON.stringify({ command: "ls" }), + }, + }, + ], + }, + ]); + }); - it("converts tool result messages", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - ], - }, - ]; + it("converts tool result messages", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "tool", - content: "file contents here", - tool_call_id: "call_1", - }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "tool", + content: "file contents here", + tool_call_id: "call_1", + }, + ]); + }); - it("converts a full multi-turn history with tool round-trip", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [{ type: "system", text: "You are helpful." }], - }, - { - role: "user", - chunks: [{ type: "text", text: "Read main.ts" }], - }, - { - role: "assistant", - chunks: [ - { type: "text", text: "Sure." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "main.ts" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "console.log('hello')", - isError: false, - }, - ], - }, - { - role: "assistant", - chunks: [{ type: "text", text: "The file logs hello." }], - }, - ]; + it("converts a full multi-turn history with tool round-trip", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [{ type: "system", text: "You are helpful." }], + }, + { + role: "user", + chunks: [{ type: "text", text: "Read main.ts" }], + }, + { + role: "assistant", + chunks: [ + { type: "text", text: "Sure." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "main.ts" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "console.log('hello')", + isError: false, + }, + ], + }, + { + role: "assistant", + chunks: [{ type: "text", text: "The file logs hello." }], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are helpful." }, - { role: "user", content: "Read main.ts" }, - { - role: "assistant", - content: "Sure.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "main.ts" }), - }, - }, - ], - }, - { - role: "tool", - content: "console.log('hello')", - tool_call_id: "call_1", - }, - { role: "assistant", content: "The file logs hello." }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Read main.ts" }, + { + role: "assistant", + content: "Sure.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "main.ts" }), + }, + }, + ], + }, + { + role: "tool", + content: "console.log('hello')", + tool_call_id: "call_1", + }, + { role: "assistant", content: "The file logs hello." }, + ]); + }); - it("handles multiple tool results in one tool message", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file1", - isError: false, - }, - { - type: "tool-result", - toolCallId: "call_2", - toolName: "read_file", - content: "file2", - isError: false, - }, - ], - }, - ]; + it("handles multiple tool results in one tool message", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file1", + isError: false, + }, + { + type: "tool-result", + toolCallId: "call_2", + toolName: "read_file", + content: "file2", + isError: false, + }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "tool", content: "file1", tool_call_id: "call_1" }, - { role: "tool", content: "file2", tool_call_id: "call_2" }, - ]); - }); + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "tool", content: "file1", tool_call_id: "call_1" }, + { role: "tool", content: "file2", tool_call_id: "call_2" }, + ]); + }); - it("includes thinking chunks in assistant content", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "thinking", text: "Let me think..." }, - { type: "text", text: "Here is my answer." }, - ], - }, - ]; + it("includes thinking chunks in assistant content", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "thinking", text: "Let me think..." }, + { type: "text", text: "Here is my answer." }, + ], + }, + ]; - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); - }); + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); + }); - it("arguments is valid JSON when input is a malformed string", () => { - // Production seq-134 shape: the model emitted broken JSON as the tool - // arguments and it was stored verbatim. Unquoted key fails JSON.parse at - // some column (position 1 here). - const malformed = '{path: "/src/main.ts"}'; - expect(() => JSON.parse(malformed)).toThrow(); + it("arguments is valid JSON when input is a malformed string", () => { + // Production seq-134 shape: the model emitted broken JSON as the tool + // arguments and it was stored verbatim. Unquoted key fails JSON.parse at + // some column (position 1 here). + const malformed = '{path: "/src/main.ts"}'; + expect(() => JSON.parse(malformed)).toThrow(); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_bad", - toolName: "read_file", - input: malformed, - }, - ], - }, - ]; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_bad", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(args).toBeDefined(); - // The output MUST parse without throwing — the provider receives valid JSON. - expect(() => JSON.parse(args as string)).not.toThrow(); - // And it is the fallback object preserving a truncated hint. - expect(JSON.parse(args as string)).toEqual({ - _malformed_arguments: malformed.slice(0, 200), - }); - }); + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBeDefined(); + // The output MUST parse without throwing — the provider receives valid JSON. + expect(() => JSON.parse(args as string)).not.toThrow(); + // And it is the fallback object preserving a truncated hint. + expect(JSON.parse(args as string)).toEqual({ + _malformed_arguments: malformed.slice(0, 200), + }); + }); - it("arguments passes through valid string input", () => { - const validJson = '{"path":"/src/main.ts"}'; - expect(() => JSON.parse(validJson)).not.toThrow(); + it("arguments passes through valid string input", () => { + const validJson = '{"path":"/src/main.ts"}'; + expect(() => JSON.parse(validJson)).not.toThrow(); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_str", - toolName: "read_file", - input: validJson, - }, - ], - }, - ]; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_str", + toolName: "read_file", + input: validJson, + }, + ], + }, + ]; - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - // A valid-JSON string round-trips to a canonical JSON string. - expect(args).toBe(JSON.stringify(JSON.parse(validJson))); - expect(args).toBe('{"path":"/src/main.ts"}'); - }); + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + // A valid-JSON string round-trips to a canonical JSON string. + expect(args).toBe(JSON.stringify(JSON.parse(validJson))); + expect(args).toBe('{"path":"/src/main.ts"}'); + }); - it("stringifies object input", () => { - const input = { path: "/src/main.ts", line: 42 }; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_obj", - toolName: "read_file", - input, - }, - ], - }, - ]; + it("stringifies object input", () => { + const input = { path: "/src/main.ts", line: 42 }; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_obj", + toolName: "read_file", + input, + }, + ], + }, + ]; - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(args).toBe(JSON.stringify(input)); - }); + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBe(JSON.stringify(input)); + }); - it("truncates the malformed input hint to 200 characters", () => { - // A long bare run of letters is not valid JSON (no quotes/braces). - const malformed = "x".repeat(500); - expect(() => JSON.parse(malformed)).toThrow(); + it("truncates the malformed input hint to 200 characters", () => { + // A long bare run of letters is not valid JSON (no quotes/braces). + const malformed = "x".repeat(500); + expect(() => JSON.parse(malformed)).toThrow(); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_long", - toolName: "read_file", - input: malformed, - }, - ], - }, - ]; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_long", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(() => JSON.parse(args as string)).not.toThrow(); - const parsed = JSON.parse(args as string); - expect(parsed).toEqual({ _malformed_arguments: malformed.slice(0, 200) }); - expect(parsed._malformed_arguments.length).toBe(200); - }); + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(() => JSON.parse(args as string)).not.toThrow(); + const parsed = JSON.parse(args as string); + expect(parsed).toEqual({ _malformed_arguments: malformed.slice(0, 200) }); + expect(parsed._malformed_arguments.length).toBe(200); + }); }); diff --git a/packages/openai-stream/src/convert-messages.ts b/packages/openai-stream/src/convert-messages.ts index 76badc8..e830243 100644 --- a/packages/openai-stream/src/convert-messages.ts +++ b/packages/openai-stream/src/convert-messages.ts @@ -1,101 +1,101 @@ import type { ChatMessage, Chunk } from "@dispatch/kernel"; export interface OpenAIMessage { - readonly role: "system" | "user" | "assistant" | "tool"; - readonly content: string | null; - readonly tool_calls?: readonly OpenAIToolCall[]; - readonly tool_call_id?: string; + readonly role: "system" | "user" | "assistant" | "tool"; + readonly content: string | null; + readonly tool_calls?: readonly OpenAIToolCall[]; + readonly tool_call_id?: string; } export interface OpenAIToolCall { - readonly id: string; - readonly type: "function"; - readonly function: { readonly name: string; readonly arguments: string }; + readonly id: string; + readonly type: "function"; + readonly function: { readonly name: string; readonly arguments: string }; } export function convertMessages(messages: readonly ChatMessage[]): OpenAIMessage[] { - const result: OpenAIMessage[] = []; - for (const msg of messages) { - const converted = convertMessage(msg); - for (const m of converted) { - result.push(m); - } - } - return result; + const result: OpenAIMessage[] = []; + for (const msg of messages) { + const converted = convertMessage(msg); + for (const m of converted) { + result.push(m); + } + } + return result; } function convertMessage(msg: ChatMessage): OpenAIMessage[] { - switch (msg.role) { - case "system": - return [convertSystemMessage(msg)]; - case "user": - return [convertUserMessage(msg)]; - case "assistant": - return [convertAssistantMessage(msg)]; - case "tool": - return convertToolResultMessages(msg); - } + switch (msg.role) { + case "system": + return [convertSystemMessage(msg)]; + case "user": + return [convertUserMessage(msg)]; + case "assistant": + return [convertAssistantMessage(msg)]; + case "tool": + return convertToolResultMessages(msg); + } } function convertSystemMessage(msg: ChatMessage): OpenAIMessage { - const text = msg.chunks - .filter( - (c): c is Extract => - c.type === "text" || c.type === "system", - ) - .map((c) => c.text) - .join(""); - return { role: "system", content: text }; + const text = msg.chunks + .filter( + (c): c is Extract => + c.type === "text" || c.type === "system", + ) + .map((c) => c.text) + .join(""); + return { role: "system", content: text }; } function convertUserMessage(msg: ChatMessage): OpenAIMessage { - const text = msg.chunks - .filter((c): c is Extract => c.type === "text") - .map((c) => c.text) - .join(""); - return { role: "user", content: text }; + const text = msg.chunks + .filter((c): c is Extract => c.type === "text") + .map((c) => c.text) + .join(""); + return { role: "user", content: text }; } function convertAssistantMessage(msg: ChatMessage): OpenAIMessage { - const textChunks = msg.chunks.filter( - (c): c is Extract => - c.type === "text" || c.type === "thinking", - ); - const content = textChunks.map((c) => c.text).join(""); + const textChunks = msg.chunks.filter( + (c): c is Extract => + c.type === "text" || c.type === "thinking", + ); + const content = textChunks.map((c) => c.text).join(""); - const toolCalls = msg.chunks - .filter((c): c is Extract => c.type === "tool-call") - .map( - (c): OpenAIToolCall => ({ - id: c.toolCallId, - type: "function", - function: { - name: c.toolName, - arguments: serializeToolArguments(c.input), - }, - }), - ); + const toolCalls = msg.chunks + .filter((c): c is Extract => c.type === "tool-call") + .map( + (c): OpenAIToolCall => ({ + id: c.toolCallId, + type: "function", + function: { + name: c.toolName, + arguments: serializeToolArguments(c.input), + }, + }), + ); - if (toolCalls.length > 0) { - return { - role: "assistant", - content: content || null, - tool_calls: toolCalls, - }; - } - return { role: "assistant", content }; + if (toolCalls.length > 0) { + return { + role: "assistant", + content: content || null, + tool_calls: toolCalls, + }; + } + return { role: "assistant", content }; } function convertToolResultMessages(msg: ChatMessage): OpenAIMessage[] { - return msg.chunks - .filter((c): c is Extract => c.type === "tool-result") - .map( - (c): OpenAIMessage => ({ - role: "tool", - content: c.content, - tool_call_id: c.toolCallId, - }), - ); + return msg.chunks + .filter((c): c is Extract => c.type === "tool-result") + .map( + (c): OpenAIMessage => ({ + role: "tool", + content: c.content, + tool_call_id: c.toolCallId, + }), + ); } /** @@ -116,12 +116,12 @@ function convertToolResultMessages(msg: ChatMessage): OpenAIMessage[] { * Pure: input → output, no I/O. */ function serializeToolArguments(input: unknown): string { - if (typeof input === "string") { - try { - return JSON.stringify(JSON.parse(input)); - } catch { - return JSON.stringify({ _malformed_arguments: input.slice(0, 200) }); - } - } - return JSON.stringify(input); + if (typeof input === "string") { + try { + return JSON.stringify(JSON.parse(input)); + } catch { + return JSON.stringify({ _malformed_arguments: input.slice(0, 200) }); + } + } + return JSON.stringify(input); } diff --git a/packages/openai-stream/src/convert-tools.test.ts b/packages/openai-stream/src/convert-tools.test.ts index d739652..907ea15 100644 --- a/packages/openai-stream/src/convert-tools.test.ts +++ b/packages/openai-stream/src/convert-tools.test.ts @@ -3,104 +3,104 @@ import { describe, expect, it } from "vitest"; import { convertTools } from "./convert-tools.js"; describe("convertTools", () => { - it("converts a single tool to OpenAI function format", () => { - const tools: ToolContract[] = [ - { - name: "read_file", - description: "Read a file from disk", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - }, - required: ["path"], - additionalProperties: false, - }, - execute: async () => ({ content: "" }), - }, - ]; + it("converts a single tool to OpenAI function format", () => { + const tools: ToolContract[] = [ + { + name: "read_file", + description: "Read a file from disk", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + }, + required: ["path"], + additionalProperties: false, + }, + execute: async () => ({ content: "" }), + }, + ]; - const result = convertTools(tools); - expect(result).toEqual([ - { - type: "function", - function: { - name: "read_file", - description: "Read a file from disk", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - }, - required: ["path"], - additionalProperties: false, - }, - }, - }, - ]); - }); + const result = convertTools(tools); + expect(result).toEqual([ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from disk", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + }, + required: ["path"], + additionalProperties: false, + }, + }, + }, + ]); + }); - it("converts multiple tools", () => { - const tools: ToolContract[] = [ - { - name: "read_file", - description: "Read a file", - parameters: { type: "object" }, - execute: async () => ({ content: "" }), - }, - { - name: "run_shell", - description: "Run a shell command", - parameters: { - type: "object", - properties: { - command: { type: "string", description: "The command" }, - }, - required: ["command"], - }, - execute: async () => ({ content: "" }), - }, - ]; + it("converts multiple tools", () => { + const tools: ToolContract[] = [ + { + name: "read_file", + description: "Read a file", + parameters: { type: "object" }, + execute: async () => ({ content: "" }), + }, + { + name: "run_shell", + description: "Run a shell command", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "The command" }, + }, + required: ["command"], + }, + execute: async () => ({ content: "" }), + }, + ]; - const result = convertTools(tools); - expect(result).toHaveLength(2); - expect(result[0]?.function.name).toBe("read_file"); - expect(result[1]?.function.name).toBe("run_shell"); - }); + const result = convertTools(tools); + expect(result).toHaveLength(2); + expect(result[0]?.function.name).toBe("read_file"); + expect(result[1]?.function.name).toBe("run_shell"); + }); - it("returns empty array for no tools", () => { - const result = convertTools([]); - expect(result).toEqual([]); - }); + it("returns empty array for no tools", () => { + const result = convertTools([]); + expect(result).toEqual([]); + }); - it("preserves nested parameter schema properties", () => { - const tools: ToolContract[] = [ - { - name: "search", - description: "Search code", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - options: { - type: "object", - properties: { - limit: { type: "number", description: "Max results", default: 10 }, - }, - }, - }, - required: ["query"], - }, - execute: async () => ({ content: "" }), - }, - ]; + it("preserves nested parameter schema properties", () => { + const tools: ToolContract[] = [ + { + name: "search", + description: "Search code", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + options: { + type: "object", + properties: { + limit: { type: "number", description: "Max results", default: 10 }, + }, + }, + }, + required: ["query"], + }, + execute: async () => ({ content: "" }), + }, + ]; - const result = convertTools(tools); - expect(result[0]?.function.parameters.properties?.options).toEqual({ - type: "object", - properties: { - limit: { type: "number", description: "Max results", default: 10 }, - }, - }); - }); + const result = convertTools(tools); + expect(result[0]?.function.parameters.properties?.options).toEqual({ + type: "object", + properties: { + limit: { type: "number", description: "Max results", default: 10 }, + }, + }); + }); }); diff --git a/packages/openai-stream/src/convert-tools.ts b/packages/openai-stream/src/convert-tools.ts index 65416bb..198232c 100644 --- a/packages/openai-stream/src/convert-tools.ts +++ b/packages/openai-stream/src/convert-tools.ts @@ -1,25 +1,25 @@ import type { ToolContract, ToolParameterSchema } from "@dispatch/kernel"; export interface OpenAITool { - readonly type: "function"; - readonly function: { - readonly name: string; - readonly description: string; - readonly parameters: ToolParameterSchema; - }; + readonly type: "function"; + readonly function: { + readonly name: string; + readonly description: string; + readonly parameters: ToolParameterSchema; + }; } export function convertTools(tools: readonly ToolContract[]): OpenAITool[] { - return tools.map(convertTool); + return tools.map(convertTool); } function convertTool(tool: ToolContract): OpenAITool { - return { - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - }; + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; } diff --git a/packages/openai-stream/src/listModels.test.ts b/packages/openai-stream/src/listModels.test.ts index 63c95fc..c2438bc 100644 --- a/packages/openai-stream/src/listModels.test.ts +++ b/packages/openai-stream/src/listModels.test.ts @@ -5,98 +5,98 @@ import { parseModelList } from "./listModels.js"; import { createOpenAICompatProvider } from "./provider.js"; function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract { - const creds: ApiKeyCredentials = { - type: "api-key", - apiKey, - baseURL: "https://api.example.com/v1", - }; - return createOpenAICompatProvider({ - credentials: creds, - model: "test-model", - id: "openai-compat", - fetchFn, - }); + const creds: ApiKeyCredentials = { + type: "api-key", + apiKey, + baseURL: "https://api.example.com/v1", + }; + return createOpenAICompatProvider({ + credentials: creds, + model: "test-model", + id: "openai-compat", + fetchFn, + }); } function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } describe("listModels — pure mapping (parseModelList)", () => { - it("maps OpenAI model entries to ModelInfo", () => { - const result = parseModelList([{ id: "a" }, { id: "b" }]); - expect(result).toEqual([{ id: "a" }, { id: "b" }]); - }); + it("maps OpenAI model entries to ModelInfo", () => { + const result = parseModelList([{ id: "a" }, { id: "b" }]); + expect(result).toEqual([{ id: "a" }, { id: "b" }]); + }); - it("returns empty array for empty input", () => { - const result = parseModelList([]); - expect(result).toEqual([]); - }); + it("returns empty array for empty input", () => { + const result = parseModelList([]); + expect(result).toEqual([]); + }); }); describe("listModels — provider contract", () => { - it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => { - const fetchFn = vi.fn( - () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType, - ); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); + it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => { + const fetchFn = vi.fn( + () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); - const models = await listModels(); + const models = await listModels(); - expect(fetchFn).toHaveBeenCalledOnce(); - const callArgs = fetchFn.mock.calls[0]; - if (!callArgs) throw new Error("no call args"); - const [url, init] = callArgs as unknown as [string, RequestInit]; - expect(url).toBe("https://api.example.com/v1/models"); - expect(init.method).toBe("GET"); - expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" }); + expect(fetchFn).toHaveBeenCalledOnce(); + const callArgs = fetchFn.mock.calls[0]; + if (!callArgs) throw new Error("no call args"); + const [url, init] = callArgs as unknown as [string, RequestInit]; + expect(url).toBe("https://api.example.com/v1/models"); + expect(init.method).toBe("GET"); + expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" }); - expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]); - }); + expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]); + }); - it("throws on non-OK HTTP status with a clear message", async () => { - const fetchFn = vi.fn( - () => - new Response("Unauthorized", { - status: 401, - headers: { "Content-Type": "text/plain" }, - }) as unknown as ReturnType, - ); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); + it("throws on non-OK HTTP status with a clear message", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); - await expect(listModels()).rejects.toThrow( - "listModels[openai-compat]: HTTP 401 — Unauthorized", - ); - }); + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: HTTP 401 — Unauthorized", + ); + }); - it("throws on network error with a clear message", async () => { - const fetchFn = vi.fn(() => { - throw new Error("connection refused"); - }) as unknown as FetchLike; - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); + it("throws on network error with a clear message", async () => { + const fetchFn = vi.fn(() => { + throw new Error("connection refused"); + }) as unknown as FetchLike; + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); - await expect(listModels()).rejects.toThrow( - "listModels[openai-compat]: network error — connection refused", - ); - }); + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: network error — connection refused", + ); + }); - it("throws when response shape is missing data array", async () => { - const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); + it("throws when response shape is missing data array", async () => { + const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); - await expect(listModels()).rejects.toThrow( - 'listModels[openai-compat]: unexpected response shape — missing "data" array', - ); - }); + await expect(listModels()).rejects.toThrow( + 'listModels[openai-compat]: unexpected response shape — missing "data" array', + ); + }); }); diff --git a/packages/openai-stream/src/listModels.ts b/packages/openai-stream/src/listModels.ts index 8a76c67..0e94c43 100644 --- a/packages/openai-stream/src/listModels.ts +++ b/packages/openai-stream/src/listModels.ts @@ -12,15 +12,15 @@ import type { FetchLike } from "@dispatch/trace-replay"; */ interface OpenAIModelEntry { - readonly id: string; - readonly context_length?: number; - readonly context_window?: number; - readonly max_context_length?: number; - readonly max_tokens?: number; + readonly id: string; + readonly context_length?: number; + readonly context_window?: number; + readonly max_context_length?: number; + readonly max_tokens?: number; } interface OpenAIModelListResponse { - readonly data: readonly OpenAIModelEntry[]; + readonly data: readonly OpenAIModelEntry[]; } /** @@ -29,52 +29,52 @@ interface OpenAIModelListResponse { * Extracted for direct unit testing with no I/O. */ export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] { - return data.map((entry) => { - const contextWindow = - entry.context_length ?? entry.context_window ?? entry.max_context_length ?? entry.max_tokens; - return { - id: entry.id, - ...(contextWindow !== undefined ? { contextWindow } : {}), - }; - }); + return data.map((entry) => { + const contextWindow = + entry.context_length ?? entry.context_window ?? entry.max_context_length ?? entry.max_tokens; + return { + id: entry.id, + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + }); } export interface ListModelsConfig { - readonly baseURL: string; - readonly apiKey: string; - readonly fetchFn?: FetchLike; - readonly providerId: string; + readonly baseURL: string; + readonly apiKey: string; + readonly fetchFn?: FetchLike; + readonly providerId: string; } export async function listModels(config: ListModelsConfig): Promise { - const effectiveFetch: FetchLike = config.fetchFn ?? fetch; - const url = `${config.baseURL}/models`; + const effectiveFetch: FetchLike = config.fetchFn ?? fetch; + const url = `${config.baseURL}/models`; - let response: Response; - try { - response = await effectiveFetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${config.apiKey}`, - }, - }); - } catch (err) { - throw new Error( - `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`, - ); - } + let response: Response; + try { + response = await effectiveFetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); + } catch (err) { + throw new Error( + `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`, + ); + } - if (!response.ok) { - const text = await response.text().catch(() => "unknown"); - throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`); - } + if (!response.ok) { + const text = await response.text().catch(() => "unknown"); + throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`); + } - const body = (await response.json()) as OpenAIModelListResponse; - if (!Array.isArray(body.data)) { - throw new Error( - `listModels[${config.providerId}]: unexpected response shape — missing "data" array`, - ); - } + const body = (await response.json()) as OpenAIModelListResponse; + if (!Array.isArray(body.data)) { + throw new Error( + `listModels[${config.providerId}]: unexpected response shape — missing "data" array`, + ); + } - return parseModelList(body.data); + return parseModelList(body.data); } diff --git a/packages/openai-stream/src/parse-sse.test.ts b/packages/openai-stream/src/parse-sse.test.ts index 1910833..dcc7d2b 100644 --- a/packages/openai-stream/src/parse-sse.test.ts +++ b/packages/openai-stream/src/parse-sse.test.ts @@ -3,261 +3,261 @@ import { describe, expect, it } from "vitest"; import { parseSSELines } from "./parse-sse.js"; describe("parseSSELines", () => { - it("parses text delta events", () => { - const lines = [ - 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"},"index":0}]}', - 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":" world"},"index":0}]}', - 'data: {"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ]; + it("parses text delta events", () => { + const lines = [ + 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"},"index":0}]}', + 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":" world"},"index":0}]}', + 'data: {"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "finish", reason: "stop" }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "finish", reason: "stop" }, + ]); + }); - it("parses a fragmented tool_call across chunks", () => { - const lines = [ - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"main.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; + it("parses a fragmented tool_call across chunks", () => { + const lines = [ + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"main.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { - type: "tool-call", - toolCallId: "call_abc", - toolName: "read_file", - input: { path: "main.ts" }, - }, - { type: "finish", reason: "tool_calls" }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { + type: "tool-call", + toolCallId: "call_abc", + toolName: "read_file", + input: { path: "main.ts" }, + }, + { type: "finish", reason: "tool_calls" }, + ]); + }); - it("parses multiple tool_calls in one response", () => { - const lines = [ - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; + it("parses multiple tool_calls in one response", () => { + const lines = [ + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, - { type: "tool-call", toolCallId: "call_2", toolName: "read_file", input: { path: "b.ts" } }, - { type: "finish", reason: "tool_calls" }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, + { type: "tool-call", toolCallId: "call_2", toolName: "read_file", input: { path: "b.ts" } }, + { type: "finish", reason: "tool_calls" }, + ]); + }); - it("parses usage from the final chunk", () => { - const lines = [ - 'data: {"id":"chatcmpl-4","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"chatcmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-4","usage":{"prompt_tokens":10,"completion_tokens":5}}', - "data: [DONE]", - ]; + it("parses usage from the final chunk", () => { + const lines = [ + 'data: {"id":"chatcmpl-4","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"chatcmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-4","usage":{"prompt_tokens":10,"completion_tokens":5}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Hi" }, - { type: "finish", reason: "stop" }, - { - type: "usage", - usage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Hi" }, + { type: "finish", reason: "stop" }, + { + type: "usage", + usage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + }, + ]); + }); - it("parses reasoning_content deltas", () => { - const lines = [ - 'data: {"id":"chatcmpl-5","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]}', - 'data: {"id":"chatcmpl-5","choices":[{"delta":{"content":"Here is my answer."},"index":0}]}', - 'data: {"id":"chatcmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ]; + it("parses reasoning_content deltas", () => { + const lines = [ + 'data: {"id":"chatcmpl-5","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]}', + 'data: {"id":"chatcmpl-5","choices":[{"delta":{"content":"Here is my answer."},"index":0}]}', + 'data: {"id":"chatcmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "reasoning-delta", delta: "Let me think..." }, - { type: "text-delta", delta: "Here is my answer." }, - { type: "finish", reason: "stop" }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "reasoning-delta", delta: "Let me think..." }, + { type: "text-delta", delta: "Here is my answer." }, + { type: "finish", reason: "stop" }, + ]); + }); - it("handles invalid JSON gracefully", () => { - const lines = [ - "data: {invalid json}", - 'data: {"id":"chatcmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', - "data: [DONE]", - ]; + it("handles invalid JSON gracefully", () => { + const lines = [ + "data: {invalid json}", + 'data: {"id":"chatcmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toHaveLength(2); - expect(events[0]?.type).toBe("error"); - expect(events[1]).toEqual({ type: "text-delta", delta: "ok" }); - }); + const events = parseSSELines(lines); + expect(events).toHaveLength(2); + expect(events[0]?.type).toBe("error"); + expect(events[1]).toEqual({ type: "text-delta", delta: "ok" }); + }); - it("ignores non-data lines", () => { - const lines = [ - "event: message", - ": comment line", - 'data: {"id":"chatcmpl-7","choices":[{"delta":{"content":"hi"},"index":0}]}', - "", - "data: [DONE]", - ]; + it("ignores non-data lines", () => { + const lines = [ + "event: message", + ": comment line", + 'data: {"id":"chatcmpl-7","choices":[{"delta":{"content":"hi"},"index":0}]}', + "", + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([{ type: "text-delta", delta: "hi" }]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([{ type: "text-delta", delta: "hi" }]); + }); - it("stops at [DONE] sentinel", () => { - const lines = [ - 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"before"},"index":0}]}', - "data: [DONE]", - 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"after"},"index":0}]}', - ]; + it("stops at [DONE] sentinel", () => { + const lines = [ + 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"before"},"index":0}]}', + "data: [DONE]", + 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"after"},"index":0}]}', + ]; - const events = parseSSELines(lines); - expect(events).toEqual([{ type: "text-delta", delta: "before" }]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([{ type: "text-delta", delta: "before" }]); + }); - it("parses nested prompt_tokens_details.cached_tokens → cacheReadTokens", () => { - const lines = [ - 'data: {"id":"chatcmpl-nested","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-nested","usage":{"prompt_tokens":665,"completion_tokens":90,"prompt_tokens_details":{"cached_tokens":384},"completion_tokens_details":{"reasoning_tokens":86}}}', - "data: [DONE]", - ]; + it("parses nested prompt_tokens_details.cached_tokens → cacheReadTokens", () => { + const lines = [ + 'data: {"id":"chatcmpl-nested","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-nested","usage":{"prompt_tokens":665,"completion_tokens":90,"prompt_tokens_details":{"cached_tokens":384},"completion_tokens_details":{"reasoning_tokens":86}}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.inputTokens).toBe(665); - expect(usageEvent.usage.outputTokens).toBe(90); - expect(usageEvent.usage.cacheReadTokens).toBe(384); - expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); - }); + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.inputTokens).toBe(665); + expect(usageEvent.usage.outputTokens).toBe(90); + expect(usageEvent.usage.cacheReadTokens).toBe(384); + expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); + }); - it("flat cache_read_tokens takes precedence over nested cached_tokens", () => { - const lines = [ - 'data: {"id":"chatcmpl-both","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-both","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":50,"prompt_tokens_details":{"cached_tokens":99}}}', - "data: [DONE]", - ]; + it("flat cache_read_tokens takes precedence over nested cached_tokens", () => { + const lines = [ + 'data: {"id":"chatcmpl-both","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-both","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":50,"prompt_tokens_details":{"cached_tokens":99}}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBe(50); - }); + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBe(50); + }); - it("returns undefined for cacheReadTokens when neither flat nor nested present", () => { - const lines = [ - 'data: {"id":"chatcmpl-none","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-none","usage":{"prompt_tokens":10,"completion_tokens":5}}', - "data: [DONE]", - ]; + it("returns undefined for cacheReadTokens when neither flat nor nested present", () => { + const lines = [ + 'data: {"id":"chatcmpl-none","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-none","usage":{"prompt_tokens":10,"completion_tokens":5}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); - }); + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); + }); - it("handles missing/partial prompt_tokens_details safely", () => { - const lines = [ - 'data: {"id":"chatcmpl-partial","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-partial","usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{}}}', - "data: [DONE]", - ]; + it("handles missing/partial prompt_tokens_details safely", () => { + const lines = [ + 'data: {"id":"chatcmpl-partial","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-partial","usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{}}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - }); + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + }); - it("handles empty prompt_tokens_details object safely", () => { - const lines = [ - 'data: {"id":"chatcmpl-empty","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-empty","usage":{"prompt_tokens":30,"completion_tokens":8,"prompt_tokens_details":null}}', - "data: [DONE]", - ]; + it("handles empty prompt_tokens_details object safely", () => { + const lines = [ + 'data: {"id":"chatcmpl-empty","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-empty","usage":{"prompt_tokens":30,"completion_tokens":8,"prompt_tokens_details":null}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - }); + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + }); - it("handles a complete turn with text, tool call, usage, and finish", () => { - const lines = [ - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"content":"Let me check."},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"search","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"dispatch\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - 'data: {"id":"chatcmpl-9","usage":{"prompt_tokens":50,"completion_tokens":20}}', - "data: [DONE]", - ]; + it("handles a complete turn with text, tool call, usage, and finish", () => { + const lines = [ + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"content":"Let me check."},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"search","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"dispatch\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + 'data: {"id":"chatcmpl-9","usage":{"prompt_tokens":50,"completion_tokens":20}}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Let me check." }, - { - type: "tool-call", - toolCallId: "call_xyz", - toolName: "search", - input: { query: "dispatch" }, - }, - { type: "finish", reason: "tool_calls" }, - { - type: "usage", - usage: { - inputTokens: 50, - outputTokens: 20, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Let me check." }, + { + type: "tool-call", + toolCallId: "call_xyz", + toolName: "search", + input: { query: "dispatch" }, + }, + { type: "finish", reason: "tool_calls" }, + { + type: "usage", + usage: { + inputTokens: 50, + outputTokens: 20, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + }, + ]); + }); - it("handles tool_call with unparseable arguments as raw string", () => { - const lines = [ - 'data: {"id":"chatcmpl-10","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_bad","function":{"name":"foo","arguments":"not-json"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-10","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; + it("handles tool_call with unparseable arguments as raw string", () => { + const lines = [ + 'data: {"id":"chatcmpl-10","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_bad","function":{"name":"foo","arguments":"not-json"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-10","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "tool-call", toolCallId: "call_bad", toolName: "foo", input: "not-json" }, - { type: "finish", reason: "tool_calls" }, - ]); - }); + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "tool-call", toolCallId: "call_bad", toolName: "foo", input: "not-json" }, + { type: "finish", reason: "tool_calls" }, + ]); + }); }); diff --git a/packages/openai-stream/src/parse-sse.ts b/packages/openai-stream/src/parse-sse.ts index cfeb5b0..112a418 100644 --- a/packages/openai-stream/src/parse-sse.ts +++ b/packages/openai-stream/src/parse-sse.ts @@ -1,130 +1,130 @@ import type { ProviderEvent } from "@dispatch/kernel"; interface ToolCallAccumulator { - id: string; - name: string; - arguments: string; + id: string; + name: string; + arguments: string; } interface SSEChunkDelta { - content?: string; - reasoning_content?: string; - tool_calls?: Array<{ - index: number; - id?: string; - function?: { name?: string; arguments?: string }; - }>; + content?: string; + reasoning_content?: string; + tool_calls?: Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }>; } interface SSEChunkChoice { - delta: SSEChunkDelta; - finish_reason?: string | null; - index: number; + delta: SSEChunkDelta; + finish_reason?: string | null; + index: number; } interface SSEChunkUsageDetails { - cached_tokens?: number; + cached_tokens?: number; } interface SSEChunk { - id?: string; - choices?: SSEChunkChoice[]; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - prompt_tokens_details?: SSEChunkUsageDetails; - completion_tokens_details?: Record; - }; + id?: string; + choices?: SSEChunkChoice[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + prompt_tokens_details?: SSEChunkUsageDetails; + completion_tokens_details?: Record; + }; } export function parseSSELines(lines: readonly string[]): ProviderEvent[] { - const events: ProviderEvent[] = []; - const toolCalls = new Map(); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (data === "[DONE]") break; - - let chunk: SSEChunk; - try { - chunk = JSON.parse(data) as SSEChunk; - } catch { - events.push({ type: "error", message: `Invalid JSON in SSE data: ${data}` }); - continue; - } - - if (chunk.choices) { - for (const choice of chunk.choices) { - const delta = choice.delta; - - if (delta.content) { - events.push({ type: "text-delta", delta: delta.content }); - } - - if (delta.reasoning_content) { - events.push({ type: "reasoning-delta", delta: delta.reasoning_content }); - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const existing = toolCalls.get(tc.index); - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments; - } - } else { - toolCalls.set(tc.index, { - id: tc.id ?? "", - name: tc.function?.name ?? "", - arguments: tc.function?.arguments ?? "", - }); - } - } - } - - if (choice.finish_reason) { - const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); - for (const idx of sortedIndices) { - const acc = toolCalls.get(idx); - if (!acc) continue; - let input: unknown; - try { - input = JSON.parse(acc.arguments); - } catch { - input = acc.arguments; - } - events.push({ - type: "tool-call", - toolCallId: acc.id, - toolName: acc.name, - input, - }); - } - events.push({ type: "finish", reason: choice.finish_reason }); - } - } - } - - if (chunk.usage) { - const cacheRead = - chunk.usage.cache_read_tokens ?? chunk.usage.prompt_tokens_details?.cached_tokens; - const cacheWrite = chunk.usage.cache_write_tokens; - events.push({ - type: "usage", - usage: { - inputTokens: chunk.usage.prompt_tokens ?? 0, - outputTokens: chunk.usage.completion_tokens ?? 0, - ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), - }, - }); - } - } - - return events; + const events: ProviderEvent[] = []; + const toolCalls = new Map(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") break; + + let chunk: SSEChunk; + try { + chunk = JSON.parse(data) as SSEChunk; + } catch { + events.push({ type: "error", message: `Invalid JSON in SSE data: ${data}` }); + continue; + } + + if (chunk.choices) { + for (const choice of chunk.choices) { + const delta = choice.delta; + + if (delta.content) { + events.push({ type: "text-delta", delta: delta.content }); + } + + if (delta.reasoning_content) { + events.push({ type: "reasoning-delta", delta: delta.reasoning_content }); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? "", + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }); + } + } + } + + if (choice.finish_reason) { + const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); + for (const idx of sortedIndices) { + const acc = toolCalls.get(idx); + if (!acc) continue; + let input: unknown; + try { + input = JSON.parse(acc.arguments); + } catch { + input = acc.arguments; + } + events.push({ + type: "tool-call", + toolCallId: acc.id, + toolName: acc.name, + input, + }); + } + events.push({ type: "finish", reason: choice.finish_reason }); + } + } + } + + if (chunk.usage) { + const cacheRead = + chunk.usage.cache_read_tokens ?? chunk.usage.prompt_tokens_details?.cached_tokens; + const cacheWrite = chunk.usage.cache_write_tokens; + events.push({ + type: "usage", + usage: { + inputTokens: chunk.usage.prompt_tokens ?? 0, + outputTokens: chunk.usage.completion_tokens ?? 0, + ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), + }, + }); + } + } + + return events; } diff --git a/packages/openai-stream/src/provider.test.ts b/packages/openai-stream/src/provider.test.ts index 434c405..8bc6e98 100644 --- a/packages/openai-stream/src/provider.test.ts +++ b/packages/openai-stream/src/provider.test.ts @@ -4,151 +4,151 @@ import { describe, expect, it, vi } from "vitest"; import { createOpenAICompatProvider } from "./provider.js"; function makeCreds(): ApiKeyCredentials { - return { - type: "api-key", - apiKey: "sk-test-1234567890abcdef", - baseURL: "https://api.example.com/v1", - }; + return { + type: "api-key", + apiKey: "sk-test-1234567890abcdef", + baseURL: "https://api.example.com/v1", + }; } function makeMessages(): readonly ChatMessage[] { - return [{ role: "user", chunks: [{ type: "text", text: "Hello" }] }]; + return [{ role: "user", chunks: [{ type: "text", text: "Hello" }] }]; } function sseBody(...lines: string[]): ReadableStream { - const encoder = new TextEncoder(); - const chunks = lines.map((l) => encoder.encode(`${l}\n`)); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - const chunk = chunks[index]; - if (chunk === undefined) throw new Error("empty chunk"); - controller.enqueue(chunk); - index++; - } else { - controller.close(); - } - }, - }); + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + if (chunk === undefined) throw new Error("empty chunk"); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); } function okSseResponse(): Response { - return new Response( - sseBody( - 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ); + return new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); } async function collectEvents(iter: AsyncIterable): Promise { - const events: unknown[] = []; - for await (const event of iter) { - events.push(event); - } - return events; + const events: unknown[] = []; + for await (const event of iter) { + events.push(event); + } + return events; } describe("createOpenAICompatProvider stamps the given id on the ProviderContract + listModels", () => { - it("stamps opts.id on ProviderContract.id", () => { - const provider = createOpenAICompatProvider({ - credentials: makeCreds(), - model: "test-model", - id: "my-custom-id", - }); - expect(provider.id).toBe("my-custom-id"); - }); - - it("uses opts.id in listModels error labels (was hardcoded 'openai-compat')", async () => { - const fetchFn = vi.fn( - () => - new Response("Unauthorized", { - status: 401, - headers: { "Content-Type": "text/plain" }, - }) as unknown as ReturnType, - ); - const provider = createOpenAICompatProvider({ - credentials: makeCreds(), - model: "test-model", - id: "my-custom-id", - fetchFn, - }); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); - - await expect(listModels()).rejects.toThrow("listModels[my-custom-id]: HTTP 401 — Unauthorized"); - }); + it("stamps opts.id on ProviderContract.id", () => { + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + }); + expect(provider.id).toBe("my-custom-id"); + }); + + it("uses opts.id in listModels error labels (was hardcoded 'openai-compat')", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType, + ); + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + fetchFn, + }); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow("listModels[my-custom-id]: HTTP 401 — Unauthorized"); + }); }); describe("transformBody", () => { - it("transformBody merges its returned fields into the request body", async () => { - let capturedInit: RequestInit | undefined; - const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { - capturedInit = init; - return okSseResponse(); - }) as unknown as FetchLike; - - let receivedBody: Record | undefined; - let receivedOpts: ProviderStreamOptions | undefined; - const provider = createOpenAICompatProvider({ - credentials: makeCreds(), - model: "test-model", - id: "umans", - fetchFn, - transformBody: (body, opts) => { - receivedBody = body; - receivedOpts = opts; - return { reasoning_effort: "high" }; - }, - }); - - await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5 })); - - // The hook was called with the body built so far + the stream opts. - expect(receivedBody).toBeDefined(); - expect(receivedOpts?.temperature).toBe(0.5); - expect(receivedBody?.model).toBe("test-model"); - - // The captured wire body carries the merged field. - expect(capturedInit?.body).toBeTypeOf("string"); - const wireBody = JSON.parse(capturedInit?.body as string) as Record; - expect(wireBody.reasoning_effort).toBe("high"); - expect(wireBody.model).toBe("test-model"); - expect(wireBody.stream).toBe(true); - expect(wireBody.temperature).toBe(0.5); - }); - - it("transformBody absent → body byte-identical to before (regression)", async () => { - let capturedInit: RequestInit | undefined; - const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { - capturedInit = init; - return okSseResponse(); - }) as unknown as FetchLike; - - const provider = createOpenAICompatProvider({ - credentials: makeCreds(), - model: "test-model", - id: "openai-compat", - fetchFn, - // No transformBody — default behavior. - }); - - await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5, maxTokens: 42 })); - - expect(capturedInit?.body).toBeTypeOf("string"); - const wireBody = JSON.parse(capturedInit?.body as string) as Record; - // Exact pre-refactor shape — no extra fields, no transformBody key leakage. - expect(wireBody).toEqual({ - model: "test-model", - messages: [{ role: "user", content: "Hello" }], - stream: true, - stream_options: { include_usage: true }, - temperature: 0.5, - max_tokens: 42, - }); - expect("reasoning_effort" in wireBody).toBe(false); - }); + it("transformBody merges its returned fields into the request body", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + let receivedBody: Record | undefined; + let receivedOpts: ProviderStreamOptions | undefined; + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "umans", + fetchFn, + transformBody: (body, opts) => { + receivedBody = body; + receivedOpts = opts; + return { reasoning_effort: "high" }; + }, + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5 })); + + // The hook was called with the body built so far + the stream opts. + expect(receivedBody).toBeDefined(); + expect(receivedOpts?.temperature).toBe(0.5); + expect(receivedBody?.model).toBe("test-model"); + + // The captured wire body carries the merged field. + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record; + expect(wireBody.reasoning_effort).toBe("high"); + expect(wireBody.model).toBe("test-model"); + expect(wireBody.stream).toBe(true); + expect(wireBody.temperature).toBe(0.5); + }); + + it("transformBody absent → body byte-identical to before (regression)", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "openai-compat", + fetchFn, + // No transformBody — default behavior. + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5, maxTokens: 42 })); + + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record; + // Exact pre-refactor shape — no extra fields, no transformBody key leakage. + expect(wireBody).toEqual({ + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + stream: true, + stream_options: { include_usage: true }, + temperature: 0.5, + max_tokens: 42, + }); + expect("reasoning_effort" in wireBody).toBe(false); + }); }); diff --git a/packages/openai-stream/src/provider.ts b/packages/openai-stream/src/provider.ts index c13d60e..df5a4ed 100644 --- a/packages/openai-stream/src/provider.ts +++ b/packages/openai-stream/src/provider.ts @@ -1,10 +1,10 @@ import type { - ApiKeyCredentials, - ChatMessage, - ModelInfo, - ProviderContract, - ProviderStreamOptions, - ToolContract, + ApiKeyCredentials, + ChatMessage, + ModelInfo, + ProviderContract, + ProviderStreamOptions, + ToolContract, } from "@dispatch/kernel"; import type { FetchLike } from "@dispatch/trace-replay"; import { listModels as fetchModels } from "./listModels.js"; @@ -19,55 +19,55 @@ import { streamChat } from "./stream.js"; */ export interface CreateOpenAICompatProviderOpts { - readonly credentials: ApiKeyCredentials; - readonly model: string; - /** Provider id (was hardcoded "openai-compat"). Stamped on the ProviderContract.id - * + used in listModels error labels. */ - readonly id: string; - /** - * Internal injectable fetch — used by tests and replay mode. - * When absent, falls back to globalThis.fetch (production default). - */ - readonly fetchFn?: FetchLike; - /** - * Optional hook a provider extension uses to add provider-specific body fields (e.g. - * `reasoning_effort`) before the request is sent. Receives the body built so far + - * the ProviderStreamOptions; returns ADDITIONAL fields to merge (or the full body). - * Default (absent): no extra fields. Generic — the library names no feature. - */ - readonly transformBody?: ( - body: Record, - opts: ProviderStreamOptions, - ) => Record; + readonly credentials: ApiKeyCredentials; + readonly model: string; + /** Provider id (was hardcoded "openai-compat"). Stamped on the ProviderContract.id + * + used in listModels error labels. */ + readonly id: string; + /** + * Internal injectable fetch — used by tests and replay mode. + * When absent, falls back to globalThis.fetch (production default). + */ + readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body fields (e.g. + * `reasoning_effort`) before the request is sent. Receives the body built so far + + * the ProviderStreamOptions; returns ADDITIONAL fields to merge (or the full body). + * Default (absent): no extra fields. Generic — the library names no feature. + */ + readonly transformBody?: ( + body: Record, + opts: ProviderStreamOptions, + ) => Record; } export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract { - const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; - const apiKey = opts.credentials.apiKey; - const fetchFn = opts.fetchFn; - const transformBody = opts.transformBody; + const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; + const apiKey = opts.credentials.apiKey; + const fetchFn = opts.fetchFn; + const transformBody = opts.transformBody; - const streamConfig = { - baseURL, - apiKey, - model: opts.model, - ...(fetchFn !== undefined ? { fetchFn } : {}), - ...(transformBody !== undefined ? { transformBody } : {}), - }; + const streamConfig = { + baseURL, + apiKey, + model: opts.model, + ...(fetchFn !== undefined ? { fetchFn } : {}), + ...(transformBody !== undefined ? { transformBody } : {}), + }; - return { - id: opts.id, - stream: ( - messages: readonly ChatMessage[], - tools: readonly ToolContract[], - streamOpts?: ProviderStreamOptions, - ) => streamChat(streamConfig, messages, tools, streamOpts), - listModels: (): Promise => - fetchModels({ - baseURL, - apiKey, - providerId: opts.id, - ...(fetchFn !== undefined ? { fetchFn } : {}), - }), - }; + return { + id: opts.id, + stream: ( + messages: readonly ChatMessage[], + tools: readonly ToolContract[], + streamOpts?: ProviderStreamOptions, + ) => streamChat(streamConfig, messages, tools, streamOpts), + listModels: (): Promise => + fetchModels({ + baseURL, + apiKey, + providerId: opts.id, + ...(fetchFn !== undefined ? { fetchFn } : {}), + }), + }; } diff --git a/packages/openai-stream/src/stream.test.ts b/packages/openai-stream/src/stream.test.ts index 0650153..ea8a079 100644 --- a/packages/openai-stream/src/stream.test.ts +++ b/packages/openai-stream/src/stream.test.ts @@ -5,860 +5,860 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type StreamConfig, streamChat } from "./stream.js"; async function collectEvents(iter: AsyncIterable): Promise { - const events: ProviderEvent[] = []; - for await (const event of iter) { - events.push(event); - } - return events; + const events: ProviderEvent[] = []; + for await (const event of iter) { + events.push(event); + } + return events; } function assertDefined(v: T, msg?: string): asserts v is NonNullable { - if (v === undefined || v === null) { - throw new Error(msg ?? "expected defined"); - } + if (v === undefined || v === null) { + throw new Error(msg ?? "expected defined"); + } } interface CapturedSpan { - name: string; - attrs: Record; - body?: string | undefined; - endOutcome?: - | { err?: unknown; attrs?: Record } - | undefined; + name: string; + attrs: Record; + body?: string | undefined; + endOutcome?: + | { err?: unknown; attrs?: Record } + | undefined; } function createFakeLogger(): { logger: Logger; spans: CapturedSpan[] } { - const spans: CapturedSpan[] = []; - let spanAttrBuffer: Record = {}; - let spanBodyBuffer: string | undefined; - - const fakeSpan: Span = { - id: "fake-span-id", - log: {} as Logger, - setAttributes(attrs) { - Object.assign(spanAttrBuffer, attrs); - }, - addLink() {}, - child() { - return fakeSpan; - }, - end(outcome?) { - spans.push({ - name: "provider.request", - attrs: { ...spanAttrBuffer }, - body: spanBodyBuffer, - endOutcome: outcome as CapturedSpan["endOutcome"], - }); - }, - }; - - const logger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return logger; - }, - span(_name, attrs, body) { - spanAttrBuffer = attrs ? { ...attrs } : {}; - spanBodyBuffer = body; - return fakeSpan; - }, - }; - - return { logger, spans }; + const spans: CapturedSpan[] = []; + let spanAttrBuffer: Record = {}; + let spanBodyBuffer: string | undefined; + + const fakeSpan: Span = { + id: "fake-span-id", + log: {} as Logger, + setAttributes(attrs) { + Object.assign(spanAttrBuffer, attrs); + }, + addLink() {}, + child() { + return fakeSpan; + }, + end(outcome?) { + spans.push({ + name: "provider.request", + attrs: { ...spanAttrBuffer }, + body: spanBodyBuffer, + endOutcome: outcome as CapturedSpan["endOutcome"], + }); + }, + }; + + const logger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return logger; + }, + span(_name, attrs, body) { + spanAttrBuffer = attrs ? { ...attrs } : {}; + spanBodyBuffer = body; + return fakeSpan; + }, + }; + + return { logger, spans }; } function makeConfig(apiKey = "sk-test-1234567890abcdef"): StreamConfig { - return { - baseURL: "https://api.example.com/v1", - apiKey, - model: "test-model", - }; + return { + baseURL: "https://api.example.com/v1", + apiKey, + model: "test-model", + }; } function mockFetch(handler: (url: string | URL | Request, init?: RequestInit) => unknown): void { - globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; + globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; } function makeMessages(): readonly ChatMessage[] { - return [ - { - role: "user", - chunks: [{ type: "text", text: "Hello" }], - }, - ]; + return [ + { + role: "user", + chunks: [{ type: "text", text: "Hello" }], + }, + ]; } function sseBody(...lines: string[]): ReadableStream { - const encoder = new TextEncoder(); - const chunks = lines.map((l) => encoder.encode(`${l}\n`)); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - const chunk = chunks[index]; - assertDefined(chunk); - controller.enqueue(chunk); - index++; - } else { - controller.close(); - } - }, - }); + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + assertDefined(chunk); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); } describe("streamChat — provider.request AFTER capture", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("opens a provider.request span with verbatim request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(spans).toHaveLength(1); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.name).toBe("provider.request"); - expect(span.attrs["request.method"]).toBe("POST"); - expect(span.attrs["request.body"]).toBeUndefined(); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("test-model"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); - - expect(span.endOutcome?.attrs?.status).toBe(200); - }); - - it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcdefghijkmnop"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk-…redacted…nop"); - expect(authHeader).not.toContain("abcdefghijkm"); - }); - - it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer s…redacted…e"); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("secret!"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer …redacted…"); - }); - - it("captures cache tokens from the response", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); - expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); - }); - - it("captures cache_read_tokens alone", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); - }); - - it("records HTTP error status and error body without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Invalid request body", { - status: 400, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 400: Invalid request body", - code: "400", - retryable: false, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.status).toBe(400); - expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); - expect(span.endOutcome?.err).toBeInstanceOf(Error); - }); - - it("records network error without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch(() => { - throw new Error("connection refused"); - }); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "connection refused", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.err).toBeInstanceOf(Error); - expect((span.endOutcome?.err as Error).message).toBe("connection refused"); - }); - - it("detects cache_control breakpoint absence in a normal request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs["request.cache_control_present"]).toBe(false); - }); - - it("does not open a span when opts.logger is absent", async () => { - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [])); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - }); - - it("fail-safe: logger throwing does not break stream()", async () => { - const brokenLogger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return brokenLogger; - }, - span() { - throw new Error("logger exploded"); - }, - }; - - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents( - streamChat(config, makeMessages(), [], { logger: brokenLogger }), - ); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(events.some((e) => e.type === "finish")).toBe(true); - }); - - it("redacts an 11-char API key (reveal 2 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde1234"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk…redacted…34"); - }); - - it("records server error (500) as retryable", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Internal Server Error", { - status: 500, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 500: Internal Server Error", - code: "500", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - expect(spans[0].endOutcome?.attrs?.status).toBe(500); - }); - - it("captures model and url on the span", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("test-model"); - expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); - }); - - it("uses opts.model override in capture", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents( - streamChat(config, makeMessages(), [], { logger, model: "override-model" }), - ); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("override-model"); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("override-model"); - }); + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("opens a provider.request span with verbatim request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(spans).toHaveLength(1); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.name).toBe("provider.request"); + expect(span.attrs["request.method"]).toBe("POST"); + expect(span.attrs["request.body"]).toBeUndefined(); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("test-model"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); + + expect(span.endOutcome?.attrs?.status).toBe(200); + }); + + it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcdefghijkmnop"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk-…redacted…nop"); + expect(authHeader).not.toContain("abcdefghijkm"); + }); + + it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer s…redacted…e"); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("secret!"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer …redacted…"); + }); + + it("captures cache tokens from the response", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); + expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); + }); + + it("captures cache_read_tokens alone", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); + }); + + it("records HTTP error status and error body without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Invalid request body", { + status: 400, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 400: Invalid request body", + code: "400", + retryable: false, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.status).toBe(400); + expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); + expect(span.endOutcome?.err).toBeInstanceOf(Error); + }); + + it("records network error without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch(() => { + throw new Error("connection refused"); + }); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "connection refused", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.err).toBeInstanceOf(Error); + expect((span.endOutcome?.err as Error).message).toBe("connection refused"); + }); + + it("detects cache_control breakpoint absence in a normal request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs["request.cache_control_present"]).toBe(false); + }); + + it("does not open a span when opts.logger is absent", async () => { + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [])); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + }); + + it("fail-safe: logger throwing does not break stream()", async () => { + const brokenLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return brokenLogger; + }, + span() { + throw new Error("logger exploded"); + }, + }; + + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents( + streamChat(config, makeMessages(), [], { logger: brokenLogger }), + ); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(events.some((e) => e.type === "finish")).toBe(true); + }); + + it("redacts an 11-char API key (reveal 2 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde1234"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk…redacted…34"); + }); + + it("records server error (500) as retryable", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 500: Internal Server Error", + code: "500", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + expect(spans[0].endOutcome?.attrs?.status).toBe(500); + }); + + it("captures model and url on the span", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("test-model"); + expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); + }); + + it("uses opts.model override in capture", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents( + streamChat(config, makeMessages(), [], { logger, model: "override-model" }), + ); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("override-model"); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("override-model"); + }); }); describe("streamChat — hermetic replay (trace-replay)", () => { - const testDir = new URL(".", import.meta.url).pathname; - const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; - const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; - - it("replays a text-turn fixture and produces correct ProviderEvents", async () => { - const fixture = loadFixture(fixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, - ]; - - const events = await collectEvents(streamChat(config, messages, [])); - - const textDeltas = events.filter( - (e): e is Extract => e.type === "text-delta", - ); - const fullText = textDeltas.map((e) => e.delta).join(""); - expect(fullText).toBe("Hello there friend"); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); - - const usageEvents = events.filter( - (e): e is Extract => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(665); - expect(usageEvents[0]?.usage.outputTokens).toBe(90); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); - expect(captured.headers["Content-Type"]).toBe("application/json"); - expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); - - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.model).toBe("deepseek-v4-flash"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); - }); - - it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { - const fixture = loadFixture(toolFixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, - ]; - - const weatherTool = { - name: "get_weather", - description: "Get current weather for a location", - parameters: { - type: "object" as const, - properties: { location: { type: "string" as const } }, - required: ["location"], - }, - execute: async () => ({ content: "" }), - }; - - const events = await collectEvents(streamChat(config, messages, [weatherTool])); - - const toolCalls = events.filter( - (e): e is Extract => e.type === "tool-call", - ); - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); - expect(toolCalls[0]?.toolName).toBe("get_weather"); - expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); - - const usageEvents = events.filter( - (e): e is Extract => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(45); - expect(usageEvents[0]?.usage.outputTokens).toBe(12); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); - expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.tools).toHaveLength(1); - expect(capturedBody.tools[0].function.name).toBe("get_weather"); - }); + const testDir = new URL(".", import.meta.url).pathname; + const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; + const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; + + it("replays a text-turn fixture and produces correct ProviderEvents", async () => { + const fixture = loadFixture(fixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, + ]; + + const events = await collectEvents(streamChat(config, messages, [])); + + const textDeltas = events.filter( + (e): e is Extract => e.type === "text-delta", + ); + const fullText = textDeltas.map((e) => e.delta).join(""); + expect(fullText).toBe("Hello there friend"); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); + + const usageEvents = events.filter( + (e): e is Extract => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(665); + expect(usageEvents[0]?.usage.outputTokens).toBe(90); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); + expect(captured.headers["Content-Type"]).toBe("application/json"); + expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); + + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.model).toBe("deepseek-v4-flash"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); + }); + + it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { + const fixture = loadFixture(toolFixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, + ]; + + const weatherTool = { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object" as const, + properties: { location: { type: "string" as const } }, + required: ["location"], + }, + execute: async () => ({ content: "" }), + }; + + const events = await collectEvents(streamChat(config, messages, [weatherTool])); + + const toolCalls = events.filter( + (e): e is Extract => e.type === "tool-call", + ); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); + expect(toolCalls[0]?.toolName).toBe("get_weather"); + expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); + + const usageEvents = events.filter( + (e): e is Extract => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(45); + expect(usageEvents[0]?.usage.outputTokens).toBe(12); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); + expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.tools).toHaveLength(1); + expect(capturedBody.tools[0].function.name).toBe("get_weather"); + }); }); describe("streamChat — record-mode redaction (trace-replay)", () => { - /** - * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). - * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. - */ - function maskSecret(value: string): string { - const len = value.length; - if (len <= 7) return "…redacted…"; - let reveal: number; - if (len >= 13) { - reveal = 3; - } else if (len >= 11) { - reveal = 2; - } else { - reveal = 1; - } - return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; - } - - it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { - const apiKey = "sk-abcdefghijkmnop"; - const responseBody = - 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - ...(fx.meta !== undefined ? { meta: fx.meta } : {}), - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', - }); - - assertDefined(capturedFixture); - - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); - - expect(capturedFixture.request.body).toContain('"model":"test"'); - expect(capturedFixture.request.body).toContain('"content":"hi"'); - - expect(capturedFixture.response.status).toBe(200); - expect(capturedFixture.response.body).toBe(responseBody); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).toContain("Bearer sk-…redacted…nop"); - expect(serialized).not.toContain("abcdefghijkm"); - expect(serialized).toContain("content"); - expect(serialized).toContain("hi"); - }); - - it("redacts capitalized Authorization header (the real leak casing)", async () => { - const apiKey = "sk-LIVEKEY1234567890abcdef"; - const responseBody = "data: [DONE]\n"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[],"stream":true}', - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); - expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain("LIVEKEY1234567890abc"); - expect(serialized).toContain("Bearer sk-…redacted…def"); - }); - - it("redacts lowercase authorization header", async () => { - const apiKey = "sk-abcdefghijkmnop"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - }); - - it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { - const apiKey = "sk-REALKEY_1234567890abcdef"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - for (const [key, value] of Object.entries(capturedFixture.request.headers)) { - if (key.toLowerCase() === "authorization") { - expect(value).not.toContain(apiKey); - expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - } - } - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain(apiKey); - expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: "Bearer secret!" }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); - }); + /** + * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). + * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. + */ + function maskSecret(value: string): string { + const len = value.length; + if (len <= 7) return "…redacted…"; + let reveal: number; + if (len >= 13) { + reveal = 3; + } else if (len >= 11) { + reveal = 2; + } else { + reveal = 1; + } + return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; + } + + it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { + const apiKey = "sk-abcdefghijkmnop"; + const responseBody = + 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + ...(fx.meta !== undefined ? { meta: fx.meta } : {}), + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', + }); + + assertDefined(capturedFixture); + + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); + + expect(capturedFixture.request.body).toContain('"model":"test"'); + expect(capturedFixture.request.body).toContain('"content":"hi"'); + + expect(capturedFixture.response.status).toBe(200); + expect(capturedFixture.response.body).toBe(responseBody); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).toContain("Bearer sk-…redacted…nop"); + expect(serialized).not.toContain("abcdefghijkm"); + expect(serialized).toContain("content"); + expect(serialized).toContain("hi"); + }); + + it("redacts capitalized Authorization header (the real leak casing)", async () => { + const apiKey = "sk-LIVEKEY1234567890abcdef"; + const responseBody = "data: [DONE]\n"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[],"stream":true}', + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); + expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain("LIVEKEY1234567890abc"); + expect(serialized).toContain("Bearer sk-…redacted…def"); + }); + + it("redacts lowercase authorization header", async () => { + const apiKey = "sk-abcdefghijkmnop"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + }); + + it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { + const apiKey = "sk-REALKEY_1234567890abcdef"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + for (const [key, value] of Object.entries(capturedFixture.request.headers)) { + if (key.toLowerCase() === "authorization") { + expect(value).not.toContain(apiKey); + expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + } + } + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain(apiKey); + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer secret!" }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); + }); }); diff --git a/packages/openai-stream/src/stream.ts b/packages/openai-stream/src/stream.ts index 2f594b5..a676715 100644 --- a/packages/openai-stream/src/stream.ts +++ b/packages/openai-stream/src/stream.ts @@ -1,36 +1,36 @@ import type { - ChatMessage, - ProviderEvent, - ProviderStreamOptions, - Span, - ToolContract, + ChatMessage, + ProviderEvent, + ProviderStreamOptions, + Span, + ToolContract, } from "@dispatch/kernel"; import type { FetchLike, HttpExchangeFixture } from "@dispatch/trace-replay"; import { convertMessages, type OpenAIMessage } from "./convert-messages.js"; import { convertTools, type OpenAITool } from "./convert-tools.js"; export interface StreamConfig { - readonly baseURL: string; - readonly apiKey: string; - readonly model: string; - /** - * Internal injectable fetch — used by replay tests and record mode. - * When absent, falls back to globalThis.fetch (production default). - */ - readonly fetchFn?: FetchLike; - /** - * Optional hook a provider extension uses to add provider-specific body - * fields (e.g. `reasoning_effort`) before the request is sent. Receives the - * body built so far + the ProviderStreamOptions; returns ADDITIONAL fields - * to merge into the body (or a full body). Generic — the library names no - * feature. Applied AFTER building `body` and BEFORE `JSON.stringify`, so - * the verbatim post-transform bytes are what hit the wire (and what the - * provider.request span captures). Default (absent): no extra fields. - */ - readonly transformBody?: ( - body: Record, - opts: ProviderStreamOptions, - ) => Record; + readonly baseURL: string; + readonly apiKey: string; + readonly model: string; + /** + * Internal injectable fetch — used by replay tests and record mode. + * When absent, falls back to globalThis.fetch (production default). + */ + readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body + * fields (e.g. `reasoning_effort`) before the request is sent. Receives the + * body built so far + the ProviderStreamOptions; returns ADDITIONAL fields + * to merge into the body (or a full body). Generic — the library names no + * feature. Applied AFTER building `body` and BEFORE `JSON.stringify`, so + * the verbatim post-transform bytes are what hit the wire (and what the + * provider.request span captures). Default (absent): no extra fields. + */ + readonly transformBody?: ( + body: Record, + opts: ProviderStreamOptions, + ) => Record; } /** @@ -38,379 +38,379 @@ export interface StreamConfig { * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. */ function maskSecret(value: string): string { - const len = value.length; - if (len <= 7) return "…redacted…"; - let reveal: number; - if (len >= 13) { - reveal = 3; - } else if (len >= 11) { - reveal = 2; - } else { - reveal = 1; - } - return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; + const len = value.length; + if (len <= 7) return "…redacted…"; + let reveal: number; + if (len >= 13) { + reveal = 3; + } else if (len >= 11) { + reveal = 2; + } else { + reveal = 1; + } + return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; } export async function* streamChat( - config: StreamConfig, - messages: readonly ChatMessage[], - tools: readonly ToolContract[], - opts?: ProviderStreamOptions, + config: StreamConfig, + messages: readonly ChatMessage[], + tools: readonly ToolContract[], + opts?: ProviderStreamOptions, ): AsyncIterable { - const openaiMessages = convertMessages(messages); - const openaiTools = convertTools(tools); - - const systemPrompt = opts?.systemPrompt; - const finalMessages: OpenAIMessage[] = systemPrompt - ? [{ role: "system", content: systemPrompt }, ...openaiMessages] - : openaiMessages; - - const body: Record = { - model: opts?.model ?? config.model, - messages: finalMessages, - stream: true, - stream_options: { include_usage: true }, - }; - - if (openaiTools.length > 0) { - body.tools = openaiTools satisfies OpenAITool[]; - } - if (opts?.temperature !== undefined) { - body.temperature = opts.temperature; - } - if (opts?.maxTokens !== undefined) { - body.max_tokens = opts.maxTokens; - } - - if (config.transformBody) { - const extra = config.transformBody(body, opts ?? {}); - Object.assign(body, extra); - } - - const url = `${config.baseURL}/chat/completions`; - const bodyString = JSON.stringify(body); - - let reqSpan: Span | undefined; - let totalInputTokens: number | undefined; - let totalOutputTokens: number | undefined; - let totalCacheReadTokens: number | undefined; - let totalCacheWriteTokens: number | undefined; - - if (opts?.logger) { - try { - const model = opts?.model ?? config.model; - const hasCacheBreakpoint = bodyString.includes("cache_control"); - reqSpan = opts.logger.span( - "provider.request", - { - model, - url, - "request.method": "POST", - "request.cache_control_present": hasCacheBreakpoint, - "request.headers.authorization": `Bearer ${maskSecret(config.apiKey)}`, - }, - bodyString, - ); - } catch { - // Fail-safe: capture must never break stream(). - } - } - - let effectiveFetch: FetchLike = config.fetchFn ?? fetch; - - const recordPath = - typeof process !== "undefined" ? process.env.DISPATCH_RECORD_FIXTURE : undefined; - if (recordPath && !config.fetchFn) { - try { - const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay"); - effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => { - try { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - const redacted: HttpExchangeFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - ...(fx.meta !== undefined ? { meta: fx.meta } : {}), - }; - saveFixture(recordPath, redacted); - } catch { - // Fail-safe: capture/write must never break the turn. - } - }); - } catch { - // Fail-safe: dynamic import or wrapping failure must never break the turn. - } - } - - let response: Response; - try { - response = await effectiveFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - }, - body: bodyString, - }); - } catch (err) { - if (reqSpan) { - try { - reqSpan.end({ - err, - attrs: { status: 0 }, - }); - } catch { - // Fail-safe. - } - } - yield { - type: "error", - message: err instanceof Error ? err.message : String(err), - retryable: true, - }; - return; - } - - if (!response.ok) { - const text = await response.text().catch(() => "unknown"); - if (reqSpan) { - try { - reqSpan.setAttributes({ status: response.status }); - reqSpan.end({ - err: new Error(`HTTP ${response.status}: ${text}`), - attrs: { - status: response.status, - "response.error_body": text, - }, - }); - } catch { - // Fail-safe. - } - } - yield { - type: "error", - message: `HTTP ${response.status}: ${text}`, - code: String(response.status), - retryable: response.status >= 500 || response.status === 429, - }; - return; - } - - if (!response.body) { - if (reqSpan) { - try { - reqSpan.end({ - err: new Error("Response body is null"), - attrs: { status: response.status }, - }); - } catch { - // Fail-safe. - } - } - yield { type: "error", message: "Response body is null" }; - return; - } - - try { - yield* readSSEStream(response.body, (usage) => { - totalInputTokens = usage.inputTokens; - totalOutputTokens = usage.outputTokens; - totalCacheReadTokens = usage.cacheReadTokens; - totalCacheWriteTokens = usage.cacheWriteTokens; - }); - } catch (err) { - if (reqSpan) { - try { - reqSpan.end({ - err, - attrs: { status: response.status }, - }); - } catch { - // Fail-safe. - } - } - throw err; - } - - if (reqSpan) { - try { - const attrs: Record = { - status: response.status, - }; - if (totalInputTokens !== undefined) { - attrs["usage.inputTokens"] = totalInputTokens; - } - if (totalOutputTokens !== undefined) { - attrs["usage.outputTokens"] = totalOutputTokens; - } - if (totalCacheReadTokens !== undefined) { - attrs["usage.cacheReadTokens"] = totalCacheReadTokens; - } - if (totalCacheWriteTokens !== undefined) { - attrs["usage.cacheWriteTokens"] = totalCacheWriteTokens; - } - reqSpan.end({ attrs }); - } catch { - // Fail-safe. - } - } + const openaiMessages = convertMessages(messages); + const openaiTools = convertTools(tools); + + const systemPrompt = opts?.systemPrompt; + const finalMessages: OpenAIMessage[] = systemPrompt + ? [{ role: "system", content: systemPrompt }, ...openaiMessages] + : openaiMessages; + + const body: Record = { + model: opts?.model ?? config.model, + messages: finalMessages, + stream: true, + stream_options: { include_usage: true }, + }; + + if (openaiTools.length > 0) { + body.tools = openaiTools satisfies OpenAITool[]; + } + if (opts?.temperature !== undefined) { + body.temperature = opts.temperature; + } + if (opts?.maxTokens !== undefined) { + body.max_tokens = opts.maxTokens; + } + + if (config.transformBody) { + const extra = config.transformBody(body, opts ?? {}); + Object.assign(body, extra); + } + + const url = `${config.baseURL}/chat/completions`; + const bodyString = JSON.stringify(body); + + let reqSpan: Span | undefined; + let totalInputTokens: number | undefined; + let totalOutputTokens: number | undefined; + let totalCacheReadTokens: number | undefined; + let totalCacheWriteTokens: number | undefined; + + if (opts?.logger) { + try { + const model = opts?.model ?? config.model; + const hasCacheBreakpoint = bodyString.includes("cache_control"); + reqSpan = opts.logger.span( + "provider.request", + { + model, + url, + "request.method": "POST", + "request.cache_control_present": hasCacheBreakpoint, + "request.headers.authorization": `Bearer ${maskSecret(config.apiKey)}`, + }, + bodyString, + ); + } catch { + // Fail-safe: capture must never break stream(). + } + } + + let effectiveFetch: FetchLike = config.fetchFn ?? fetch; + + const recordPath = + typeof process !== "undefined" ? process.env.DISPATCH_RECORD_FIXTURE : undefined; + if (recordPath && !config.fetchFn) { + try { + const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay"); + effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => { + try { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + const redacted: HttpExchangeFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + ...(fx.meta !== undefined ? { meta: fx.meta } : {}), + }; + saveFixture(recordPath, redacted); + } catch { + // Fail-safe: capture/write must never break the turn. + } + }); + } catch { + // Fail-safe: dynamic import or wrapping failure must never break the turn. + } + } + + let response: Response; + try { + response = await effectiveFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: bodyString, + }); + } catch (err) { + if (reqSpan) { + try { + reqSpan.end({ + err, + attrs: { status: 0 }, + }); + } catch { + // Fail-safe. + } + } + yield { + type: "error", + message: err instanceof Error ? err.message : String(err), + retryable: true, + }; + return; + } + + if (!response.ok) { + const text = await response.text().catch(() => "unknown"); + if (reqSpan) { + try { + reqSpan.setAttributes({ status: response.status }); + reqSpan.end({ + err: new Error(`HTTP ${response.status}: ${text}`), + attrs: { + status: response.status, + "response.error_body": text, + }, + }); + } catch { + // Fail-safe. + } + } + yield { + type: "error", + message: `HTTP ${response.status}: ${text}`, + code: String(response.status), + retryable: response.status >= 500 || response.status === 429, + }; + return; + } + + if (!response.body) { + if (reqSpan) { + try { + reqSpan.end({ + err: new Error("Response body is null"), + attrs: { status: response.status }, + }); + } catch { + // Fail-safe. + } + } + yield { type: "error", message: "Response body is null" }; + return; + } + + try { + yield* readSSEStream(response.body, (usage) => { + totalInputTokens = usage.inputTokens; + totalOutputTokens = usage.outputTokens; + totalCacheReadTokens = usage.cacheReadTokens; + totalCacheWriteTokens = usage.cacheWriteTokens; + }); + } catch (err) { + if (reqSpan) { + try { + reqSpan.end({ + err, + attrs: { status: response.status }, + }); + } catch { + // Fail-safe. + } + } + throw err; + } + + if (reqSpan) { + try { + const attrs: Record = { + status: response.status, + }; + if (totalInputTokens !== undefined) { + attrs["usage.inputTokens"] = totalInputTokens; + } + if (totalOutputTokens !== undefined) { + attrs["usage.outputTokens"] = totalOutputTokens; + } + if (totalCacheReadTokens !== undefined) { + attrs["usage.cacheReadTokens"] = totalCacheReadTokens; + } + if (totalCacheWriteTokens !== undefined) { + attrs["usage.cacheWriteTokens"] = totalCacheWriteTokens; + } + reqSpan.end({ attrs }); + } catch { + // Fail-safe. + } + } } async function* readSSEStream( - body: ReadableStream, - onUsage?: (usage: { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - }) => void, + body: ReadableStream, + onUsage?: (usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }) => void, ): AsyncIterable { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const toolCalls = new Map(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (data === "[DONE]") return; - - let chunk: Record; - try { - chunk = JSON.parse(data); - } catch { - yield { type: "error", message: `Invalid JSON in SSE data: ${data}` }; - continue; - } - - const choices = chunk.choices as - | Array<{ - delta: Record; - finish_reason?: string | null; - }> - | undefined; - - if (choices) { - for (const choice of choices) { - const delta = choice.delta; - - if (typeof delta.content === "string" && delta.content) { - yield { type: "text-delta", delta: delta.content }; - } - - if (typeof delta.reasoning_content === "string" && delta.reasoning_content) { - yield { type: "reasoning-delta", delta: delta.reasoning_content }; - } - - const tcs = delta.tool_calls as - | Array<{ - index: number; - id?: string; - function?: { name?: string; arguments?: string }; - }> - | undefined; - - if (tcs) { - for (const tc of tcs) { - const existing = toolCalls.get(tc.index); - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments; - } - } else { - toolCalls.set(tc.index, { - id: tc.id ?? "", - name: tc.function?.name ?? "", - arguments: tc.function?.arguments ?? "", - }); - } - } - } - - if (choice.finish_reason) { - const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); - for (const idx of sortedIndices) { - const acc = toolCalls.get(idx); - if (!acc) continue; - let input: unknown; - try { - input = JSON.parse(acc.arguments); - } catch { - input = acc.arguments; - } - yield { - type: "tool-call", - toolCallId: acc.id, - toolName: acc.name, - input, - }; - } - yield { type: "finish", reason: choice.finish_reason }; - } - } - } - - const usage = chunk.usage as - | { - prompt_tokens?: number; - completion_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - prompt_tokens_details?: { cached_tokens?: number }; - completion_tokens_details?: Record; - } - | undefined; - - if (usage) { - const cacheRead = usage.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens; - const cacheWrite = usage.cache_write_tokens; - const usageObj: { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - } = { - inputTokens: usage.prompt_tokens ?? 0, - outputTokens: usage.completion_tokens ?? 0, - }; - if (cacheRead !== undefined) { - usageObj.cacheReadTokens = cacheRead; - } - if (cacheWrite !== undefined) { - usageObj.cacheWriteTokens = cacheWrite; - } - onUsage?.(usageObj); - yield { - type: "usage", - usage: { - inputTokens: usage.prompt_tokens ?? 0, - outputTokens: usage.completion_tokens ?? 0, - ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), - }, - }; - } - } - } - } finally { - reader.releaseLock(); - } + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const toolCalls = new Map(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") return; + + let chunk: Record; + try { + chunk = JSON.parse(data); + } catch { + yield { type: "error", message: `Invalid JSON in SSE data: ${data}` }; + continue; + } + + const choices = chunk.choices as + | Array<{ + delta: Record; + finish_reason?: string | null; + }> + | undefined; + + if (choices) { + for (const choice of choices) { + const delta = choice.delta; + + if (typeof delta.content === "string" && delta.content) { + yield { type: "text-delta", delta: delta.content }; + } + + if (typeof delta.reasoning_content === "string" && delta.reasoning_content) { + yield { type: "reasoning-delta", delta: delta.reasoning_content }; + } + + const tcs = delta.tool_calls as + | Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }> + | undefined; + + if (tcs) { + for (const tc of tcs) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? "", + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }); + } + } + } + + if (choice.finish_reason) { + const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); + for (const idx of sortedIndices) { + const acc = toolCalls.get(idx); + if (!acc) continue; + let input: unknown; + try { + input = JSON.parse(acc.arguments); + } catch { + input = acc.arguments; + } + yield { + type: "tool-call", + toolCallId: acc.id, + toolName: acc.name, + input, + }; + } + yield { type: "finish", reason: choice.finish_reason }; + } + } + } + + const usage = chunk.usage as + | { + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; + completion_tokens_details?: Record; + } + | undefined; + + if (usage) { + const cacheRead = usage.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens; + const cacheWrite = usage.cache_write_tokens; + const usageObj: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + } = { + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + }; + if (cacheRead !== undefined) { + usageObj.cacheReadTokens = cacheRead; + } + if (cacheWrite !== undefined) { + usageObj.cacheWriteTokens = cacheWrite; + } + onUsage?.(usageObj); + yield { + type: "usage", + usage: { + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), + }, + }; + } + } + } + } finally { + reader.releaseLock(); + } } diff --git a/packages/openai-stream/tsconfig.json b/packages/openai-stream/tsconfig.json index 39be10e..bda89c6 100644 --- a/packages/openai-stream/tsconfig.json +++ b/packages/openai-stream/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }, { "path": "../wire" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }, { "path": "../wire" }] } diff --git a/packages/provider-openai-compat/package.json b/packages/provider-openai-compat/package.json index 465df0c..a029d76 100644 --- a/packages/provider-openai-compat/package.json +++ b/packages/provider-openai-compat/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/provider-openai-compat", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/openai-stream": "workspace:*", - "@dispatch/trace-replay": "workspace:*" - } + "name": "@dispatch/provider-openai-compat", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*", + "@dispatch/trace-replay": "workspace:*" + } } diff --git a/packages/provider-openai-compat/src/extension.test.ts b/packages/provider-openai-compat/src/extension.test.ts index 6dc409a..653b825 100644 --- a/packages/provider-openai-compat/src/extension.test.ts +++ b/packages/provider-openai-compat/src/extension.test.ts @@ -3,97 +3,97 @@ import { describe, expect, it, vi } from "vitest"; import { activate, manifest } from "./extension.js"; function makeFakeHost(overrides: { - getAuthProvider?: (id: string) => AuthContract | undefined; - configGet?: (key: string) => unknown; + getAuthProvider?: (id: string) => AuthContract | undefined; + configGet?: (key: string) => unknown; }): { host: HostAPI; defineProvider: ReturnType } { - const defineProvider = vi.fn(); - const warn = vi.fn(); - const info = vi.fn(); - const host = { - defineProvider, - config: { get: overrides.configGet ?? (() => undefined) }, - logger: { debug: vi.fn(), info, warn, error: vi.fn() }, - getAuthProvider: overrides.getAuthProvider ?? (() => undefined), - } as unknown as HostAPI; - return { host, defineProvider }; + const defineProvider = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + const host = { + defineProvider, + config: { get: overrides.configGet ?? (() => undefined) }, + logger: { debug: vi.fn(), info, warn, error: vi.fn() }, + getAuthProvider: overrides.getAuthProvider ?? (() => undefined), + } as unknown as HostAPI; + return { host, defineProvider }; } describe("provider-openai-compat activation", () => { - it("registers provider when auth resolves ApiKeyCredentials", async () => { - const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; - const fakeAuth: AuthContract = { - id: "apikey", - resolve: async () => fakeCreds, - }; + it("registers provider when auth resolves ApiKeyCredentials", async () => { + const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => fakeCreds, + }; - const { host, defineProvider } = makeFakeHost({ - getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), - }); + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); - await activate(host); + await activate(host); - expect(defineProvider).toHaveBeenCalledTimes(1); - expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("openai-compat"); - }); + expect(defineProvider).toHaveBeenCalledTimes(1); + expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("openai-compat"); + }); - it("does not register provider when getAuthProvider returns undefined", async () => { - const { host, defineProvider } = makeFakeHost({ - getAuthProvider: () => undefined, - }); + it("does not register provider when getAuthProvider returns undefined", async () => { + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: () => undefined, + }); - await activate(host); + await activate(host); - expect(defineProvider).not.toHaveBeenCalled(); - }); + expect(defineProvider).not.toHaveBeenCalled(); + }); - it("does not register provider when resolve returns null", async () => { - const fakeAuth: AuthContract = { - id: "apikey", - resolve: async () => null, - }; + it("does not register provider when resolve returns null", async () => { + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => null, + }; - const { host, defineProvider } = makeFakeHost({ - getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), - }); + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); - await activate(host); + await activate(host); - expect(defineProvider).not.toHaveBeenCalled(); - }); + expect(defineProvider).not.toHaveBeenCalled(); + }); - it("does not register provider when credentials are not api-key type", async () => { - const fakeAuth: AuthContract = { - id: "apikey", - resolve: async () => ({ type: "bearer-token", token: "tok-123" }), - }; + it("does not register provider when credentials are not api-key type", async () => { + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => ({ type: "bearer-token", token: "tok-123" }), + }; - const { host, defineProvider } = makeFakeHost({ - getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), - }); + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); - await activate(host); + await activate(host); - expect(defineProvider).not.toHaveBeenCalled(); - }); + expect(defineProvider).not.toHaveBeenCalled(); + }); - it("uses default model when config returns undefined", async () => { - const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; - const fakeAuth: AuthContract = { - id: "apikey", - resolve: async () => fakeCreds, - }; + it("uses default model when config returns undefined", async () => { + const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => fakeCreds, + }; - const { host, defineProvider } = makeFakeHost({ - getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), - configGet: () => undefined, - }); + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + configGet: () => undefined, + }); - await activate(host); + await activate(host); - expect(defineProvider).toHaveBeenCalledTimes(1); - }); + expect(defineProvider).toHaveBeenCalledTimes(1); + }); - it("declares dependsOn auth-apikey", () => { - expect(manifest.dependsOn).toEqual(["auth-apikey"]); - }); + it("declares dependsOn auth-apikey", () => { + expect(manifest.dependsOn).toEqual(["auth-apikey"]); + }); }); diff --git a/packages/provider-openai-compat/src/extension.ts b/packages/provider-openai-compat/src/extension.ts index 042a807..49bc96a 100644 --- a/packages/provider-openai-compat/src/extension.ts +++ b/packages/provider-openai-compat/src/extension.ts @@ -2,53 +2,53 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createOpenAICompatProvider } from "@dispatch/openai-stream"; export const manifest: Manifest = { - id: "provider-openai-compat", - name: "OpenAI-Compatible Provider", - version: "0.0.0", - apiVersion: "^0.1.0", - dependsOn: ["auth-apikey"], - trust: "bundled", - activation: "eager", - capabilities: { network: true }, - contributes: { providers: ["openai-compat"] }, + id: "provider-openai-compat", + name: "OpenAI-Compatible Provider", + version: "0.0.0", + apiVersion: "^0.1.0", + dependsOn: ["auth-apikey"], + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { providers: ["openai-compat"] }, }; export async function activate(host: HostAPI): Promise { - const auth = host.getAuthProvider("apikey"); - if (!auth) { - host.logger.warn( - "provider-openai-compat: auth-apikey extension not available. Provider not registered.", - ); - return; - } + const auth = host.getAuthProvider("apikey"); + if (!auth) { + host.logger.warn( + "provider-openai-compat: auth-apikey extension not available. Provider not registered.", + ); + return; + } - const creds = await auth.resolve(); - if (!creds) { - host.logger.warn( - "provider-openai-compat: no credentials resolved from auth-apikey. Provider not registered.", - ); - return; - } + const creds = await auth.resolve(); + if (!creds) { + host.logger.warn( + "provider-openai-compat: no credentials resolved from auth-apikey. Provider not registered.", + ); + return; + } - if (creds.type !== "api-key") { - host.logger.warn( - `provider-openai-compat: expected api-key credentials but got "${creds.type}". Provider not registered.`, - ); - return; - } + if (creds.type !== "api-key") { + host.logger.warn( + `provider-openai-compat: expected api-key credentials but got "${creds.type}". Provider not registered.`, + ); + return; + } - const model = host.config.get("provider.openai-compat.model") ?? "deepseek-v4-flash"; + const model = host.config.get("provider.openai-compat.model") ?? "deepseek-v4-flash"; - const provider = createOpenAICompatProvider({ - credentials: creds, - model, - id: "openai-compat", - }); - host.defineProvider(provider); - host.logger.info(`provider-openai-compat: registered (model=${model})`); + const provider = createOpenAICompatProvider({ + credentials: creds, + model, + id: "openai-compat", + }); + host.defineProvider(provider); + host.logger.info(`provider-openai-compat: registered (model=${model})`); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/provider-openai-compat/src/index.ts b/packages/provider-openai-compat/src/index.ts index 78e30bb..a9117d1 100644 --- a/packages/provider-openai-compat/src/index.ts +++ b/packages/provider-openai-compat/src/index.ts @@ -1,14 +1,14 @@ export type { - CreateOpenAICompatProviderOpts, - OpenAIMessage, - OpenAITool, - OpenAIToolCall, + CreateOpenAICompatProviderOpts, + OpenAIMessage, + OpenAITool, + OpenAIToolCall, } from "@dispatch/openai-stream"; export { - convertMessages, - convertTools, - createOpenAICompatProvider, - parseModelList, - parseSSELines, + convertMessages, + convertTools, + createOpenAICompatProvider, + parseModelList, + parseSSELines, } from "@dispatch/openai-stream"; export { activate, extension, manifest } from "./extension.js"; diff --git a/packages/provider-openai-compat/tsconfig.json b/packages/provider-openai-compat/tsconfig.json index 9cc0414..4842361 100644 --- a/packages/provider-openai-compat/tsconfig.json +++ b/packages/provider-openai-compat/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../openai-stream" }, - { "path": "../trace-replay" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../openai-stream" }, + { "path": "../trace-replay" } + ] } diff --git a/packages/provider-umans/package.json b/packages/provider-umans/package.json index ca09e06..83b9306 100644 --- a/packages/provider-umans/package.json +++ b/packages/provider-umans/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/provider-umans", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/openai-stream": "workspace:*" - } + "name": "@dispatch/provider-umans", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*" + } } diff --git a/packages/provider-umans/src/extension.test.ts b/packages/provider-umans/src/extension.test.ts index 59efddc..07b4f4c 100644 --- a/packages/provider-umans/src/extension.test.ts +++ b/packages/provider-umans/src/extension.test.ts @@ -4,49 +4,49 @@ import { activate, manifest } from "./extension.js"; import type { EnvSource } from "./resolver.js"; function makeFakeHost(overrides: { configGet?: (key: string) => unknown }): { - host: HostAPI; - defineProvider: ReturnType; - warn: ReturnType; - info: ReturnType; + host: HostAPI; + defineProvider: ReturnType; + warn: ReturnType; + info: ReturnType; } { - const defineProvider = vi.fn(); - const warn = vi.fn(); - const info = vi.fn(); - const host = { - defineProvider, - config: { get: overrides.configGet ?? (() => undefined) }, - logger: { debug: vi.fn(), info, warn, error: vi.fn() }, - } as unknown as HostAPI; - return { host, defineProvider, warn, info }; + const defineProvider = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + const host = { + defineProvider, + config: { get: overrides.configGet ?? (() => undefined) }, + logger: { debug: vi.fn(), info, warn, error: vi.fn() }, + } as unknown as HostAPI; + return { host, defineProvider, warn, info }; } describe("provider-umans activation", () => { - it('activate registers the "umans" provider when UMANS_API_KEY is set (defineProvider called with id "umans")', async () => { - const { host, defineProvider } = makeFakeHost({}); - const env: EnvSource = { UMANS_API_KEY: "sk-test" }; + it('activate registers the "umans" provider when UMANS_API_KEY is set (defineProvider called with id "umans")', async () => { + const { host, defineProvider } = makeFakeHost({}); + const env: EnvSource = { UMANS_API_KEY: "sk-test" }; - await activate(host, env); + await activate(host, env); - expect(defineProvider).toHaveBeenCalledTimes(1); - expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("umans"); - }); + expect(defineProvider).toHaveBeenCalledTimes(1); + expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("umans"); + }); - it("activate does NOT register + warns when UMANS_API_KEY is unset", async () => { - const { host, defineProvider, warn } = makeFakeHost({}); - const env: EnvSource = {}; + it("activate does NOT register + warns when UMANS_API_KEY is unset", async () => { + const { host, defineProvider, warn } = makeFakeHost({}); + const env: EnvSource = {}; - await activate(host, env); + await activate(host, env); - expect(defineProvider).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith("provider-umans: no UMANS_API_KEY. Provider not registered."); - }); + expect(defineProvider).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("provider-umans: no UMANS_API_KEY. Provider not registered."); + }); - it("declares no dependsOn (self-contained, reads env directly)", () => { - expect(manifest.dependsOn).toBeUndefined(); - }); + it("declares no dependsOn (self-contained, reads env directly)", () => { + expect(manifest.dependsOn).toBeUndefined(); + }); - it("declares the umans provider contribution + network capability", () => { - expect(manifest.contributes?.providers).toEqual(["umans"]); - expect(manifest.capabilities?.network).toBe(true); - }); + it("declares the umans provider contribution + network capability", () => { + expect(manifest.contributes?.providers).toEqual(["umans"]); + expect(manifest.capabilities?.network).toBe(true); + }); }); diff --git a/packages/provider-umans/src/extension.ts b/packages/provider-umans/src/extension.ts index 74f8481..da77724 100644 --- a/packages/provider-umans/src/extension.ts +++ b/packages/provider-umans/src/extension.ts @@ -4,14 +4,14 @@ import { transformBody } from "./reasoning.js"; import { type EnvSource, resolveUmansConfig } from "./resolver.js"; export const manifest: Manifest = { - id: "provider-umans", - name: "Umans AI Coding Plan", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { network: true }, - contributes: { providers: ["umans"] }, + id: "provider-umans", + name: "Umans AI Coding Plan", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { providers: ["umans"] }, }; /** @@ -26,30 +26,30 @@ export const manifest: Manifest = { * No `UMANS_API_KEY` is a config state, not an error — warn + skip registration. */ export async function activate(host: HostAPI, env: EnvSource = process.env): Promise { - const configModel = host.config.get("provider.umans.model"); - const cfg = resolveUmansConfig(env, configModel); - if (!cfg) { - host.logger.warn("provider-umans: no UMANS_API_KEY. Provider not registered."); - return; - } + const configModel = host.config.get("provider.umans.model"); + const cfg = resolveUmansConfig(env, configModel); + if (!cfg) { + host.logger.warn("provider-umans: no UMANS_API_KEY. Provider not registered."); + return; + } - const credentials: ApiKeyCredentials = { - type: "api-key", - apiKey: cfg.apiKey, - baseURL: cfg.baseURL, - }; + const credentials: ApiKeyCredentials = { + type: "api-key", + apiKey: cfg.apiKey, + baseURL: cfg.baseURL, + }; - const provider = createOpenAICompatProvider({ - credentials, - model: cfg.model, - id: "umans", - transformBody, - }); - host.defineProvider(provider); - host.logger.info(`provider-umans: registered (model=${cfg.model})`); + const provider = createOpenAICompatProvider({ + credentials, + model: cfg.model, + id: "umans", + transformBody, + }); + host.defineProvider(provider); + host.logger.info(`provider-umans: registered (model=${cfg.model})`); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/provider-umans/src/index.ts b/packages/provider-umans/src/index.ts index d4d143b..241028e 100644 --- a/packages/provider-umans/src/index.ts +++ b/packages/provider-umans/src/index.ts @@ -1,8 +1,8 @@ export { activate, extension, manifest } from "./extension.js"; export { mapReasoningEffort, transformBody } from "./reasoning.js"; export { - type EnvSource, - resolveUmansConfig, - toUmansCredentials, - type UmansConfig, + type EnvSource, + resolveUmansConfig, + toUmansCredentials, + type UmansConfig, } from "./resolver.js"; diff --git a/packages/provider-umans/src/reasoning.test.ts b/packages/provider-umans/src/reasoning.test.ts index 2e57e25..a31b8cf 100644 --- a/packages/provider-umans/src/reasoning.test.ts +++ b/packages/provider-umans/src/reasoning.test.ts @@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest"; import { mapReasoningEffort, transformBody } from "./reasoning.js"; describe("mapReasoningEffort", () => { - it('mapReasoningEffort: low → "low", medium → "medium", high → "high", xhigh → "high", max → "high"', () => { - expect(mapReasoningEffort("low")).toBe("low"); - expect(mapReasoningEffort("medium")).toBe("medium"); - expect(mapReasoningEffort("high")).toBe("high"); - expect(mapReasoningEffort("xhigh")).toBe("high"); - expect(mapReasoningEffort("max")).toBe("high"); - }); + it('mapReasoningEffort: low → "low", medium → "medium", high → "high", xhigh → "high", max → "high"', () => { + expect(mapReasoningEffort("low")).toBe("low"); + expect(mapReasoningEffort("medium")).toBe("medium"); + expect(mapReasoningEffort("high")).toBe("high"); + expect(mapReasoningEffort("xhigh")).toBe("high"); + expect(mapReasoningEffort("max")).toBe("high"); + }); - it("mapReasoningEffort: undefined → undefined (no field)", () => { - expect(mapReasoningEffort(undefined)).toBe(undefined); - }); + it("mapReasoningEffort: undefined → undefined (no field)", () => { + expect(mapReasoningEffort(undefined)).toBe(undefined); + }); }); describe("transformBody", () => { - it("transformBody adds reasoning_effort when opts.reasoningEffort is set", () => { - const result = transformBody({}, { reasoningEffort: "high" }); - expect(result).toEqual({ reasoning_effort: "high" }); - }); + it("transformBody adds reasoning_effort when opts.reasoningEffort is set", () => { + const result = transformBody({}, { reasoningEffort: "high" }); + expect(result).toEqual({ reasoning_effort: "high" }); + }); - it("transformBody adds nothing when opts.reasoningEffort is absent (byte-stable)", () => { - const result = transformBody({}, {}); - expect(result).toEqual({}); - }); + it("transformBody adds nothing when opts.reasoningEffort is absent (byte-stable)", () => { + const result = transformBody({}, {}); + expect(result).toEqual({}); + }); }); diff --git a/packages/provider-umans/src/reasoning.ts b/packages/provider-umans/src/reasoning.ts index 9015848..22b3202 100644 --- a/packages/provider-umans/src/reasoning.ts +++ b/packages/provider-umans/src/reasoning.ts @@ -12,11 +12,11 @@ import type { ProviderStreamOptions, ReasoningEffort } from "@dispatch/kernel"; * Pure: the `transformBody` decision, factored out for direct unit testing. */ export function mapReasoningEffort( - effort: ReasoningEffort | undefined, + effort: ReasoningEffort | undefined, ): "low" | "medium" | "high" | undefined { - if (effort === undefined) return undefined; - if (effort === "xhigh" || effort === "max") return "high"; - return effort; + if (effort === undefined) return undefined; + if (effort === "xhigh" || effort === "max") return "high"; + return effort; } /** @@ -27,10 +27,10 @@ export function mapReasoningEffort( * byte-stable for calls with no reasoning preference. */ export function transformBody( - _body: Record, - opts: ProviderStreamOptions, + _body: Record, + opts: ProviderStreamOptions, ): Record { - const mapped = mapReasoningEffort(opts.reasoningEffort); - if (mapped === undefined) return {}; - return { reasoning_effort: mapped }; + const mapped = mapReasoningEffort(opts.reasoningEffort); + if (mapped === undefined) return {}; + return { reasoning_effort: mapped }; } diff --git a/packages/provider-umans/src/resolver.test.ts b/packages/provider-umans/src/resolver.test.ts index 47d436e..966c1bf 100644 --- a/packages/provider-umans/src/resolver.test.ts +++ b/packages/provider-umans/src/resolver.test.ts @@ -2,42 +2,42 @@ import { describe, expect, it } from "vitest"; import { resolveUmansConfig } from "./resolver.js"; describe("resolveUmansConfig", () => { - it("activate uses UMANS_BASE_URL override when set (default otherwise)", () => { - const override = resolveUmansConfig( - { UMANS_API_KEY: "sk-test", UMANS_BASE_URL: "https://custom.example.com/v1" }, - undefined, - ); - expect(override?.baseURL).toBe("https://custom.example.com/v1"); + it("activate uses UMANS_BASE_URL override when set (default otherwise)", () => { + const override = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_BASE_URL: "https://custom.example.com/v1" }, + undefined, + ); + expect(override?.baseURL).toBe("https://custom.example.com/v1"); - const fallback = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); - expect(fallback?.baseURL).toBe("https://api.code.umans.ai/v1"); - }); + const fallback = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fallback?.baseURL).toBe("https://api.code.umans.ai/v1"); + }); - it('activate uses config provider.umans.model → UMANS_MODEL → "umans-coder" resolution', () => { - // config wins over env + default - const fromConfig = resolveUmansConfig( - { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, - "config-model", - ); - expect(fromConfig?.model).toBe("config-model"); + it('activate uses config provider.umans.model → UMANS_MODEL → "umans-coder" resolution', () => { + // config wins over env + default + const fromConfig = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + "config-model", + ); + expect(fromConfig?.model).toBe("config-model"); - // env wins when config is absent - const fromEnv = resolveUmansConfig( - { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, - undefined, - ); - expect(fromEnv?.model).toBe("env-model"); + // env wins when config is absent + const fromEnv = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + undefined, + ); + expect(fromEnv?.model).toBe("env-model"); - // default when neither is set - const fromDefault = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); - expect(fromDefault?.model).toBe("umans-coder"); - }); + // default when neither is set + const fromDefault = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fromDefault?.model).toBe("umans-coder"); + }); - it("returns null when UMANS_API_KEY is unset", () => { - expect(resolveUmansConfig({}, undefined)).toBeNull(); - }); + it("returns null when UMANS_API_KEY is unset", () => { + expect(resolveUmansConfig({}, undefined)).toBeNull(); + }); - it("returns null when UMANS_API_KEY is empty string", () => { - expect(resolveUmansConfig({ UMANS_API_KEY: "" }, undefined)).toBeNull(); - }); + it("returns null when UMANS_API_KEY is empty string", () => { + expect(resolveUmansConfig({ UMANS_API_KEY: "" }, undefined)).toBeNull(); + }); }); diff --git a/packages/provider-umans/src/resolver.ts b/packages/provider-umans/src/resolver.ts index 416a281..5f0e76e 100644 --- a/packages/provider-umans/src/resolver.ts +++ b/packages/provider-umans/src/resolver.ts @@ -8,9 +8,9 @@ const DEFAULT_MODEL = "umans-coder"; * and model that `activate` threads into `createOpenAICompatProvider`. */ export interface UmansConfig { - readonly apiKey: string; - readonly baseURL: string; - readonly model: string; + readonly apiKey: string; + readonly baseURL: string; + readonly model: string; } /** @@ -32,14 +32,14 @@ export type EnvSource = Readonly>; * unit testing with zero mocks. */ export function resolveUmansConfig( - env: EnvSource, - configModel: string | undefined, + env: EnvSource, + configModel: string | undefined, ): UmansConfig | null { - const apiKey = env.UMANS_API_KEY; - if (!apiKey) return null; - const baseURL = env.UMANS_BASE_URL ?? DEFAULT_BASE_URL; - const model = configModel ?? env.UMANS_MODEL ?? DEFAULT_MODEL; - return { apiKey, baseURL, model }; + const apiKey = env.UMANS_API_KEY; + if (!apiKey) return null; + const baseURL = env.UMANS_BASE_URL ?? DEFAULT_BASE_URL; + const model = configModel ?? env.UMANS_MODEL ?? DEFAULT_MODEL; + return { apiKey, baseURL, model }; } /** @@ -47,5 +47,5 @@ export function resolveUmansConfig( * — `baseURL` on the credential so it is overridable per-credential. */ export function toUmansCredentials(cfg: UmansConfig): ApiKeyCredentials { - return { type: "api-key", apiKey: cfg.apiKey, baseURL: cfg.baseURL }; + return { type: "api-key", apiKey: cfg.apiKey, baseURL: cfg.baseURL }; } diff --git a/packages/provider-umans/tsconfig.json b/packages/provider-umans/tsconfig.json index f450b9a..3883d12 100644 --- a/packages/provider-umans/tsconfig.json +++ b/packages/provider-umans/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../openai-stream" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../openai-stream" }] } diff --git a/packages/session-orchestrator/package.json b/packages/session-orchestrator/package.json index 40cc0fe..ba34c4d 100644 --- a/packages/session-orchestrator/package.json +++ b/packages/session-orchestrator/package.json @@ -1,15 +1,15 @@ { - "name": "@dispatch/session-orchestrator", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/conversation-store": "workspace:*", - "@dispatch/credential-store": "workspace:*", - "@dispatch/message-queue": "workspace:*", - "@dispatch/system-prompt": "workspace:*" - } + "name": "@dispatch/session-orchestrator", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/conversation-store": "workspace:*", + "@dispatch/credential-store": "workspace:*", + "@dispatch/message-queue": "workspace:*", + "@dispatch/system-prompt": "workspace:*" + } } diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index 1a57cc3..5afffd8 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -5,169 +5,169 @@ import { runTurn } from "@dispatch/kernel"; import { messageQueueHandle } from "@dispatch/message-queue"; import { systemPromptHandle } from "@dispatch/system-prompt"; import { - cacheWarmHandle, - compactionHandle, - createCompactionService, - createSessionOrchestrator, - createWarmService, - sessionOrchestratorHandle, + cacheWarmHandle, + compactionHandle, + createCompactionService, + createSessionOrchestrator, + createWarmService, + sessionOrchestratorHandle, } from "./orchestrator.js"; import { selectFirstProvider } from "./pure.js"; import { filterRemoteIncompatibleTools, toolsFilter } from "./tools-filter.js"; export const manifest: Manifest = { - id: "session-orchestrator", - name: "Session Orchestrator", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - dependsOn: ["conversation-store", "credential-store"], - activation: "eager", - contributes: { - services: [ - "session-orchestrator/orchestrator", - "session-orchestrator/warm", - "session-orchestrator/compaction", - ], - hooks: [ - "session-orchestrator/turn-started", - "session-orchestrator/turn-settled", - "session-orchestrator/warm-completed", - "session-orchestrator/conversation-closed", - "session-orchestrator/conversation-status-changed", - "session-orchestrator/conversation-compacted", - ], - }, + id: "session-orchestrator", + name: "Session Orchestrator", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: ["conversation-store", "credential-store"], + activation: "eager", + contributes: { + services: [ + "session-orchestrator/orchestrator", + "session-orchestrator/warm", + "session-orchestrator/compaction", + ], + hooks: [ + "session-orchestrator/turn-started", + "session-orchestrator/turn-settled", + "session-orchestrator/warm-completed", + "session-orchestrator/conversation-closed", + "session-orchestrator/conversation-status-changed", + "session-orchestrator/conversation-compacted", + ], + }, }; export function activate(host: HostAPI): void { - const conversationStore = host.getService(conversationStoreHandle); + const conversationStore = host.getService(conversationStoreHandle); - const { orchestrator, activeConversations } = createSessionOrchestrator({ - conversationStore, - resolveProvider: () => selectFirstProvider(host.getProviders()), - resolveTools: () => [...host.getTools().values()], - resolveModel: (modelName: string) => { - const store = host.getService(credentialStoreHandle); - const r = store.resolve(modelName); - if (r === undefined) return undefined; - const provider = host.getProviders().get(r.providerId); - return provider ? { provider, model: r.model } : undefined; - }, - resolveModelInfo: async (modelName: string) => { - const store = host.getService(credentialStoreHandle); - return store.getModelInfo(modelName); - }, - applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), - runTurn, - logger: host.logger, - now: () => Date.now(), - emit: (hook, payload) => host.emit(hook, payload), - resolveQueue: () => { - // Lazily resolve the message-queue service. Returns undefined when the - // extension isn't loaded (feature degrades off) — checked via the - // activated-manifests list so `host.getService` is only called when the - // service is registered. Lazy so activation order with message-queue - // doesn't matter; called per-turn / per-enqueue, not at activate time. - const loaded = host.getExtensions().some((m) => m.id === "message-queue"); - return loaded ? host.getService(messageQueueHandle) : undefined; - }, - resolveCompaction: () => { - // Lazily resolve the compaction service (registered below after - // the orchestrator). By the time this is called at runtime - // (after a turn settles), the service is registered. - try { - return host.getService(compactionHandle); - } catch { - return undefined; - } - }, - resolveSystemPrompt: () => { - // Lazily resolve the system-prompt service. Returns undefined when - // the system-prompt extension isn't loaded (no system prompt sent — - // current behavior). Lazy so activation order with system-prompt - // doesn't matter; called per-turn / per-compaction, not at activate. - try { - return host.getService(systemPromptHandle); - } catch { - return undefined; - } - }, - }); + const { orchestrator, activeConversations } = createSessionOrchestrator({ + conversationStore, + resolveProvider: () => selectFirstProvider(host.getProviders()), + resolveTools: () => [...host.getTools().values()], + resolveModel: (modelName: string) => { + const store = host.getService(credentialStoreHandle); + const r = store.resolve(modelName); + if (r === undefined) return undefined; + const provider = host.getProviders().get(r.providerId); + return provider ? { provider, model: r.model } : undefined; + }, + resolveModelInfo: async (modelName: string) => { + const store = host.getService(credentialStoreHandle); + return store.getModelInfo(modelName); + }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), + runTurn, + logger: host.logger, + now: () => Date.now(), + emit: (hook, payload) => host.emit(hook, payload), + resolveQueue: () => { + // Lazily resolve the message-queue service. Returns undefined when the + // extension isn't loaded (feature degrades off) — checked via the + // activated-manifests list so `host.getService` is only called when the + // service is registered. Lazy so activation order with message-queue + // doesn't matter; called per-turn / per-enqueue, not at activate time. + const loaded = host.getExtensions().some((m) => m.id === "message-queue"); + return loaded ? host.getService(messageQueueHandle) : undefined; + }, + resolveCompaction: () => { + // Lazily resolve the compaction service (registered below after + // the orchestrator). By the time this is called at runtime + // (after a turn settles), the service is registered. + try { + return host.getService(compactionHandle); + } catch { + return undefined; + } + }, + resolveSystemPrompt: () => { + // Lazily resolve the system-prompt service. Returns undefined when + // the system-prompt extension isn't loaded (no system prompt sent — + // current behavior). Lazy so activation order with system-prompt + // doesn't matter; called per-turn / per-compaction, not at activate. + try { + return host.getService(systemPromptHandle); + } catch { + return undefined; + } + }, + }); - host.provideService(sessionOrchestratorHandle, orchestrator); + host.provideService(sessionOrchestratorHandle, orchestrator); - // Remote-degradation rule (plan §6): when a turn is REMOTE - // (`assembly.computerId !== undefined`), drop tools that spawn local - // processes and cannot run over SFTP — the `lsp` tool (local LSP servers) - // and MCP-namespaced tools (`__`, local MCP servers). - // When LOCAL (`computerId === undefined`), the filter is a passthrough — - // byte-identical to today. Registered at default priority (0) with - // activation-order tie-breaking: session-orchestrator activates before - // MCP (which dependsOn it), so this runs FIRST in the chain — the drops - // happen before MCP's filter connects/registers servers. Mirrors how MCP - // adds its own filter via host.addFilter. - host.addFilter(toolsFilter, filterRemoteIncompatibleTools); + // Remote-degradation rule (plan §6): when a turn is REMOTE + // (`assembly.computerId !== undefined`), drop tools that spawn local + // processes and cannot run over SFTP — the `lsp` tool (local LSP servers) + // and MCP-namespaced tools (`__`, local MCP servers). + // When LOCAL (`computerId === undefined`), the filter is a passthrough — + // byte-identical to today. Registered at default priority (0) with + // activation-order tie-breaking: session-orchestrator activates before + // MCP (which dependsOn it), so this runs FIRST in the chain — the drops + // happen before MCP's filter connects/registers servers. Mirrors how MCP + // adds its own filter via host.addFilter. + host.addFilter(toolsFilter, filterRemoteIncompatibleTools); - const warmService = createWarmService( - { - conversationStore, - resolveProvider: () => selectFirstProvider(host.getProviders()), - resolveTools: () => [...host.getTools().values()], - resolveModel: (modelName: string) => { - const store = host.getService(credentialStoreHandle); - const r = store.resolve(modelName); - if (r === undefined) return undefined; - const provider = host.getProviders().get(r.providerId); - return provider ? { provider, model: r.model } : undefined; - }, - applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), - runTurn, - logger: host.logger, - now: () => Date.now(), - emit: (hook, payload) => host.emit(hook, payload), - }, - activeConversations, - ); + const warmService = createWarmService( + { + conversationStore, + resolveProvider: () => selectFirstProvider(host.getProviders()), + resolveTools: () => [...host.getTools().values()], + resolveModel: (modelName: string) => { + const store = host.getService(credentialStoreHandle); + const r = store.resolve(modelName); + if (r === undefined) return undefined; + const provider = host.getProviders().get(r.providerId); + return provider ? { provider, model: r.model } : undefined; + }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), + runTurn, + logger: host.logger, + now: () => Date.now(), + emit: (hook, payload) => host.emit(hook, payload), + }, + activeConversations, + ); - host.provideService(cacheWarmHandle, warmService); + host.provideService(cacheWarmHandle, warmService); - const compactionService = createCompactionService( - { - conversationStore, - resolveProvider: () => selectFirstProvider(host.getProviders()), - resolveTools: () => [...host.getTools().values()], - resolveModel: (modelName: string) => { - const store = host.getService(credentialStoreHandle); - const r = store.resolve(modelName); - if (r === undefined) return undefined; - const provider = host.getProviders().get(r.providerId); - return provider ? { provider, model: r.model } : undefined; - }, - resolveModelInfo: async (modelName: string) => { - const store = host.getService(credentialStoreHandle); - return store.getModelInfo(modelName); - }, - resolveSystemPrompt: () => { - try { - return host.getService(systemPromptHandle); - } catch { - return undefined; - } - }, - applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), - runTurn, - logger: host.logger, - now: () => Date.now(), - emit: (hook, payload) => host.emit(hook, payload), - }, - activeConversations, - ); + const compactionService = createCompactionService( + { + conversationStore, + resolveProvider: () => selectFirstProvider(host.getProviders()), + resolveTools: () => [...host.getTools().values()], + resolveModel: (modelName: string) => { + const store = host.getService(credentialStoreHandle); + const r = store.resolve(modelName); + if (r === undefined) return undefined; + const provider = host.getProviders().get(r.providerId); + return provider ? { provider, model: r.model } : undefined; + }, + resolveModelInfo: async (modelName: string) => { + const store = host.getService(credentialStoreHandle); + return store.getModelInfo(modelName); + }, + resolveSystemPrompt: () => { + try { + return host.getService(systemPromptHandle); + } catch { + return undefined; + } + }, + applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), + runTurn, + logger: host.logger, + now: () => Date.now(), + emit: (hook, payload) => host.emit(hook, payload), + }, + activeConversations, + ); - host.provideService(compactionHandle, compactionService); + host.provideService(compactionHandle, compactionService); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index aaafb76..cc41fa8 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -1,48 +1,48 @@ export { extension, manifest } from "./extension.js"; export { - type CompactionService, - type ConversationClosedPayload, - type ConversationCompactedPayload, - type ConversationOpenedPayload, - type ConversationStatusChangedPayload, - cacheWarmHandle, - compactionHandle, - conversationClosed, - conversationCompacted, - conversationOpened, - conversationStatusChanged, - createCompactionService, - createRetryStrategy, - createSessionOrchestrator, - createWarmService, - type EnqueueInput, - type EnqueueResult, - type SessionOrchestrator, - type SessionOrchestratorBundle, - type SessionOrchestratorDeps, - type StartTurnInput, - type StartTurnResult, - sessionOrchestratorHandle, - type TurnEventListener, - type TurnLifecyclePayload, - turnSettled, - turnStarted, - type WarmCompletedPayload, - type WarmResult, - type WarmService, - type WarmServiceDeps, - warmCompleted, + type CompactionService, + type ConversationClosedPayload, + type ConversationCompactedPayload, + type ConversationOpenedPayload, + type ConversationStatusChangedPayload, + cacheWarmHandle, + compactionHandle, + conversationClosed, + conversationCompacted, + conversationOpened, + conversationStatusChanged, + createCompactionService, + createRetryStrategy, + createSessionOrchestrator, + createWarmService, + type EnqueueInput, + type EnqueueResult, + type SessionOrchestrator, + type SessionOrchestratorBundle, + type SessionOrchestratorDeps, + type StartTurnInput, + type StartTurnResult, + sessionOrchestratorHandle, + type TurnEventListener, + type TurnLifecyclePayload, + turnSettled, + turnStarted, + type WarmCompletedPayload, + type WarmResult, + type WarmService, + type WarmServiceDeps, + warmCompleted, } from "./orchestrator.js"; export { - buildUserMessage, - cumulativeSleepMs, - defaultDispatchPolicy, - delayFor, - generateTurnId, - RETRY_BUDGET_MS, - RETRY_SCHEDULE_MS, - RETRY_TAIL_MS, - resolveReasoningEffort, - selectFirstProvider, + buildUserMessage, + cumulativeSleepMs, + defaultDispatchPolicy, + delayFor, + generateTurnId, + RETRY_BUDGET_MS, + RETRY_SCHEDULE_MS, + RETRY_TAIL_MS, + resolveReasoningEffort, + selectFirstProvider, } from "./pure.js"; export { type ToolAssembly, toolsFilter } from "./tools-filter.js"; diff --git a/packages/session-orchestrator/src/metrics.test.ts b/packages/session-orchestrator/src/metrics.test.ts index 1920fc0..2ecaae8 100644 --- a/packages/session-orchestrator/src/metrics.test.ts +++ b/packages/session-orchestrator/src/metrics.test.ts @@ -3,367 +3,367 @@ import { describe, expect, it } from "vitest"; import { createMetricsAccumulator } from "./metrics.js"; function stepId(id: string): StepId { - return id as StepId; + return id as StepId; } describe("createMetricsAccumulator", () => { - it("builds a TurnMetrics from a single-step turn", () => { - const acc = createMetricsAccumulator(); - - const usageEvent: AgentEvent = { - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }; - const stepCompleteEvent: AgentEvent = { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - ttftMs: 50, - decodeMs: 150, - genTotalMs: 200, - }; - const doneEvent: AgentEvent = { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - durationMs: 500, - usage: { inputTokens: 10, outputTokens: 5 }, - }; - - acc.ingest(usageEvent); - acc.ingest(stepCompleteEvent); - acc.ingest(doneEvent); - - const tm = acc.build("t1"); - expect(tm.turnId).toBe("t1"); - expect(tm.usage.inputTokens).toBe(10); - expect(tm.usage.outputTokens).toBe(5); - expect(tm.durationMs).toBe(500); - expect(tm.steps).toHaveLength(1); - expect(tm.steps[0]?.stepId).toBe(stepId("t1#0")); - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[0]?.usage.outputTokens).toBe(5); - expect(tm.steps[0]?.ttftMs).toBe(50); - expect(tm.steps[0]?.decodeMs).toBe(150); - expect(tm.steps[0]?.genTotalMs).toBe(200); - }); - - it("aggregates multi-step usage and preserves step order", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - genTotalMs: 100, - }); - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#1"), - usage: { inputTokens: 20, outputTokens: 10 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#1"), - genTotalMs: 150, - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 30, outputTokens: 15 }, - }); - - const tm = acc.build("t1"); - expect(tm.steps).toHaveLength(2); - expect(tm.steps[0]?.stepId).toBe(stepId("t1#0")); - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[1]?.stepId).toBe(stepId("t1#1")); - expect(tm.steps[1]?.usage.inputTokens).toBe(20); - expect(tm.usage.inputTokens).toBe(30); - expect(tm.usage.outputTokens).toBe(15); - }); - - it("joins per-step timing and usage by stepId", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - ttftMs: 50, - decodeMs: 100, - genTotalMs: 150, - }); - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - - const tm = acc.build("t1"); - expect(tm.steps).toHaveLength(1); - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[0]?.ttftMs).toBe(50); - expect(tm.steps[0]?.decodeMs).toBe(100); - expect(tm.steps[0]?.genTotalMs).toBe(150); - }); - - it("turn-level usage comes from done event, not step sum", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 100, outputTokens: 50 }, - }); - - const tm = acc.build("t1"); - expect(tm.usage.inputTokens).toBe(100); - expect(tm.usage.outputTokens).toBe(50); - }); - - it("falls back to summing step usage when done.usage is absent", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - }); - - const tm = acc.build("t1"); - expect(tm.usage.inputTokens).toBe(10); - expect(tm.usage.outputTokens).toBe(5); - }); - - it("tolerates missing usage on a step (timing only)", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - genTotalMs: 200, - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - }); - - const tm = acc.build("t1"); - expect(tm.steps).toHaveLength(1); - expect(tm.steps[0]?.usage.inputTokens).toBe(0); - expect(tm.steps[0]?.usage.outputTokens).toBe(0); - expect(tm.steps[0]?.genTotalMs).toBe(200); - }); - - it("tolerates missing timing on a step (usage only)", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - }); - - const tm = acc.build("t1"); - expect(tm.steps).toHaveLength(1); - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[0]?.ttftMs).toBeUndefined(); - expect(tm.steps[0]?.decodeMs).toBeUndefined(); - expect(tm.steps[0]?.genTotalMs).toBeUndefined(); - }); - - it("reset clears all accumulated state", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 10, outputTokens: 5 }, - }); - - acc.reset(); - - const tm = acc.build("t2"); - expect(tm.steps).toHaveLength(0); - expect(tm.usage.inputTokens).toBe(0); - expect(tm.usage.outputTokens).toBe(0); - }); - - it("contextSize equals inputTokens + outputTokens for a single-step turn", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 10, outputTokens: 5 }, - }); - - const tm = acc.build("t1"); - expect(tm.contextSize).toBe(15); - }); - - it("contextSize equals ONLY the last step's inputTokens + outputTokens for a multi-step turn", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - usage: { inputTokens: 10, outputTokens: 5 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - }); - acc.ingest({ - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#1"), - usage: { inputTokens: 20, outputTokens: 10 }, - }); - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#1"), - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 100, outputTokens: 50 }, - }); - - const tm = acc.build("t1"); - expect(tm.contextSize).toBe(30); - expect(tm.contextSize).not.toBe(tm.usage.inputTokens); - }); - - it("contextSize is undefined when the turn has no steps", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - }); - - const tm = acc.build("t1"); - expect(tm.contextSize).toBeUndefined(); - }); - - it("contextSize is undefined when the last step has no usable per-step usage", () => { - const acc = createMetricsAccumulator(); - - acc.ingest({ - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: stepId("t1#0"), - genTotalMs: 200, - }); - acc.ingest({ - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - }); - - const tm = acc.build("t1"); - expect(tm.contextSize).toBeUndefined(); - }); + it("builds a TurnMetrics from a single-step turn", () => { + const acc = createMetricsAccumulator(); + + const usageEvent: AgentEvent = { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }; + const stepCompleteEvent: AgentEvent = { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + ttftMs: 50, + decodeMs: 150, + genTotalMs: 200, + }; + const doneEvent: AgentEvent = { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + durationMs: 500, + usage: { inputTokens: 10, outputTokens: 5 }, + }; + + acc.ingest(usageEvent); + acc.ingest(stepCompleteEvent); + acc.ingest(doneEvent); + + const tm = acc.build("t1"); + expect(tm.turnId).toBe("t1"); + expect(tm.usage.inputTokens).toBe(10); + expect(tm.usage.outputTokens).toBe(5); + expect(tm.durationMs).toBe(500); + expect(tm.steps).toHaveLength(1); + expect(tm.steps[0]?.stepId).toBe(stepId("t1#0")); + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[0]?.usage.outputTokens).toBe(5); + expect(tm.steps[0]?.ttftMs).toBe(50); + expect(tm.steps[0]?.decodeMs).toBe(150); + expect(tm.steps[0]?.genTotalMs).toBe(200); + }); + + it("aggregates multi-step usage and preserves step order", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + genTotalMs: 100, + }); + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + usage: { inputTokens: 20, outputTokens: 10 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + genTotalMs: 150, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 30, outputTokens: 15 }, + }); + + const tm = acc.build("t1"); + expect(tm.steps).toHaveLength(2); + expect(tm.steps[0]?.stepId).toBe(stepId("t1#0")); + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[1]?.stepId).toBe(stepId("t1#1")); + expect(tm.steps[1]?.usage.inputTokens).toBe(20); + expect(tm.usage.inputTokens).toBe(30); + expect(tm.usage.outputTokens).toBe(15); + }); + + it("joins per-step timing and usage by stepId", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + ttftMs: 50, + decodeMs: 100, + genTotalMs: 150, + }); + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + + const tm = acc.build("t1"); + expect(tm.steps).toHaveLength(1); + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[0]?.ttftMs).toBe(50); + expect(tm.steps[0]?.decodeMs).toBe(100); + expect(tm.steps[0]?.genTotalMs).toBe(150); + }); + + it("turn-level usage comes from done event, not step sum", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 100, outputTokens: 50 }, + }); + + const tm = acc.build("t1"); + expect(tm.usage.inputTokens).toBe(100); + expect(tm.usage.outputTokens).toBe(50); + }); + + it("falls back to summing step usage when done.usage is absent", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.usage.inputTokens).toBe(10); + expect(tm.usage.outputTokens).toBe(5); + }); + + it("tolerates missing usage on a step (timing only)", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + genTotalMs: 200, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.steps).toHaveLength(1); + expect(tm.steps[0]?.usage.inputTokens).toBe(0); + expect(tm.steps[0]?.usage.outputTokens).toBe(0); + expect(tm.steps[0]?.genTotalMs).toBe(200); + }); + + it("tolerates missing timing on a step (usage only)", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.steps).toHaveLength(1); + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[0]?.ttftMs).toBeUndefined(); + expect(tm.steps[0]?.decodeMs).toBeUndefined(); + expect(tm.steps[0]?.genTotalMs).toBeUndefined(); + }); + + it("reset clears all accumulated state", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 10, outputTokens: 5 }, + }); + + acc.reset(); + + const tm = acc.build("t2"); + expect(tm.steps).toHaveLength(0); + expect(tm.usage.inputTokens).toBe(0); + expect(tm.usage.outputTokens).toBe(0); + }); + + it("contextSize equals inputTokens + outputTokens for a single-step turn", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 10, outputTokens: 5 }, + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBe(15); + }); + + it("contextSize equals ONLY the last step's inputTokens + outputTokens for a multi-step turn", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + usage: { inputTokens: 20, outputTokens: 10 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 100, outputTokens: 50 }, + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBe(30); + expect(tm.contextSize).not.toBe(tm.usage.inputTokens); + }); + + it("contextSize is undefined when the turn has no steps", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBeUndefined(); + }); + + it("contextSize is undefined when the last step has no usable per-step usage", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + genTotalMs: 200, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBeUndefined(); + }); }); diff --git a/packages/session-orchestrator/src/metrics.ts b/packages/session-orchestrator/src/metrics.ts index 2dfa533..c0f9566 100644 --- a/packages/session-orchestrator/src/metrics.ts +++ b/packages/session-orchestrator/src/metrics.ts @@ -1,138 +1,138 @@ import type { - AgentEvent, - StepId, - StepMetrics, - TurnDoneEvent, - TurnMetrics, - TurnStepCompleteEvent, - TurnUsageEvent, - Usage, + AgentEvent, + StepId, + StepMetrics, + TurnDoneEvent, + TurnMetrics, + TurnStepCompleteEvent, + TurnUsageEvent, + Usage, } from "@dispatch/kernel"; const zeroUsage: Usage = { inputTokens: 0, outputTokens: 0 }; interface StepAccumulator { - readonly stepId: StepId; - usage: Usage | undefined; - ttftMs: number | undefined; - decodeMs: number | undefined; - genTotalMs: number | undefined; + readonly stepId: StepId; + usage: Usage | undefined; + ttftMs: number | undefined; + decodeMs: number | undefined; + genTotalMs: number | undefined; } export interface MetricsAccumulator { - readonly ingest: (event: AgentEvent) => void; - readonly build: (turnId: string) => TurnMetrics; - readonly reset: () => void; + readonly ingest: (event: AgentEvent) => void; + readonly build: (turnId: string) => TurnMetrics; + readonly reset: () => void; } export function createMetricsAccumulator(): MetricsAccumulator { - const steps = new Map(); - const stepOrder: StepId[] = []; - let doneUsage: Usage | undefined; - let doneDurationMs: number | undefined; + const steps = new Map(); + const stepOrder: StepId[] = []; + let doneUsage: Usage | undefined; + let doneDurationMs: number | undefined; - function getOrCreateStep(stepId: StepId): StepAccumulator { - let acc = steps.get(stepId); - if (acc === undefined) { - acc = { - stepId, - usage: undefined, - ttftMs: undefined, - decodeMs: undefined, - genTotalMs: undefined, - }; - steps.set(stepId, acc); - stepOrder.push(stepId); - } - return acc; - } + function getOrCreateStep(stepId: StepId): StepAccumulator { + let acc = steps.get(stepId); + if (acc === undefined) { + acc = { + stepId, + usage: undefined, + ttftMs: undefined, + decodeMs: undefined, + genTotalMs: undefined, + }; + steps.set(stepId, acc); + stepOrder.push(stepId); + } + return acc; + } - function ingest(event: AgentEvent): void { - switch (event.type) { - case "usage": { - const e = event as TurnUsageEvent; - if (e.stepId !== undefined) { - const acc = getOrCreateStep(e.stepId); - acc.usage = e.usage; - } - break; - } - case "step-complete": { - const e = event as TurnStepCompleteEvent; - const acc = getOrCreateStep(e.stepId); - acc.ttftMs = e.ttftMs; - acc.decodeMs = e.decodeMs; - acc.genTotalMs = e.genTotalMs; - break; - } - case "done": { - const e = event as TurnDoneEvent; - doneUsage = e.usage; - doneDurationMs = e.durationMs; - break; - } - } - } + function ingest(event: AgentEvent): void { + switch (event.type) { + case "usage": { + const e = event as TurnUsageEvent; + if (e.stepId !== undefined) { + const acc = getOrCreateStep(e.stepId); + acc.usage = e.usage; + } + break; + } + case "step-complete": { + const e = event as TurnStepCompleteEvent; + const acc = getOrCreateStep(e.stepId); + acc.ttftMs = e.ttftMs; + acc.decodeMs = e.decodeMs; + acc.genTotalMs = e.genTotalMs; + break; + } + case "done": { + const e = event as TurnDoneEvent; + doneUsage = e.usage; + doneDurationMs = e.durationMs; + break; + } + } + } - function build(turnId: string): TurnMetrics { - const stepMetrics: StepMetrics[] = stepOrder.map((stepId) => { - const acc = steps.get(stepId); - if (acc === undefined) { - return { stepId, usage: zeroUsage }; - } - const usage = acc.usage ?? zeroUsage; - const sm: StepMetrics = { stepId, usage }; - if (acc.ttftMs !== undefined) { - (sm as { ttftMs?: number }).ttftMs = acc.ttftMs; - } - if (acc.decodeMs !== undefined) { - (sm as { decodeMs?: number }).decodeMs = acc.decodeMs; - } - if (acc.genTotalMs !== undefined) { - (sm as { genTotalMs?: number }).genTotalMs = acc.genTotalMs; - } - return sm; - }); + function build(turnId: string): TurnMetrics { + const stepMetrics: StepMetrics[] = stepOrder.map((stepId) => { + const acc = steps.get(stepId); + if (acc === undefined) { + return { stepId, usage: zeroUsage }; + } + const usage = acc.usage ?? zeroUsage; + const sm: StepMetrics = { stepId, usage }; + if (acc.ttftMs !== undefined) { + (sm as { ttftMs?: number }).ttftMs = acc.ttftMs; + } + if (acc.decodeMs !== undefined) { + (sm as { decodeMs?: number }).decodeMs = acc.decodeMs; + } + if (acc.genTotalMs !== undefined) { + (sm as { genTotalMs?: number }).genTotalMs = acc.genTotalMs; + } + return sm; + }); - const aggregateUsage = doneUsage ?? sumStepUsage(stepMetrics); + const aggregateUsage = doneUsage ?? sumStepUsage(stepMetrics); - const tm: TurnMetrics = { turnId, usage: aggregateUsage, steps: stepMetrics }; - if (doneDurationMs !== undefined) { - (tm as { durationMs?: number }).durationMs = doneDurationMs; - } + const tm: TurnMetrics = { turnId, usage: aggregateUsage, steps: stepMetrics }; + if (doneDurationMs !== undefined) { + (tm as { durationMs?: number }).durationMs = doneDurationMs; + } - // contextSize = final step's inputTokens + outputTokens (true context occupancy). - // Omit when no steps or the last step had no usable per-step usage event. - if (stepMetrics.length > 0) { - const lastStep = stepMetrics[stepMetrics.length - 1]; - if (lastStep !== undefined) { - const lastAcc = steps.get(lastStep.stepId); - if (lastAcc?.usage !== undefined) { - (tm as { contextSize?: number }).contextSize = - lastStep.usage.inputTokens + lastStep.usage.outputTokens; - } - } - } + // contextSize = final step's inputTokens + outputTokens (true context occupancy). + // Omit when no steps or the last step had no usable per-step usage event. + if (stepMetrics.length > 0) { + const lastStep = stepMetrics[stepMetrics.length - 1]; + if (lastStep !== undefined) { + const lastAcc = steps.get(lastStep.stepId); + if (lastAcc?.usage !== undefined) { + (tm as { contextSize?: number }).contextSize = + lastStep.usage.inputTokens + lastStep.usage.outputTokens; + } + } + } - return tm; - } + return tm; + } - function reset(): void { - steps.clear(); - stepOrder.length = 0; - doneUsage = undefined; - doneDurationMs = undefined; - } + function reset(): void { + steps.clear(); + stepOrder.length = 0; + doneUsage = undefined; + doneDurationMs = undefined; + } - return { ingest, build, reset }; + return { ingest, build, reset }; } function sumStepUsage(steps: readonly StepMetrics[]): Usage { - let inputTokens = 0; - let outputTokens = 0; - for (const s of steps) { - inputTokens += s.usage.inputTokens; - outputTokens += s.usage.outputTokens; - } - return { inputTokens, outputTokens }; + let inputTokens = 0; + let outputTokens = 0; + for (const s of steps) { + inputTokens += s.usage.inputTokens; + outputTokens += s.usage.outputTokens; + } + return { inputTokens, outputTokens }; } diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 654c0c3..076ad51 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -1,3982 +1,3982 @@ import { resolve as pathResolve } from "node:path"; import type { ConversationStore } from "@dispatch/conversation-store"; import type { - AgentEvent, - ChatMessage, - EventHookDescriptor, - Logger, - ProviderContract, - ProviderEvent, - ProviderStreamOptions, - ReasoningEffort, - RunTurnInput, - RunTurnResult, - StoredChunk, - ToolContract, - TurnMetrics, + AgentEvent, + ChatMessage, + EventHookDescriptor, + Logger, + ProviderContract, + ProviderEvent, + ProviderStreamOptions, + ReasoningEffort, + RunTurnInput, + RunTurnResult, + StoredChunk, + ToolContract, + TurnMetrics, } from "@dispatch/kernel"; import { runTurn } from "@dispatch/kernel"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { describe, expect, it } from "vitest"; import { - type ConversationOpenedPayload, - type ConversationStatusChangedPayload, - createCompactionService, - createSessionOrchestrator, - createWarmService, - type TurnLifecyclePayload, - type WarmCompletedPayload, + type ConversationOpenedPayload, + type ConversationStatusChangedPayload, + createCompactionService, + createSessionOrchestrator, + createWarmService, + type TurnLifecyclePayload, + type WarmCompletedPayload, } from "./orchestrator.js"; import type { ToolAssembly } from "./tools-filter.js"; function createInMemoryStore(): ConversationStore & { - readonly data: Map; - readonly metricsData: Map; - readonly cwdData: Map; - readonly computerData: Map; - readonly effortData: Map; - readonly modelData: Map; - readonly workspaceIdData: Map; + readonly data: Map; + readonly metricsData: Map; + readonly cwdData: Map; + readonly computerData: Map; + readonly effortData: Map; + readonly modelData: Map; + readonly workspaceIdData: Map; } { - const data = new Map(); - const metricsData = new Map(); - const cwdData = new Map(); - const computerData = new Map(); - const effortData = new Map(); - const modelData = new Map(); - const workspaceIdData = new Map(); - // Track conversations that have a meta row. In the real store, append, - // setWorkspaceId, setConversationStatus, setConversationTitle, and - // setCompactedFrom all create a minimal meta row on first contact. - // getConversationMeta returns non-null for known conversations so the - // orchestrator's newness detection (meta === null) matches reality. - const knownConversations = new Set(); - return { - data, - metricsData, - cwdData, - computerData, - effortData, - modelData, - workspaceIdData, - async append(conversationId, messages) { - knownConversations.add(conversationId); - const existing = data.get(conversationId) ?? []; - data.set(conversationId, [...existing, ...messages]); - }, - async load(conversationId) { - return [...(data.get(conversationId) ?? [])]; - }, - async loadSince(conversationId, sinceSeq) { - const messages = data.get(conversationId) ?? []; - const result: StoredChunk[] = []; - let seq = 1; - for (const msg of messages) { - for (const chunk of msg.chunks) { - if (sinceSeq === undefined || seq > sinceSeq) { - result.push({ seq, role: msg.role, chunk }); - } - seq++; - } - } - return result; - }, - async appendMetrics(conversationId, metrics) { - const existing = metricsData.get(conversationId) ?? []; - metricsData.set(conversationId, [...existing, metrics]); - }, - async loadMetrics(conversationId) { - return [...(metricsData.get(conversationId) ?? [])]; - }, - async getCwd(conversationId) { - return cwdData.get(conversationId) ?? null; - }, - async setCwd(conversationId, cwd) { - cwdData.set(conversationId, cwd); - }, - async clearCwd(conversationId) { - cwdData.delete(conversationId); - }, - async getComputerId(conversationId) { - return computerData.get(conversationId) ?? null; - }, - async setComputerId(conversationId, alias) { - if (alias === null) { - computerData.delete(conversationId); - } else { - computerData.set(conversationId, alias); - } - }, - async clearComputerId(conversationId) { - computerData.delete(conversationId); - }, - async getReasoningEffort(conversationId) { - return effortData.get(conversationId) ?? null; - }, - async setReasoningEffort(conversationId, effort) { - effortData.set(conversationId, effort); - }, - async getModel(conversationId) { - return modelData.get(conversationId) ?? null; - }, - async setModel(conversationId, model) { - // Mirror the real store contract: an empty string clears the key. - if (model === "") { - modelData.delete(conversationId); - } else { - modelData.set(conversationId, model); - } - }, - async listConversations() { - return []; - }, - async getConversationMeta(conversationId) { - if (!knownConversations.has(conversationId)) return null; - return { - id: conversationId, - createdAt: 0, - lastActivityAt: 0, - title: "Untitled", - status: "idle", - workspaceId: workspaceIdData.get(conversationId) ?? "default", - }; - }, - async setConversationTitle(conversationId) { - knownConversations.add(conversationId); - }, - async getConversationStatus() { - return null; - }, - async setConversationStatus(conversationId) { - knownConversations.add(conversationId); - }, - async replaceHistory(conversationId, messages) { - knownConversations.add(conversationId); - data.set(conversationId, [...messages]); - }, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory(_sourceId, targetId) { - knownConversations.add(targetId); - }, - async setCompactedFrom(conversationId) { - knownConversations.add(conversationId); - }, - async getWorkspace() { - return null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle(id, title) { - return { - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId(conversationId) { - return workspaceIdData.get(conversationId) ?? "default"; - }, - async setWorkspaceId(conversationId, workspaceId) { - workspaceIdData.set(conversationId, workspaceId); - knownConversations.add(conversationId); - }, - async getEffectiveCwd(conversationId, overrideCwd) { - return overrideCwd ?? cwdData.get(conversationId) ?? null; - }, - async getEffectiveComputer(conversationId, overrideAlias) { - return overrideAlias ?? computerData.get(conversationId) ?? null; - }, - }; + const data = new Map(); + const metricsData = new Map(); + const cwdData = new Map(); + const computerData = new Map(); + const effortData = new Map(); + const modelData = new Map(); + const workspaceIdData = new Map(); + // Track conversations that have a meta row. In the real store, append, + // setWorkspaceId, setConversationStatus, setConversationTitle, and + // setCompactedFrom all create a minimal meta row on first contact. + // getConversationMeta returns non-null for known conversations so the + // orchestrator's newness detection (meta === null) matches reality. + const knownConversations = new Set(); + return { + data, + metricsData, + cwdData, + computerData, + effortData, + modelData, + workspaceIdData, + async append(conversationId, messages) { + knownConversations.add(conversationId); + const existing = data.get(conversationId) ?? []; + data.set(conversationId, [...existing, ...messages]); + }, + async load(conversationId) { + return [...(data.get(conversationId) ?? [])]; + }, + async loadSince(conversationId, sinceSeq) { + const messages = data.get(conversationId) ?? []; + const result: StoredChunk[] = []; + let seq = 1; + for (const msg of messages) { + for (const chunk of msg.chunks) { + if (sinceSeq === undefined || seq > sinceSeq) { + result.push({ seq, role: msg.role, chunk }); + } + seq++; + } + } + return result; + }, + async appendMetrics(conversationId, metrics) { + const existing = metricsData.get(conversationId) ?? []; + metricsData.set(conversationId, [...existing, metrics]); + }, + async loadMetrics(conversationId) { + return [...(metricsData.get(conversationId) ?? [])]; + }, + async getCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdData.set(conversationId, cwd); + }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId(conversationId) { + return computerData.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerData.delete(conversationId); + } else { + computerData.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerData.delete(conversationId); + }, + async getReasoningEffort(conversationId) { + return effortData.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + effortData.set(conversationId, effort); + }, + async getModel(conversationId) { + return modelData.get(conversationId) ?? null; + }, + async setModel(conversationId, model) { + // Mirror the real store contract: an empty string clears the key. + if (model === "") { + modelData.delete(conversationId); + } else { + modelData.set(conversationId, model); + } + }, + async listConversations() { + return []; + }, + async getConversationMeta(conversationId) { + if (!knownConversations.has(conversationId)) return null; + return { + id: conversationId, + createdAt: 0, + lastActivityAt: 0, + title: "Untitled", + status: "idle", + workspaceId: workspaceIdData.get(conversationId) ?? "default", + }; + }, + async setConversationTitle(conversationId) { + knownConversations.add(conversationId); + }, + async getConversationStatus() { + return null; + }, + async setConversationStatus(conversationId) { + knownConversations.add(conversationId); + }, + async replaceHistory(conversationId, messages) { + knownConversations.add(conversationId); + data.set(conversationId, [...messages]); + }, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory(_sourceId, targetId) { + knownConversations.add(targetId); + }, + async setCompactedFrom(conversationId) { + knownConversations.add(conversationId); + }, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle(id, title) { + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId(conversationId) { + return workspaceIdData.get(conversationId) ?? "default"; + }, + async setWorkspaceId(conversationId, workspaceId) { + workspaceIdData.set(conversationId, workspaceId); + knownConversations.add(conversationId); + }, + async getEffectiveCwd(conversationId, overrideCwd) { + return overrideCwd ?? cwdData.get(conversationId) ?? null; + }, + async getEffectiveComputer(conversationId, overrideAlias) { + return overrideAlias ?? computerData.get(conversationId) ?? null; + }, + }; } function createFakeProvider(script: ProviderEvent[][]): ProviderContract { - let callIndex = 0; - return { - id: "fake", - stream(_messages, _tools) { - const events = script[callIndex] ?? []; - callIndex++; - return (async function* () { - for (const event of events) { - yield event; - } - })(); - }, - }; + let callIndex = 0; + return { + id: "fake", + stream(_messages, _tools) { + const events = script[callIndex] ?? []; + callIndex++; + return (async function* () { + for (const event of events) { + yield event; + } + })(); + }, + }; } function collectEvents(): { events: AgentEvent[]; onEvent: (event: AgentEvent) => void } { - const events: AgentEvent[] = []; - return { events, onEvent: (event) => events.push(event) }; + const events: AgentEvent[] = []; + return { events, onEvent: (event) => events.push(event) }; } function createFakeTool( - name: string, - handler: (input: unknown) => Promise<{ content: string }>, + name: string, + handler: (input: unknown) => Promise<{ content: string }>, ): ToolContract { - return { - name, - description: `Fake tool: ${name}`, - parameters: { type: "object" }, - execute: async (input) => handler(input), - }; + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + execute: async (input) => handler(input), + }; } function identityApplyToolsFilter(assembly: ToolAssembly): Promise { - return Promise.resolve(assembly); + return Promise.resolve(assembly); } describe("handleMessage integration", () => { - it("loads history, runs turn, emits events, and persists result", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " there" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const { events, onEvent } = collectEvents(); - - await orchestrator.handleMessage({ - conversationId: "conv-1", - text: "Hi", - onEvent, - }); - - expect(events.length).toBeGreaterThan(0); - const textDeltas = events.filter((e) => e.type === "text-delta"); - expect(textDeltas).toHaveLength(2); - - const stored = store.data.get("conv-1"); - expect(stored).toBeDefined(); - expect(stored).toHaveLength(2); - expect(stored?.[0]?.role).toBe("user"); - expect(stored?.[1]?.role).toBe("assistant"); - - const userChunks = stored?.[0]?.chunks ?? []; - expect(userChunks[0]).toEqual({ type: "text", text: "Hi" }); - - const assistantChunks = stored?.[1]?.chunks ?? []; - expect(assistantChunks.some((c) => c.type === "text")).toBe(true); - }); - - it("multi-turn: second call sees first turn in history", async () => { - const store = createInMemoryStore(); - let capturedMessages: ChatMessage[] | undefined; - - let callCount = 0; - const provider: ProviderContract = { - id: "fake", - stream(messages, _tools) { - if (callCount === 1) { - capturedMessages = [...messages]; - } - callCount++; - return (async function* () { - yield { type: "text-delta", delta: `Reply ${callCount}` } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-multi", - text: "First message", - onEvent: () => {}, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-multi", - text: "Second message", - onEvent: () => {}, - }); - - expect(capturedMessages).toBeDefined(); - expect(capturedMessages?.length).toBeGreaterThanOrEqual(3); - - expect(capturedMessages?.[0]?.role).toBe("user"); - const firstUserText = capturedMessages?.[0]?.chunks[0]; - expect(firstUserText).toEqual({ type: "text", text: "First message" }); - - expect(capturedMessages?.[1]?.role).toBe("assistant"); - - const lastUser = capturedMessages?.findLast((m) => m.role === "user"); - expect(lastUser).toBeDefined(); - const lastUserText = lastUser?.chunks[0]; - expect(lastUserText).toEqual({ type: "text", text: "Second message" }); - }); - - it("uses custom dispatch policy when resolveDispatch is provided", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - resolveDispatch: () => ({ maxConcurrent: 4, eager: false }), - runTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-dispatch", - text: "test", - onEvent: () => {}, - }); - - const stored = store.data.get("conv-dispatch"); - expect(stored).toBeDefined(); - expect(stored?.length).toBeGreaterThanOrEqual(1); - }); + it("loads history, runs turn, emits events, and persists result", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " there" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const { events, onEvent } = collectEvents(); + + await orchestrator.handleMessage({ + conversationId: "conv-1", + text: "Hi", + onEvent, + }); + + expect(events.length).toBeGreaterThan(0); + const textDeltas = events.filter((e) => e.type === "text-delta"); + expect(textDeltas).toHaveLength(2); + + const stored = store.data.get("conv-1"); + expect(stored).toBeDefined(); + expect(stored).toHaveLength(2); + expect(stored?.[0]?.role).toBe("user"); + expect(stored?.[1]?.role).toBe("assistant"); + + const userChunks = stored?.[0]?.chunks ?? []; + expect(userChunks[0]).toEqual({ type: "text", text: "Hi" }); + + const assistantChunks = stored?.[1]?.chunks ?? []; + expect(assistantChunks.some((c) => c.type === "text")).toBe(true); + }); + + it("multi-turn: second call sees first turn in history", async () => { + const store = createInMemoryStore(); + let capturedMessages: ChatMessage[] | undefined; + + let callCount = 0; + const provider: ProviderContract = { + id: "fake", + stream(messages, _tools) { + if (callCount === 1) { + capturedMessages = [...messages]; + } + callCount++; + return (async function* () { + yield { type: "text-delta", delta: `Reply ${callCount}` } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-multi", + text: "First message", + onEvent: () => {}, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-multi", + text: "Second message", + onEvent: () => {}, + }); + + expect(capturedMessages).toBeDefined(); + expect(capturedMessages?.length).toBeGreaterThanOrEqual(3); + + expect(capturedMessages?.[0]?.role).toBe("user"); + const firstUserText = capturedMessages?.[0]?.chunks[0]; + expect(firstUserText).toEqual({ type: "text", text: "First message" }); + + expect(capturedMessages?.[1]?.role).toBe("assistant"); + + const lastUser = capturedMessages?.findLast((m) => m.role === "user"); + expect(lastUser).toBeDefined(); + const lastUserText = lastUser?.chunks[0]; + expect(lastUserText).toEqual({ type: "text", text: "Second message" }); + }); + + it("uses custom dispatch policy when resolveDispatch is provided", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + resolveDispatch: () => ({ maxConcurrent: 4, eager: false }), + runTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-dispatch", + text: "test", + onEvent: () => {}, + }); + + const stored = store.data.get("conv-dispatch"); + expect(stored).toBeDefined(); + expect(stored?.length).toBeGreaterThanOrEqual(1); + }); }); function createCapturingRunTurn(): { - result: RunTurnResult; - captured: RunTurnInput[]; - captureRunTurn: (input: RunTurnInput) => Promise; + result: RunTurnResult; + captured: RunTurnInput[]; + captureRunTurn: (input: RunTurnInput) => Promise; } { - const result: RunTurnResult = { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - const captured: RunTurnInput[] = []; - return { - result, - captured, - captureRunTurn: async (input) => { - captured.push(input); - return result; - }, - }; + const result: RunTurnResult = { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + const captured: RunTurnInput[] = []; + return { + result, + captured, + captureRunTurn: async (input) => { + captured.push(input); + return result; + }, + }; } describe("handleMessage model resolution", () => { - it("modelName resolves → runTurn receives resolved provider, providerOpts.model, and cwd", async () => { - const store = createInMemoryStore(); - const resolvedProvider: ProviderContract = { id: "resolved", stream: async function* () {} }; - const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => fallbackProvider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - resolveModel: (name) => { - if (name === "cred/gpt-4") return { provider: resolvedProvider, model: "gpt-4" }; - return undefined; - }, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-model", - text: "hi", - onEvent: () => {}, - modelName: "cred/gpt-4", - cwd: "/work/dir", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.provider).toBe(resolvedProvider); - expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high", model: "gpt-4" }); - expect(captured[0]?.cwd).toBe("/work/dir"); - }); - - it("modelName given but resolveModel returns undefined → error event emitted, runTurn NOT called", async () => { - const store = createInMemoryStore(); - const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - const events: AgentEvent[] = []; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => fallbackProvider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - resolveModel: () => undefined, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-unknown", - text: "hi", - onEvent: (e) => events.push(e), - modelName: "cred/nonexistent", - }); - - expect(captured).toHaveLength(0); - const errorEvents = events.filter((e) => e.type === "error"); - expect(errorEvents).toHaveLength(1); - expect((errorEvents[0] as AgentEvent & { type: "error" }).message).toBe( - "unknown model: cred/nonexistent", - ); - expect((errorEvents[0] as AgentEvent & { type: "error" }).conversationId).toBe("conv-unknown"); - expect((errorEvents[0] as AgentEvent & { type: "error" }).turnId).toMatch(/^turn-/); - }); - - it("no modelName → falls back to resolveProvider(), no model override", async () => { - const store = createInMemoryStore(); - const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => fallbackProvider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - resolveModel: () => ({ - provider: { id: "should-not-use", stream: async function* () {} }, - model: "x", - }), - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-fallback", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.provider).toBe(fallbackProvider); - expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high" }); - }); - - it("cwd is forwarded to RunTurnInput.cwd and absent when not provided", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-cwd", - text: "hi", - onEvent: () => {}, - cwd: "/custom/path", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/custom/path"); - - await orchestrator.handleMessage({ - conversationId: "conv-no-cwd", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(2); - expect(captured[1]?.cwd).toBeUndefined(); - }); - - it("computerId is forwarded to RunTurnInput.computerId and absent when not provided", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-computer", - text: "hi", - onEvent: () => {}, - computerId: "my-ssh-host", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.computerId).toBe("my-ssh-host"); - - await orchestrator.handleMessage({ - conversationId: "conv-no-computer", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(2); - expect(captured[1]?.computerId).toBeUndefined(); - }); - - it("computerId override persists via setComputerId (mirrors setCwd-on-override)", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-persist-computer", - text: "hi", - onEvent: () => {}, - computerId: "persisted-host", - }); - - expect(store.computerData.get("conv-persist-computer")).toBe("persisted-host"); - }); - - it("computerId not provided → setComputerId NOT called (no override persisted)", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-no-persist", - text: "hi", - onEvent: () => {}, - }); - - expect(store.computerData.get("conv-no-persist")).toBeUndefined(); - }); - - it("computerId threads into ToolAssembly passed to applyToolsFilter", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - const capturedAssemblies: ToolAssembly[] = []; - const recordingApplyToolsFilter = (assembly: ToolAssembly): Promise => { - capturedAssemblies.push(assembly); - return Promise.resolve(assembly); - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: recordingApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-assembly", - text: "hi", - onEvent: () => {}, - computerId: "remote-host", - }); - - expect(capturedAssemblies).toHaveLength(1); - expect(capturedAssemblies[0]?.computerId).toBe("remote-host"); - }); - - it("forwards an injected now into the RunTurnInput passed to runTurn", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - const fakeNow = () => 42; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - now: fakeNow, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-now", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.now).toBe(fakeNow); - expect(captured[0]?.now?.()).toBe(42); - }); - - it("omits now from RunTurnInput when deps.now is not provided", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-no-now", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.now).toBeUndefined(); - }); + it("modelName resolves → runTurn receives resolved provider, providerOpts.model, and cwd", async () => { + const store = createInMemoryStore(); + const resolvedProvider: ProviderContract = { id: "resolved", stream: async function* () {} }; + const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => fallbackProvider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: (name) => { + if (name === "cred/gpt-4") return { provider: resolvedProvider, model: "gpt-4" }; + return undefined; + }, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-model", + text: "hi", + onEvent: () => {}, + modelName: "cred/gpt-4", + cwd: "/work/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.provider).toBe(resolvedProvider); + expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high", model: "gpt-4" }); + expect(captured[0]?.cwd).toBe("/work/dir"); + }); + + it("modelName given but resolveModel returns undefined → error event emitted, runTurn NOT called", async () => { + const store = createInMemoryStore(); + const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + const events: AgentEvent[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => fallbackProvider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => undefined, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-unknown", + text: "hi", + onEvent: (e) => events.push(e), + modelName: "cred/nonexistent", + }); + + expect(captured).toHaveLength(0); + const errorEvents = events.filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect((errorEvents[0] as AgentEvent & { type: "error" }).message).toBe( + "unknown model: cred/nonexistent", + ); + expect((errorEvents[0] as AgentEvent & { type: "error" }).conversationId).toBe("conv-unknown"); + expect((errorEvents[0] as AgentEvent & { type: "error" }).turnId).toMatch(/^turn-/); + }); + + it("no modelName → falls back to resolveProvider(), no model override", async () => { + const store = createInMemoryStore(); + const fallbackProvider: ProviderContract = { id: "fallback", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => fallbackProvider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + resolveModel: () => ({ + provider: { id: "should-not-use", stream: async function* () {} }, + model: "x", + }), + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-fallback", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.provider).toBe(fallbackProvider); + expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high" }); + }); + + it("cwd is forwarded to RunTurnInput.cwd and absent when not provided", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-cwd", + text: "hi", + onEvent: () => {}, + cwd: "/custom/path", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/custom/path"); + + await orchestrator.handleMessage({ + conversationId: "conv-no-cwd", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBeUndefined(); + }); + + it("computerId is forwarded to RunTurnInput.computerId and absent when not provided", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-computer", + text: "hi", + onEvent: () => {}, + computerId: "my-ssh-host", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.computerId).toBe("my-ssh-host"); + + await orchestrator.handleMessage({ + conversationId: "conv-no-computer", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.computerId).toBeUndefined(); + }); + + it("computerId override persists via setComputerId (mirrors setCwd-on-override)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-computer", + text: "hi", + onEvent: () => {}, + computerId: "persisted-host", + }); + + expect(store.computerData.get("conv-persist-computer")).toBe("persisted-host"); + }); + + it("computerId not provided → setComputerId NOT called (no override persisted)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-persist", + text: "hi", + onEvent: () => {}, + }); + + expect(store.computerData.get("conv-no-persist")).toBeUndefined(); + }); + + it("computerId threads into ToolAssembly passed to applyToolsFilter", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const capturedAssemblies: ToolAssembly[] = []; + const recordingApplyToolsFilter = (assembly: ToolAssembly): Promise => { + capturedAssemblies.push(assembly); + return Promise.resolve(assembly); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: recordingApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-assembly", + text: "hi", + onEvent: () => {}, + computerId: "remote-host", + }); + + expect(capturedAssemblies).toHaveLength(1); + expect(capturedAssemblies[0]?.computerId).toBe("remote-host"); + }); + + it("forwards an injected now into the RunTurnInput passed to runTurn", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + const fakeNow = () => 42; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + now: fakeNow, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-now", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.now).toBe(fakeNow); + expect(captured[0]?.now?.()).toBe(42); + }); + + it("omits now from RunTurnInput when deps.now is not provided", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-now", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.now).toBeUndefined(); + }); }); describe("turn-sealed event", () => { - it("emits turn-sealed after persisting the turn", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const { events, onEvent } = collectEvents(); - - await orchestrator.handleMessage({ - conversationId: "conv-seal", - text: "test", - onEvent, - }); - - const sealedEvents = events.filter((e) => e.type === "turn-sealed"); - expect(sealedEvents).toHaveLength(1); - const sealed = sealedEvents[0] as AgentEvent & { type: "turn-sealed" }; - expect(sealed.conversationId).toBe("conv-seal"); - expect(sealed.turnId).toMatch(/^turn-/); - }); - - it("turn-sealed is emitted after the store append", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const ordering: string[] = []; - const wrappedStore: ConversationStore = { - ...store, - async append(conversationId, messages) { - await store.append(conversationId, messages); - ordering.push("append"); - }, - async appendMetrics(conversationId, metrics) { - await store.appendMetrics(conversationId, metrics); - ordering.push("appendMetrics"); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: wrappedStore, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-order", - text: "test", - onEvent: (event) => { - if (event.type === "turn-sealed") { - ordering.push("turn-sealed"); - } - }, - }); - - expect(ordering).toEqual(["append", "append", "appendMetrics", "turn-sealed"]); - }); - - it("does not emit turn-sealed when append throws — emits error event instead", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const failingStore: ConversationStore = { - async append() { - throw new Error("storage failure"); - }, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() { - return undefined; - }, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getComputerId() { - return null; - }, - async setComputerId() {}, - async clearComputerId() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async getModel() { - return null; - }, - async setModel() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle(id, title) { - return { - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - async getEffectiveComputer() { - return null; - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: failingStore, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const { events, onEvent } = collectEvents(); - - await orchestrator.handleMessage({ - conversationId: "conv-fail", - text: "test", - onEvent, - }); - - const sealedEvents = events.filter((e) => e.type === "turn-sealed"); - expect(sealedEvents).toHaveLength(0); - - const errorEvents = events.filter((e) => e.type === "error"); - expect(errorEvents).toHaveLength(1); - expect((errorEvents[0] as AgentEvent & { type: "error" }).message).toBe("storage failure"); - }); + it("emits turn-sealed after persisting the turn", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const { events, onEvent } = collectEvents(); + + await orchestrator.handleMessage({ + conversationId: "conv-seal", + text: "test", + onEvent, + }); + + const sealedEvents = events.filter((e) => e.type === "turn-sealed"); + expect(sealedEvents).toHaveLength(1); + const sealed = sealedEvents[0] as AgentEvent & { type: "turn-sealed" }; + expect(sealed.conversationId).toBe("conv-seal"); + expect(sealed.turnId).toMatch(/^turn-/); + }); + + it("turn-sealed is emitted after the store append", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const ordering: string[] = []; + const wrappedStore: ConversationStore = { + ...store, + async append(conversationId, messages) { + await store.append(conversationId, messages); + ordering.push("append"); + }, + async appendMetrics(conversationId, metrics) { + await store.appendMetrics(conversationId, metrics); + ordering.push("appendMetrics"); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: wrappedStore, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-order", + text: "test", + onEvent: (event) => { + if (event.type === "turn-sealed") { + ordering.push("turn-sealed"); + } + }, + }); + + expect(ordering).toEqual(["append", "append", "appendMetrics", "turn-sealed"]); + }); + + it("does not emit turn-sealed when append throws — emits error event instead", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const failingStore: ConversationStore = { + async append() { + throw new Error("storage failure"); + }, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() { + return undefined; + }, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async getModel() { + return null; + }, + async setModel() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle(id, title) { + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + async getEffectiveComputer() { + return null; + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: failingStore, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const { events, onEvent } = collectEvents(); + + await orchestrator.handleMessage({ + conversationId: "conv-fail", + text: "test", + onEvent, + }); + + const sealedEvents = events.filter((e) => e.type === "turn-sealed"); + expect(sealedEvents).toHaveLength(0); + + const errorEvents = events.filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect((errorEvents[0] as AgentEvent & { type: "error" }).message).toBe("storage failure"); + }); }); describe("turn metrics persistence", () => { - it("persists a TurnMetrics after a single-step turn seals", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: () => 1000, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-metrics-1", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-metrics-1"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - expect(metrics?.[0]?.turnId).toMatch(/^turn-/); - expect(metrics?.[0]?.usage.inputTokens).toBe(10); - expect(metrics?.[0]?.usage.outputTokens).toBe(5); - expect(metrics?.[0]?.steps).toHaveLength(1); - expect(metrics?.[0]?.steps[0]?.usage.inputTokens).toBe(10); - expect(metrics?.[0]?.steps[0]?.usage.outputTokens).toBe(5); - }); - - it("TurnMetrics aggregates multi-step usage and carries each step's StepMetrics in order", async () => { - const store = createInMemoryStore(); - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - yield { type: "text-delta", delta: "Step2" } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 20, outputTokens: 10 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [tool], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: () => 1000, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-metrics-multi", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-metrics-multi"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - - const tm = metrics?.[0]; - if (tm === undefined) throw new Error("expected metrics"); - - expect(tm.steps.length).toBeGreaterThanOrEqual(2); - - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[0]?.usage.outputTokens).toBe(5); - expect(tm.steps[1]?.usage.inputTokens).toBe(20); - expect(tm.steps[1]?.usage.outputTokens).toBe(10); - - expect(tm.usage.inputTokens).toBe(30); - expect(tm.usage.outputTokens).toBe(15); - }); - - it("per-step timing and usage are joined by stepId into one StepMetrics", async () => { - const store = createInMemoryStore(); - const clock = createCounterNow(); - clock.tick(100); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - clock.tick(50); - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - clock.tick(100); - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - clock.tick(50); - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: clock.now, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-metrics-join", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-metrics-join"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - - const tm = metrics?.[0]; - if (tm === undefined) throw new Error("expected metrics"); - - expect(tm.steps).toHaveLength(1); - const step = tm.steps[0]; - if (step === undefined) throw new Error("expected step"); - - expect(step.usage.inputTokens).toBe(10); - expect(step.usage.outputTokens).toBe(5); - expect(step.genTotalMs).toBe(200); - expect(step.ttftMs).toBe(50); - expect(step.decodeMs).toBe(150); - }); - - it("turn-level usage comes from the done event aggregate", async () => { - const store = createInMemoryStore(); - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - yield { type: "text-delta", delta: "Step2" } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 20, outputTokens: 10 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [tool], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: () => 1000, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-metrics-done", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-metrics-done"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - - const tm = metrics?.[0]; - if (tm === undefined) throw new Error("expected metrics"); - - expect(tm.usage.inputTokens).toBe(30); - expect(tm.usage.outputTokens).toBe(15); - }); - - it("persists contextSize as the last step's inputTokens + outputTokens", async () => { - const store = createInMemoryStore(); - const tool = createFakeTool("echo", async () => ({ content: "echoed" })); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - yield { - type: "tool-call", - toolCallId: "tc1", - toolName: "echo", - input: {}, - } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 5 }, - } as ProviderEvent; - yield { type: "finish", reason: "tool-calls" } as ProviderEvent; - } else { - yield { type: "text-delta", delta: "Step2" } as ProviderEvent; - yield { - type: "usage", - usage: { inputTokens: 20, outputTokens: 10 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [tool], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: () => 1000, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-context-size", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-context-size"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - - const tm = metrics?.[0]; - if (tm === undefined) throw new Error("expected metrics"); - - expect(tm.steps.length).toBeGreaterThanOrEqual(2); - expect(tm.contextSize).toBe(30); - }); - - it("does not persist metrics nor emit turn-sealed when chunk append fails", async () => { - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - let metricsAppended = false; - const failingMetricsStore: ConversationStore = { - async append() { - throw new Error("storage failure"); - }, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() { - metricsAppended = true; - }, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getComputerId() { - return null; - }, - async setComputerId() {}, - async clearComputerId() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async getModel() { - return null; - }, - async setModel() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle(id, title) { - return { - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - async getEffectiveComputer() { - return null; - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: failingMetricsStore, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const { events, onEvent } = collectEvents(); - - await orchestrator.handleMessage({ - conversationId: "conv-fail-metrics", - text: "test", - onEvent, - }); - - const sealedEvents = events.filter((e) => e.type === "turn-sealed"); - expect(sealedEvents).toHaveLength(0); - expect(metricsAppended).toBe(false); - - const errorEvents = events.filter((e) => e.type === "error"); - expect(errorEvents).toHaveLength(1); - }); + it("persists a TurnMetrics after a single-step turn seals", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-metrics-1", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-metrics-1"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + expect(metrics?.[0]?.turnId).toMatch(/^turn-/); + expect(metrics?.[0]?.usage.inputTokens).toBe(10); + expect(metrics?.[0]?.usage.outputTokens).toBe(5); + expect(metrics?.[0]?.steps).toHaveLength(1); + expect(metrics?.[0]?.steps[0]?.usage.inputTokens).toBe(10); + expect(metrics?.[0]?.steps[0]?.usage.outputTokens).toBe(5); + }); + + it("TurnMetrics aggregates multi-step usage and carries each step's StepMetrics in order", async () => { + const store = createInMemoryStore(); + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "Step2" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 20, outputTokens: 10 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-metrics-multi", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-metrics-multi"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.steps.length).toBeGreaterThanOrEqual(2); + + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[0]?.usage.outputTokens).toBe(5); + expect(tm.steps[1]?.usage.inputTokens).toBe(20); + expect(tm.steps[1]?.usage.outputTokens).toBe(10); + + expect(tm.usage.inputTokens).toBe(30); + expect(tm.usage.outputTokens).toBe(15); + }); + + it("per-step timing and usage are joined by stepId into one StepMetrics", async () => { + const store = createInMemoryStore(); + const clock = createCounterNow(); + clock.tick(100); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + clock.tick(50); + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + clock.tick(100); + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + clock.tick(50); + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: clock.now, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-metrics-join", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-metrics-join"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.steps).toHaveLength(1); + const step = tm.steps[0]; + if (step === undefined) throw new Error("expected step"); + + expect(step.usage.inputTokens).toBe(10); + expect(step.usage.outputTokens).toBe(5); + expect(step.genTotalMs).toBe(200); + expect(step.ttftMs).toBe(50); + expect(step.decodeMs).toBe(150); + }); + + it("turn-level usage comes from the done event aggregate", async () => { + const store = createInMemoryStore(); + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "Step2" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 20, outputTokens: 10 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-metrics-done", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-metrics-done"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.usage.inputTokens).toBe(30); + expect(tm.usage.outputTokens).toBe(15); + }); + + it("persists contextSize as the last step's inputTokens + outputTokens", async () => { + const store = createInMemoryStore(); + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "Step2" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 20, outputTokens: 10 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-context-size", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-context-size"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.steps.length).toBeGreaterThanOrEqual(2); + expect(tm.contextSize).toBe(30); + }); + + it("does not persist metrics nor emit turn-sealed when chunk append fails", async () => { + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + let metricsAppended = false; + const failingMetricsStore: ConversationStore = { + async append() { + throw new Error("storage failure"); + }, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() { + metricsAppended = true; + }, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async getModel() { + return null; + }, + async setModel() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle(id, title) { + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + async getEffectiveComputer() { + return null; + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: failingMetricsStore, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const { events, onEvent } = collectEvents(); + + await orchestrator.handleMessage({ + conversationId: "conv-fail-metrics", + text: "test", + onEvent, + }); + + const sealedEvents = events.filter((e) => e.type === "turn-sealed"); + expect(sealedEvents).toHaveLength(0); + expect(metricsAppended).toBe(false); + + const errorEvents = events.filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + }); }); describe("tools filter", () => { - it("applies the tools filter once and passes the result to runTurn", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); - const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); - - let filterCallCount = 0; - const transformingFilter = (assembly: ToolAssembly): Promise => { - filterCallCount++; - return Promise.resolve({ ...assembly, tools: [toolB] }); - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [toolA], - applyToolsFilter: transformingFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-filter-once", - text: "hi", - onEvent: () => {}, - }); - - expect(filterCallCount).toBe(1); - expect(captured).toHaveLength(1); - expect(captured[0]?.tools).toHaveLength(1); - expect(captured[0]?.tools[0]?.name).toBe("tool-b"); - }); - - it("tools filter identity is a no-op (same tools reach runTurn)", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); - const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [toolA, toolB], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-filter-identity", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.tools).toHaveLength(2); - expect(captured[0]?.tools[0]?.name).toBe("tool-a"); - expect(captured[0]?.tools[1]?.name).toBe("tool-b"); - }); - - it("threads cwd and conversationId into the tool assembly", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - let receivedAssembly: ToolAssembly | undefined; - const capturingFilter = (assembly: ToolAssembly): Promise => { - receivedAssembly = assembly; - return Promise.resolve(assembly); - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: capturingFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-filter-threads", - text: "hi", - onEvent: () => {}, - cwd: "/test/dir", - }); - - expect(receivedAssembly).toBeDefined(); - expect(receivedAssembly?.conversationId).toBe("conv-filter-threads"); - expect(receivedAssembly?.cwd).toBe("/test/dir"); - expect(receivedAssembly?.tools).toEqual([]); - }); + it("applies the tools filter once and passes the result to runTurn", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + let filterCallCount = 0; + const transformingFilter = (assembly: ToolAssembly): Promise => { + filterCallCount++; + return Promise.resolve({ ...assembly, tools: [toolB] }); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA], + applyToolsFilter: transformingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-once", + text: "hi", + onEvent: () => {}, + }); + + expect(filterCallCount).toBe(1); + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(1); + expect(captured[0]?.tools[0]?.name).toBe("tool-b"); + }); + + it("tools filter identity is a no-op (same tools reach runTurn)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + const toolB = createFakeTool("tool-b", async () => ({ content: "b" })); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA, toolB], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-identity", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.tools).toHaveLength(2); + expect(captured[0]?.tools[0]?.name).toBe("tool-a"); + expect(captured[0]?.tools[1]?.name).toBe("tool-b"); + }); + + it("threads cwd and conversationId into the tool assembly", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + let receivedAssembly: ToolAssembly | undefined; + const capturingFilter = (assembly: ToolAssembly): Promise => { + receivedAssembly = assembly; + return Promise.resolve(assembly); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: capturingFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-filter-threads", + text: "hi", + onEvent: () => {}, + cwd: "/test/dir", + }); + + expect(receivedAssembly).toBeDefined(); + expect(receivedAssembly?.conversationId).toBe("conv-filter-threads"); + expect(receivedAssembly?.cwd).toBe("/test/dir"); + expect(receivedAssembly?.tools).toEqual([]); + }); }); function createCounterNow(): { now: () => number; tick: (ms: number) => void } { - let t = 0; - return { - now: () => t, - tick(ms: number) { - t += ms; - }, - }; + let t = 0; + return { + now: () => t, + tick(ms: number) { + t += ms; + }, + }; } describe("lifecycle event hooks", () => { - it("emits turnStarted before and turnSettled after a turn", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const emitted: Array<{ hook: string; payload: TurnLifecyclePayload; order: number }> = []; - let order = 0; - - const fakeEmit = (hook: EventHookDescriptor, payload: TPayload): void => { - emitted.push({ hook: hook.id, payload: payload as TurnLifecyclePayload, order: order++ }); - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: fakeEmit, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-lifecycle", - text: "test", - onEvent: () => {}, - cwd: "/work", - modelName: "mymodel", - }); - - // The status-changed emits resolve the persisted workspace id async - // (getWorkspaceId) before firing, so they may land after turn-settled - // in microtask order. Flush all pending microtasks so every emit has - // landed, then assert by hook identity rather than strict index order. - await new Promise((resolve) => setImmediate(resolve)); - - expect(emitted).toHaveLength(4); - - const started = emitted.find((e) => e.hook === "session-orchestrator/turn-started"); - const settled = emitted.find((e) => e.hook === "session-orchestrator/turn-settled"); - const statusChanges = emitted.filter( - (e) => e.hook === "session-orchestrator/conversation-status-changed", - ); - - expect(started).toBeDefined(); - expect(started?.payload.conversationId).toBe("conv-lifecycle"); - expect(started?.payload.cwd).toBe("/work"); - expect(started?.payload.modelName).toBe("mymodel"); - // turn-started is the FIRST emit (synchronous, before any async deferral). - expect(started?.order).toBe(0); - - expect(settled).toBeDefined(); - expect(settled?.payload.conversationId).toBe("conv-lifecycle"); - expect(settled?.payload.cwd).toBe("/work"); - expect(settled?.payload.modelName).toBe("mymodel"); - // turn-started precedes turn-settled. - expect(started?.order).toBeLessThan(settled?.order ?? Infinity); - - expect(statusChanges).toHaveLength(2); - const activeChange = statusChanges.find( - (e) => (e.payload as unknown as { status: string }).status === "active", - ); - const idleChange = statusChanges.find( - (e) => (e.payload as unknown as { status: string }).status === "idle", - ); - expect(activeChange).toBeDefined(); - expect(idleChange).toBeDefined(); - // Both status-changed payloads now carry the persisted workspace id. - expect((activeChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe( - "default", - ); - expect((idleChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe("default"); - }); + it("emits turnStarted before and turnSettled after a turn", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const emitted: Array<{ hook: string; payload: TurnLifecyclePayload; order: number }> = []; + let order = 0; + + const fakeEmit = (hook: EventHookDescriptor, payload: TPayload): void => { + emitted.push({ hook: hook.id, payload: payload as TurnLifecyclePayload, order: order++ }); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: fakeEmit, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-lifecycle", + text: "test", + onEvent: () => {}, + cwd: "/work", + modelName: "mymodel", + }); + + // The status-changed emits resolve the persisted workspace id async + // (getWorkspaceId) before firing, so they may land after turn-settled + // in microtask order. Flush all pending microtasks so every emit has + // landed, then assert by hook identity rather than strict index order. + await new Promise((resolve) => setImmediate(resolve)); + + expect(emitted).toHaveLength(4); + + const started = emitted.find((e) => e.hook === "session-orchestrator/turn-started"); + const settled = emitted.find((e) => e.hook === "session-orchestrator/turn-settled"); + const statusChanges = emitted.filter( + (e) => e.hook === "session-orchestrator/conversation-status-changed", + ); + + expect(started).toBeDefined(); + expect(started?.payload.conversationId).toBe("conv-lifecycle"); + expect(started?.payload.cwd).toBe("/work"); + expect(started?.payload.modelName).toBe("mymodel"); + // turn-started is the FIRST emit (synchronous, before any async deferral). + expect(started?.order).toBe(0); + + expect(settled).toBeDefined(); + expect(settled?.payload.conversationId).toBe("conv-lifecycle"); + expect(settled?.payload.cwd).toBe("/work"); + expect(settled?.payload.modelName).toBe("mymodel"); + // turn-started precedes turn-settled. + expect(started?.order).toBeLessThan(settled?.order ?? Infinity); + + expect(statusChanges).toHaveLength(2); + const activeChange = statusChanges.find( + (e) => (e.payload as unknown as { status: string }).status === "active", + ); + const idleChange = statusChanges.find( + (e) => (e.payload as unknown as { status: string }).status === "idle", + ); + expect(activeChange).toBeDefined(); + expect(idleChange).toBeDefined(); + // Both status-changed payloads now carry the persisted workspace id. + expect((activeChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe( + "default", + ); + expect((idleChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe("default"); + }); }); describe("warm service", () => { - it("warm reuses the assembled tools + full history and appends the probe turn", async () => { - const store = createInMemoryStore(); - const existingMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "existing" }], - }; - const assistantMsg: ChatMessage = { - role: "assistant", - chunks: [{ type: "text", text: "reply" }], - }; - await store.append("conv-warm-reuse", [existingMsg, assistantMsg]); - - let capturedMessages: readonly ChatMessage[] | undefined; - let capturedTools: readonly ToolContract[] | undefined; - let _capturedOpts: unknown; - - const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); - - const provider: ProviderContract = { - id: "warm-provider", - stream(messages, tools, opts) { - capturedMessages = messages; - capturedTools = tools; - _capturedOpts = opts; - return (async function* () { - yield { - type: "usage", - usage: { inputTokens: 100, outputTokens: 5, cacheReadTokens: 80, cacheWriteTokens: 20 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [toolA], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - const result = await warmService.warm("conv-warm-reuse", { cwd: "/test" }); - - expect(capturedMessages).toBeDefined(); - expect(capturedMessages).toHaveLength(3); - expect(capturedMessages?.[0]?.chunks[0]).toEqual({ type: "text", text: "existing" }); - expect(capturedMessages?.[1]?.chunks[0]).toEqual({ type: "text", text: "reply" }); - expect(capturedMessages?.[2]?.role).toBe("user"); - expect((capturedMessages?.[2]?.chunks[0] as { type: "text"; text: string }).text).toBe( - "reply with just a .", - ); - - expect(capturedTools).toHaveLength(1); - expect(capturedTools?.[0]?.name).toBe("tool-a"); - - if ("inputTokens" in result) { - expect(result.inputTokens).toBe(100); - expect(result.cacheReadTokens).toBe(80); - } - }); - - it("warm forwards a `warm`-flagged logger so the send is captured as a span", async () => { - const store = createInMemoryStore(); - await store.append("conv-warm-log", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - - let capturedOpts: ProviderStreamOptions | undefined; - const provider: ProviderContract = { - id: "p", - stream(_messages, _tools, opts) { - capturedOpts = opts; - return (async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - })(); - }, - }; - - // Minimal Logger stub recording the child() correlation it was asked for. - let childArg: (Partial<{ conversationId: string }> & { attrs?: unknown }) | undefined; - const warmChild = { __warmChild: true } as unknown as Logger; - const logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - span() { - throw new Error("warm should not open spans directly"); - }, - child(ctx: { conversationId?: string; attrs?: unknown }) { - childArg = ctx; - return warmChild; - }, - } as unknown as Logger; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - logger, - }; - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - await warmService.warm("conv-warm-log"); - - // The warm send must carry the logger so the provider opens a provider.request span. - expect(capturedOpts?.logger).toBe(warmChild); - // …and it must be flagged warm + correlated to the conversation, so it can be - // diffed against the real turn's request (the 0%-cache debugging workflow). - expect(childArg).toMatchObject({ - conversationId: "conv-warm-log", - attrs: { warm: true }, - }); - }); - - it("warm falls back to the conversation's stored cwd for tool assembly", async () => { - // A cwd-sensitive tools filter (e.g. skill discovery) must see the SAME cwd - // the real turn used, or the tools block diverges and the prompt cache misses. - // A manual reheat sends no cwd, so the warm must fall back to the stored cwd. - const store = createInMemoryStore(); - await store.append("conv-warm-cwd", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - await store.setCwd("conv-warm-cwd", "/home/tradam/projects/roblox"); - - let assemblyCwd: string | undefined = "UNSET"; - const provider: ProviderContract = { - id: "p", - stream() { - return (async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - })(); - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: (assembly: ToolAssembly) => { - assemblyCwd = assembly.cwd; - return Promise.resolve(assembly); - }, - runTurn, - emit: () => {}, - }; - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - // No cwd in opts (the reheat case) → must use the stored cwd. - await warmService.warm("conv-warm-cwd"); - expect(assemblyCwd).toBe("/home/tradam/projects/roblox"); - }); - - it("warm refuses while the conversation is generating", async () => { - const store = createInMemoryStore(); - let resolveRunTurn: (() => void) | undefined; - const runTurnBlocker = new Promise((resolve) => { - resolveRunTurn = resolve; - }); - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { type: "text-delta", delta: "slow" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const blockingRunTurn = async (_input: RunTurnInput): Promise => { - await runTurnBlocker; - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingRunTurn, - emit: () => {}, - }; - - const { orchestrator, activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - const turnPromise = orchestrator.handleMessage({ - conversationId: "conv-blocking", - text: "test", - onEvent: () => {}, - }); - - const warmResult = await warmService.warm("conv-blocking"); - expect(warmResult).toEqual({ error: "conversation is generating" }); - - resolveRunTurn?.(); - await turnPromise; - }); - - it("warm never persists (no append) and emits no AgentEvents", async () => { - const store = createInMemoryStore(); - const existingMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "existing" }], - }; - await store.append("conv-no-persist", [existingMsg]); - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { inputTokens: 10, outputTokens: 2 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - const sizeBefore = store.data.get("conv-no-persist")?.length; - - await warmService.warm("conv-no-persist"); - - const sizeAfter = store.data.get("conv-no-persist")?.length; - expect(sizeAfter).toBe(sizeBefore); - }); - - it("warm returns provider usage (input + cacheReadTokens)", async () => { - const store = createInMemoryStore(); - const existingMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "existing" }], - }; - await store.append("conv-usage", [existingMsg]); - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { - inputTokens: 500, - outputTokens: 3, - cacheReadTokens: 400, - cacheWriteTokens: 100, - }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - const result = await warmService.warm("conv-usage"); - - expect(result).toEqual({ - inputTokens: 500, - outputTokens: 3, - cacheReadTokens: 400, - cacheWriteTokens: 100, - }); - }); - - it("warm emits warmCompleted with the usage on success", async () => { - const store = createInMemoryStore(); - const existingMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "existing" }], - }; - await store.append("conv-warm-emit", [existingMsg]); - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { inputTokens: 200, outputTokens: 10, cacheReadTokens: 150, cacheWriteTokens: 50 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const emitted: Array<{ hook: string; payload: WarmCompletedPayload }> = []; - const fakeEmit = (hook: EventHookDescriptor, payload: TPayload): void => { - emitted.push({ hook: hook.id, payload: payload as WarmCompletedPayload }); - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: fakeEmit, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - const result = await warmService.warm("conv-warm-emit"); - - if (!("inputTokens" in result)) throw new Error("expected success"); - - expect(emitted).toHaveLength(1); - expect(emitted[0]?.hook).toBe("session-orchestrator/warm-completed"); - expect(emitted[0]?.payload.conversationId).toBe("conv-warm-emit"); - expect(emitted[0]?.payload.usage).toEqual(result); - }); - - it("warm does NOT emit warmCompleted when it refuses (conversation generating / no history)", async () => { - const store = createInMemoryStore(); - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { type: "text-delta", delta: "slow" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const emitted: Array<{ hook: string }> = []; - const fakeEmit = (hook: EventHookDescriptor, _payload: TPayload): void => { - emitted.push({ hook: hook.id }); - }; - - const blockingRunTurn = async (_input: RunTurnInput): Promise => { - await new Promise((resolve) => setTimeout(resolve, 50)); - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingRunTurn, - emit: fakeEmit, - }; - - const { orchestrator, activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - // Refuse because conversation is generating - const turnPromise = orchestrator.handleMessage({ - conversationId: "conv-refuse-gen", - text: "test", - onEvent: () => {}, - }); - - const genResult = await warmService.warm("conv-refuse-gen"); - expect(genResult).toEqual({ error: "conversation is generating" }); - - await turnPromise; - - // Refuse because no history - const noHistResult = await warmService.warm("conv-refuse-empty"); - expect(noHistResult).toEqual({ error: "no history" }); - - const warmEmits = emitted.filter((e) => e.hook === "session-orchestrator/warm-completed"); - expect(warmEmits).toHaveLength(0); - }); + it("warm reuses the assembled tools + full history and appends the probe turn", async () => { + const store = createInMemoryStore(); + const existingMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "existing" }], + }; + const assistantMsg: ChatMessage = { + role: "assistant", + chunks: [{ type: "text", text: "reply" }], + }; + await store.append("conv-warm-reuse", [existingMsg, assistantMsg]); + + let capturedMessages: readonly ChatMessage[] | undefined; + let capturedTools: readonly ToolContract[] | undefined; + let _capturedOpts: unknown; + + const toolA = createFakeTool("tool-a", async () => ({ content: "a" })); + + const provider: ProviderContract = { + id: "warm-provider", + stream(messages, tools, opts) { + capturedMessages = messages; + capturedTools = tools; + _capturedOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 100, outputTokens: 5, cacheReadTokens: 80, cacheWriteTokens: 20 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [toolA], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + const result = await warmService.warm("conv-warm-reuse", { cwd: "/test" }); + + expect(capturedMessages).toBeDefined(); + expect(capturedMessages).toHaveLength(3); + expect(capturedMessages?.[0]?.chunks[0]).toEqual({ type: "text", text: "existing" }); + expect(capturedMessages?.[1]?.chunks[0]).toEqual({ type: "text", text: "reply" }); + expect(capturedMessages?.[2]?.role).toBe("user"); + expect((capturedMessages?.[2]?.chunks[0] as { type: "text"; text: string }).text).toBe( + "reply with just a .", + ); + + expect(capturedTools).toHaveLength(1); + expect(capturedTools?.[0]?.name).toBe("tool-a"); + + if ("inputTokens" in result) { + expect(result.inputTokens).toBe(100); + expect(result.cacheReadTokens).toBe(80); + } + }); + + it("warm forwards a `warm`-flagged logger so the send is captured as a span", async () => { + const store = createInMemoryStore(); + await store.append("conv-warm-log", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + let capturedOpts: ProviderStreamOptions | undefined; + const provider: ProviderContract = { + id: "p", + stream(_messages, _tools, opts) { + capturedOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + // Minimal Logger stub recording the child() correlation it was asked for. + let childArg: (Partial<{ conversationId: string }> & { attrs?: unknown }) | undefined; + const warmChild = { __warmChild: true } as unknown as Logger; + const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + span() { + throw new Error("warm should not open spans directly"); + }, + child(ctx: { conversationId?: string; attrs?: unknown }) { + childArg = ctx; + return warmChild; + }, + } as unknown as Logger; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + logger, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-log"); + + // The warm send must carry the logger so the provider opens a provider.request span. + expect(capturedOpts?.logger).toBe(warmChild); + // …and it must be flagged warm + correlated to the conversation, so it can be + // diffed against the real turn's request (the 0%-cache debugging workflow). + expect(childArg).toMatchObject({ + conversationId: "conv-warm-log", + attrs: { warm: true }, + }); + }); + + it("warm falls back to the conversation's stored cwd for tool assembly", async () => { + // A cwd-sensitive tools filter (e.g. skill discovery) must see the SAME cwd + // the real turn used, or the tools block diverges and the prompt cache misses. + // A manual reheat sends no cwd, so the warm must fall back to the stored cwd. + const store = createInMemoryStore(); + await store.append("conv-warm-cwd", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store.setCwd("conv-warm-cwd", "/home/tradam/projects/roblox"); + + let assemblyCwd: string | undefined = "UNSET"; + const provider: ProviderContract = { + id: "p", + stream() { + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: (assembly: ToolAssembly) => { + assemblyCwd = assembly.cwd; + return Promise.resolve(assembly); + }, + runTurn, + emit: () => {}, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + // No cwd in opts (the reheat case) → must use the stored cwd. + await warmService.warm("conv-warm-cwd"); + expect(assemblyCwd).toBe("/home/tradam/projects/roblox"); + }); + + it("warm refuses while the conversation is generating", async () => { + const store = createInMemoryStore(); + let resolveRunTurn: (() => void) | undefined; + const runTurnBlocker = new Promise((resolve) => { + resolveRunTurn = resolve; + }); + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { type: "text-delta", delta: "slow" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const blockingRunTurn = async (_input: RunTurnInput): Promise => { + await runTurnBlocker; + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingRunTurn, + emit: () => {}, + }; + + const { orchestrator, activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + const turnPromise = orchestrator.handleMessage({ + conversationId: "conv-blocking", + text: "test", + onEvent: () => {}, + }); + + const warmResult = await warmService.warm("conv-blocking"); + expect(warmResult).toEqual({ error: "conversation is generating" }); + + resolveRunTurn?.(); + await turnPromise; + }); + + it("warm never persists (no append) and emits no AgentEvents", async () => { + const store = createInMemoryStore(); + const existingMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "existing" }], + }; + await store.append("conv-no-persist", [existingMsg]); + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 2 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + const sizeBefore = store.data.get("conv-no-persist")?.length; + + await warmService.warm("conv-no-persist"); + + const sizeAfter = store.data.get("conv-no-persist")?.length; + expect(sizeAfter).toBe(sizeBefore); + }); + + it("warm returns provider usage (input + cacheReadTokens)", async () => { + const store = createInMemoryStore(); + const existingMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "existing" }], + }; + await store.append("conv-usage", [existingMsg]); + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { + inputTokens: 500, + outputTokens: 3, + cacheReadTokens: 400, + cacheWriteTokens: 100, + }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + const result = await warmService.warm("conv-usage"); + + expect(result).toEqual({ + inputTokens: 500, + outputTokens: 3, + cacheReadTokens: 400, + cacheWriteTokens: 100, + }); + }); + + it("warm emits warmCompleted with the usage on success", async () => { + const store = createInMemoryStore(); + const existingMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "existing" }], + }; + await store.append("conv-warm-emit", [existingMsg]); + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 200, outputTokens: 10, cacheReadTokens: 150, cacheWriteTokens: 50 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const emitted: Array<{ hook: string; payload: WarmCompletedPayload }> = []; + const fakeEmit = (hook: EventHookDescriptor, payload: TPayload): void => { + emitted.push({ hook: hook.id, payload: payload as WarmCompletedPayload }); + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: fakeEmit, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + const result = await warmService.warm("conv-warm-emit"); + + if (!("inputTokens" in result)) throw new Error("expected success"); + + expect(emitted).toHaveLength(1); + expect(emitted[0]?.hook).toBe("session-orchestrator/warm-completed"); + expect(emitted[0]?.payload.conversationId).toBe("conv-warm-emit"); + expect(emitted[0]?.payload.usage).toEqual(result); + }); + + it("warm does NOT emit warmCompleted when it refuses (conversation generating / no history)", async () => { + const store = createInMemoryStore(); + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { type: "text-delta", delta: "slow" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const emitted: Array<{ hook: string }> = []; + const fakeEmit = (hook: EventHookDescriptor, _payload: TPayload): void => { + emitted.push({ hook: hook.id }); + }; + + const blockingRunTurn = async (_input: RunTurnInput): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingRunTurn, + emit: fakeEmit, + }; + + const { orchestrator, activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + // Refuse because conversation is generating + const turnPromise = orchestrator.handleMessage({ + conversationId: "conv-refuse-gen", + text: "test", + onEvent: () => {}, + }); + + const genResult = await warmService.warm("conv-refuse-gen"); + expect(genResult).toEqual({ error: "conversation is generating" }); + + await turnPromise; + + // Refuse because no history + const noHistResult = await warmService.warm("conv-refuse-empty"); + expect(noHistResult).toEqual({ error: "no history" }); + + const warmEmits = emitted.filter((e) => e.hook === "session-orchestrator/warm-completed"); + expect(warmEmits).toHaveLength(0); + }); }); describe("cwd persistence", () => { - it("uses the persisted cwd when the request omits cwd", async () => { - const store = createInMemoryStore(); - await store.setCwd("conv-persisted", "/persisted/dir"); - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-persisted", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/persisted/dir"); - }); - - it("persists the cwd when the request provides one (and a later cwd-less turn reuses it)", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-persist-new", - text: "first", - onEvent: () => {}, - cwd: "/new/dir", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/new/dir"); - expect(store.cwdData.get("conv-persist-new")).toBe("/new/dir"); - - await orchestrator.handleMessage({ - conversationId: "conv-persist-new", - text: "second", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(2); - expect(captured[1]?.cwd).toBe("/new/dir"); - }); - - it("an explicit request cwd overrides the persisted cwd (and updates it)", async () => { - const store = createInMemoryStore(); - await store.setCwd("conv-override", "/old/dir"); - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-override", - text: "override", - onEvent: () => {}, - cwd: "/new/dir", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/new/dir"); - expect(store.cwdData.get("conv-override")).toBe("/new/dir"); - - await orchestrator.handleMessage({ - conversationId: "conv-override", - text: "reused", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(2); - expect(captured[1]?.cwd).toBe("/new/dir"); - }); - - it("no cwd is threaded when neither request nor store has one", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-no-cwd-either", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBeUndefined(); - }); + it("uses the persisted cwd when the request omits cwd", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-persisted", "/persisted/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persisted", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/persisted/dir"); + }); + + it("persists the cwd when the request provides one (and a later cwd-less turn reuses it)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "first", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-persist-new")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "second", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("an explicit request cwd overrides the persisted cwd (and updates it)", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-override", "/old/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "override", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-override")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "reused", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("no cwd is threaded when neither request nor store has one", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-cwd-either", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBeUndefined(); + }); }); describe("detached turn hub", () => { - function waitForEvent( - orchestrator: ReturnType["orchestrator"], - conversationId: string, - eventType: string, - ): Promise { - return new Promise((resolve) => { - const unsub = orchestrator.subscribe(conversationId, (event) => { - if (event.type === eventType) { - unsub(); - resolve(event); - } - }); - }); - } - - it("subscribe-BEFORE-startTurn delivers — listener receives full ordered event sequence", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-pre-sub", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-pre-sub", text: "Hi" }); - - const sealed = waitForEvent(orchestrator, "conv-pre-sub", "turn-sealed"); - await sealed; - - unsub(); - - expect(events.length).toBeGreaterThan(0); - const types = events.map((e) => e.type); - expect(types[0]).toBe("user-message"); - expect(types[1]).toBe("turn-start"); - expect(types).toContain("text-delta"); - expect(types[types.length - 1]).toBe("turn-sealed"); - - const textDeltas = events.filter((e) => e.type === "text-delta"); - expect(textDeltas).toHaveLength(2); - }); - - it("multi-subscriber fan-out (subscribed before start) — two listeners receive identical ordered events", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const eventsA: AgentEvent[] = []; - const eventsB: AgentEvent[] = []; - const unsubA = orchestrator.subscribe("conv-fanout-pre", (e) => eventsA.push(e)); - const unsubB = orchestrator.subscribe("conv-fanout-pre", (e) => eventsB.push(e)); - - orchestrator.startTurn({ conversationId: "conv-fanout-pre", text: "Hi" }); - - const sealed = waitForEvent(orchestrator, "conv-fanout-pre", "turn-sealed"); - await sealed; - - unsubA(); - unsubB(); - - expect(eventsA.length).toBeGreaterThan(0); - expect(eventsA).toEqual(eventsB); - - const types = eventsA.map((e) => e.type); - expect(types[0]).toBe("user-message"); - expect(types[1]).toBe("turn-start"); - expect(types[types.length - 1]).toBe("turn-sealed"); - }); - - it("late-join replay — subscriber added mid-turn receives buffered events then live events, no gap/dup", async () => { - const store = createInMemoryStore(); - let emitBarrierResolve: (() => void) | undefined; - const emitBarrier = new Promise((resolve) => { - emitBarrierResolve = resolve; - }); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - yield { type: "text-delta", delta: " world" } as ProviderEvent; - await emitBarrier; - yield { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - orchestrator.startTurn({ conversationId: "conv-latejoin", text: "Hi" }); - - const earlyEvents: AgentEvent[] = []; - const unsubEarly = orchestrator.subscribe("conv-latejoin", (e) => earlyEvents.push(e)); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const lateEvents: AgentEvent[] = []; - const unsubLate = orchestrator.subscribe("conv-latejoin", (e) => lateEvents.push(e)); - - const earlySnapshot = [...earlyEvents]; - expect(earlySnapshot.length).toBeGreaterThanOrEqual(2); - expect(earlySnapshot.some((e) => e.type === "turn-start")).toBe(true); - expect(earlySnapshot.some((e) => e.type === "text-delta")).toBe(true); - - expect(lateEvents.length).toBe(earlySnapshot.length); - expect(lateEvents).toEqual(earlySnapshot); - - emitBarrierResolve?.(); - - const sealed = waitForEvent(orchestrator, "conv-latejoin", "turn-sealed"); - await sealed; - - unsubEarly(); - unsubLate(); - - expect(earlyEvents.length).toBeGreaterThan(earlySnapshot.length); - expect(lateEvents.length).toBe(earlyEvents.length); - expect(lateEvents).toEqual(earlyEvents); - }); - - it("subscriber persists across turns — one subscriber receives events from two sequential turns", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Turn1" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - [ - { type: "text-delta", delta: "Turn2" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const allEvents: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-persist", (e) => allEvents.push(e)); - - // First turn - orchestrator.startTurn({ conversationId: "conv-persist", text: "First" }); - const sealed1 = waitForEvent(orchestrator, "conv-persist", "turn-sealed"); - await sealed1; - - // Second turn - orchestrator.startTurn({ conversationId: "conv-persist", text: "Second" }); - const sealed2 = waitForEvent(orchestrator, "conv-persist", "turn-sealed"); - await sealed2; - - unsub(); - - const turnStarts = allEvents.filter((e) => e.type === "turn-start"); - expect(turnStarts).toHaveLength(2); - - const turnSealeds = allEvents.filter((e) => e.type === "turn-sealed"); - expect(turnSealeds).toHaveLength(2); - - const textDeltas = allEvents.filter((e) => e.type === "text-delta"); - expect(textDeltas).toHaveLength(2); - expect((textDeltas[0] as AgentEvent & { type: "text-delta" }).delta).toBe("Turn1"); - expect((textDeltas[1] as AgentEvent & { type: "text-delta" }).delta).toBe("Turn2"); - }); - - it("detached completion — turn runs to completion with zero subscribers and persists", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const result = orchestrator.startTurn({ conversationId: "conv-detached", text: "Hi" }); - expect(result.started).toBe(true); - const sealed = waitForEvent(orchestrator, "conv-detached", "turn-sealed"); - - await sealed; - - const stored = store.data.get("conv-detached"); - expect(stored).toBeDefined(); - expect(stored).toHaveLength(2); - expect(stored?.[0]?.role).toBe("user"); - expect(stored?.[1]?.role).toBe("assistant"); - }); - - it("single-flight reject — startTurn while active returns already-active, no second turn", async () => { - const store = createInMemoryStore(); - let resolveRunTurn: (() => void) | undefined; - const runTurnBlocker = new Promise((resolve) => { - resolveRunTurn = resolve; - }); - - const provider: ProviderContract = { - id: "fake", - stream: async function* () { - yield { type: "text-delta", delta: "slow" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const blockingRunTurn = async (_input: RunTurnInput): Promise => { - await runTurnBlocker; - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingRunTurn, - }); - - const first = orchestrator.startTurn({ conversationId: "conv-singleflight", text: "first" }); - expect(first.started).toBe(true); - const sealed = waitForEvent(orchestrator, "conv-singleflight", "turn-sealed"); - - const second = orchestrator.startTurn({ conversationId: "conv-singleflight", text: "second" }); - expect(second.started).toBe(false); - if (!second.started) { - expect(second.reason).toBe("already-active"); - } - - resolveRunTurn?.(); - await sealed; - - expect(store.data.get("conv-singleflight")?.length).toBe(2); - }); - - it("isActive false after seal — subscribe replays nothing after turn-sealed", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - expect(orchestrator.isActive("conv-cleared")).toBe(false); - - orchestrator.startTurn({ conversationId: "conv-cleared", text: "test" }); - const sealed = waitForEvent(orchestrator, "conv-cleared", "turn-sealed"); - - await sealed; - - expect(orchestrator.isActive("conv-cleared")).toBe(false); - - const lateEvents: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-cleared", (e) => lateEvents.push(e)); - unsub(); - - expect(lateEvents).toHaveLength(0); - }); - - it("handleMessage convenience — drives turn end-to-end via onEvent and resolves on seal", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const events: AgentEvent[] = []; - await orchestrator.handleMessage({ - conversationId: "conv-hm", - text: "Hi", - onEvent: (e) => events.push(e), - }); - - const types = events.map((e) => e.type); - expect(types[0]).toBe("user-message"); - expect(types[1]).toBe("turn-start"); - expect(types).toContain("text-delta"); - expect(types[types.length - 1]).toBe("turn-sealed"); - - const stored = store.data.get("conv-hm"); - expect(stored).toHaveLength(2); - }); - - it("handleMessage already-active emits error event and resolves without hanging", async () => { - const store = createInMemoryStore(); - let resolveRunTurn: (() => void) | undefined; - const runTurnBlocker = new Promise((resolve) => { - resolveRunTurn = resolve; - }); - - const provider: ProviderContract = { - id: "fake", - stream: async function* () { - yield { type: "text-delta", delta: "slow" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const blockingRunTurn = async (_input: RunTurnInput): Promise => { - await runTurnBlocker; - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingRunTurn, - }); - - const firstEvents: AgentEvent[] = []; - const firstPromise = orchestrator.handleMessage({ - conversationId: "conv-hm-active", - text: "first", - onEvent: (e) => firstEvents.push(e), - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const secondEvents: AgentEvent[] = []; - const secondPromise = orchestrator.handleMessage({ - conversationId: "conv-hm-active", - text: "second", - onEvent: (e) => secondEvents.push(e), - }); - - await secondPromise; - - expect(secondEvents).toHaveLength(1); - expect(secondEvents[0]?.type).toBe("error"); - expect((secondEvents[0] as AgentEvent & { type: "error" }).message).toBe( - "turn already active for this conversation", - ); - - resolveRunTurn?.(); - await firstPromise; - - expect(firstEvents.some((e) => e.type === "turn-sealed")).toBe(true); - }); + function waitForEvent( + orchestrator: ReturnType["orchestrator"], + conversationId: string, + eventType: string, + ): Promise { + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (event) => { + if (event.type === eventType) { + unsub(); + resolve(event); + } + }); + }); + } + + it("subscribe-BEFORE-startTurn delivers — listener receives full ordered event sequence", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-pre-sub", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-pre-sub", text: "Hi" }); + + const sealed = waitForEvent(orchestrator, "conv-pre-sub", "turn-sealed"); + await sealed; + + unsub(); + + expect(events.length).toBeGreaterThan(0); + const types = events.map((e) => e.type); + expect(types[0]).toBe("user-message"); + expect(types[1]).toBe("turn-start"); + expect(types).toContain("text-delta"); + expect(types[types.length - 1]).toBe("turn-sealed"); + + const textDeltas = events.filter((e) => e.type === "text-delta"); + expect(textDeltas).toHaveLength(2); + }); + + it("multi-subscriber fan-out (subscribed before start) — two listeners receive identical ordered events", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const eventsA: AgentEvent[] = []; + const eventsB: AgentEvent[] = []; + const unsubA = orchestrator.subscribe("conv-fanout-pre", (e) => eventsA.push(e)); + const unsubB = orchestrator.subscribe("conv-fanout-pre", (e) => eventsB.push(e)); + + orchestrator.startTurn({ conversationId: "conv-fanout-pre", text: "Hi" }); + + const sealed = waitForEvent(orchestrator, "conv-fanout-pre", "turn-sealed"); + await sealed; + + unsubA(); + unsubB(); + + expect(eventsA.length).toBeGreaterThan(0); + expect(eventsA).toEqual(eventsB); + + const types = eventsA.map((e) => e.type); + expect(types[0]).toBe("user-message"); + expect(types[1]).toBe("turn-start"); + expect(types[types.length - 1]).toBe("turn-sealed"); + }); + + it("late-join replay — subscriber added mid-turn receives buffered events then live events, no gap/dup", async () => { + const store = createInMemoryStore(); + let emitBarrierResolve: (() => void) | undefined; + const emitBarrier = new Promise((resolve) => { + emitBarrierResolve = resolve; + }); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + yield { type: "text-delta", delta: " world" } as ProviderEvent; + await emitBarrier; + yield { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + orchestrator.startTurn({ conversationId: "conv-latejoin", text: "Hi" }); + + const earlyEvents: AgentEvent[] = []; + const unsubEarly = orchestrator.subscribe("conv-latejoin", (e) => earlyEvents.push(e)); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const lateEvents: AgentEvent[] = []; + const unsubLate = orchestrator.subscribe("conv-latejoin", (e) => lateEvents.push(e)); + + const earlySnapshot = [...earlyEvents]; + expect(earlySnapshot.length).toBeGreaterThanOrEqual(2); + expect(earlySnapshot.some((e) => e.type === "turn-start")).toBe(true); + expect(earlySnapshot.some((e) => e.type === "text-delta")).toBe(true); + + expect(lateEvents.length).toBe(earlySnapshot.length); + expect(lateEvents).toEqual(earlySnapshot); + + emitBarrierResolve?.(); + + const sealed = waitForEvent(orchestrator, "conv-latejoin", "turn-sealed"); + await sealed; + + unsubEarly(); + unsubLate(); + + expect(earlyEvents.length).toBeGreaterThan(earlySnapshot.length); + expect(lateEvents.length).toBe(earlyEvents.length); + expect(lateEvents).toEqual(earlyEvents); + }); + + it("subscriber persists across turns — one subscriber receives events from two sequential turns", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Turn1" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + [ + { type: "text-delta", delta: "Turn2" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const allEvents: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-persist", (e) => allEvents.push(e)); + + // First turn + orchestrator.startTurn({ conversationId: "conv-persist", text: "First" }); + const sealed1 = waitForEvent(orchestrator, "conv-persist", "turn-sealed"); + await sealed1; + + // Second turn + orchestrator.startTurn({ conversationId: "conv-persist", text: "Second" }); + const sealed2 = waitForEvent(orchestrator, "conv-persist", "turn-sealed"); + await sealed2; + + unsub(); + + const turnStarts = allEvents.filter((e) => e.type === "turn-start"); + expect(turnStarts).toHaveLength(2); + + const turnSealeds = allEvents.filter((e) => e.type === "turn-sealed"); + expect(turnSealeds).toHaveLength(2); + + const textDeltas = allEvents.filter((e) => e.type === "text-delta"); + expect(textDeltas).toHaveLength(2); + expect((textDeltas[0] as AgentEvent & { type: "text-delta" }).delta).toBe("Turn1"); + expect((textDeltas[1] as AgentEvent & { type: "text-delta" }).delta).toBe("Turn2"); + }); + + it("detached completion — turn runs to completion with zero subscribers and persists", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const result = orchestrator.startTurn({ conversationId: "conv-detached", text: "Hi" }); + expect(result.started).toBe(true); + const sealed = waitForEvent(orchestrator, "conv-detached", "turn-sealed"); + + await sealed; + + const stored = store.data.get("conv-detached"); + expect(stored).toBeDefined(); + expect(stored).toHaveLength(2); + expect(stored?.[0]?.role).toBe("user"); + expect(stored?.[1]?.role).toBe("assistant"); + }); + + it("single-flight reject — startTurn while active returns already-active, no second turn", async () => { + const store = createInMemoryStore(); + let resolveRunTurn: (() => void) | undefined; + const runTurnBlocker = new Promise((resolve) => { + resolveRunTurn = resolve; + }); + + const provider: ProviderContract = { + id: "fake", + stream: async function* () { + yield { type: "text-delta", delta: "slow" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const blockingRunTurn = async (_input: RunTurnInput): Promise => { + await runTurnBlocker; + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingRunTurn, + }); + + const first = orchestrator.startTurn({ conversationId: "conv-singleflight", text: "first" }); + expect(first.started).toBe(true); + const sealed = waitForEvent(orchestrator, "conv-singleflight", "turn-sealed"); + + const second = orchestrator.startTurn({ conversationId: "conv-singleflight", text: "second" }); + expect(second.started).toBe(false); + if (!second.started) { + expect(second.reason).toBe("already-active"); + } + + resolveRunTurn?.(); + await sealed; + + expect(store.data.get("conv-singleflight")?.length).toBe(2); + }); + + it("isActive false after seal — subscribe replays nothing after turn-sealed", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + expect(orchestrator.isActive("conv-cleared")).toBe(false); + + orchestrator.startTurn({ conversationId: "conv-cleared", text: "test" }); + const sealed = waitForEvent(orchestrator, "conv-cleared", "turn-sealed"); + + await sealed; + + expect(orchestrator.isActive("conv-cleared")).toBe(false); + + const lateEvents: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-cleared", (e) => lateEvents.push(e)); + unsub(); + + expect(lateEvents).toHaveLength(0); + }); + + it("handleMessage convenience — drives turn end-to-end via onEvent and resolves on seal", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const events: AgentEvent[] = []; + await orchestrator.handleMessage({ + conversationId: "conv-hm", + text: "Hi", + onEvent: (e) => events.push(e), + }); + + const types = events.map((e) => e.type); + expect(types[0]).toBe("user-message"); + expect(types[1]).toBe("turn-start"); + expect(types).toContain("text-delta"); + expect(types[types.length - 1]).toBe("turn-sealed"); + + const stored = store.data.get("conv-hm"); + expect(stored).toHaveLength(2); + }); + + it("handleMessage already-active emits error event and resolves without hanging", async () => { + const store = createInMemoryStore(); + let resolveRunTurn: (() => void) | undefined; + const runTurnBlocker = new Promise((resolve) => { + resolveRunTurn = resolve; + }); + + const provider: ProviderContract = { + id: "fake", + stream: async function* () { + yield { type: "text-delta", delta: "slow" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const blockingRunTurn = async (_input: RunTurnInput): Promise => { + await runTurnBlocker; + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingRunTurn, + }); + + const firstEvents: AgentEvent[] = []; + const firstPromise = orchestrator.handleMessage({ + conversationId: "conv-hm-active", + text: "first", + onEvent: (e) => firstEvents.push(e), + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const secondEvents: AgentEvent[] = []; + const secondPromise = orchestrator.handleMessage({ + conversationId: "conv-hm-active", + text: "second", + onEvent: (e) => secondEvents.push(e), + }); + + await secondPromise; + + expect(secondEvents).toHaveLength(1); + expect(secondEvents[0]?.type).toBe("error"); + expect((secondEvents[0] as AgentEvent & { type: "error" }).message).toBe( + "turn already active for this conversation", + ); + + resolveRunTurn?.(); + await firstPromise; + + expect(firstEvents.some((e) => e.type === "turn-sealed")).toBe(true); + }); }); describe("user-message event", () => { - function waitForEvent( - orchestrator: ReturnType["orchestrator"], - conversationId: string, - eventType: string, - ): Promise { - return new Promise((resolve) => { - const unsub = orchestrator.subscribe(conversationId, (event) => { - if (event.type === eventType) { - unsub(); - resolve(event); - } - }); - }); - } - - it("emits user-message first — pre-subscriber receives user-message before turn-start", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "Hello" }, - { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-um-first", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-um-first", text: "What is 2+2?" }); - - const sealed = waitForEvent(orchestrator, "conv-um-first", "turn-sealed"); - await sealed; - unsub(); - - expect(events.length).toBeGreaterThan(1); - expect(events[0]?.type).toBe("user-message"); - const um = events[0] as AgentEvent & { type: "user-message" }; - expect(um.text).toBe("What is 2+2?"); - expect(um.conversationId).toBe("conv-um-first"); - expect(um.turnId).toMatch(/^turn-/); - expect(events[1]?.type).toBe("turn-start"); - }); - - it("late-join replays user-message — buffer starts with user-message", async () => { - const store = createInMemoryStore(); - let emitBarrierResolve: (() => void) | undefined; - const emitBarrier = new Promise((resolve) => { - emitBarrierResolve = resolve; - }); - - let callIndex = 0; - const provider: ProviderContract = { - id: "fake", - stream() { - const idx = callIndex++; - return (async function* () { - if (idx === 0) { - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - await emitBarrier; - yield { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - } - })(); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - }); - - orchestrator.startTurn({ conversationId: "conv-um-late", text: "late prompt" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const lateEvents: AgentEvent[] = []; - const unsubLate = orchestrator.subscribe("conv-um-late", (e) => lateEvents.push(e)); - - expect(lateEvents.length).toBeGreaterThanOrEqual(1); - expect(lateEvents[0]?.type).toBe("user-message"); - const um = lateEvents[0] as AgentEvent & { type: "user-message" }; - expect(um.text).toBe("late prompt"); - expect(um.turnId).toMatch(/^turn-/); - - emitBarrierResolve?.(); - const sealed = waitForEvent(orchestrator, "conv-um-late", "turn-sealed"); - await sealed; - unsubLate(); - }); - - it("metrics unaffected — user-message does not alter TurnMetrics", async () => { - const store = createInMemoryStore(); - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - now: () => 1000, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-um-metrics", - text: "test", - onEvent: () => {}, - }); - - const metrics = store.metricsData.get("conv-um-metrics"); - expect(metrics).toBeDefined(); - expect(metrics).toHaveLength(1); - - const tm = metrics?.[0]; - if (tm === undefined) throw new Error("expected metrics"); - - expect(tm.turnId).toMatch(/^turn-/); - expect(tm.usage.inputTokens).toBe(10); - expect(tm.usage.outputTokens).toBe(5); - expect(tm.steps).toHaveLength(1); - expect(tm.steps[0]?.usage.inputTokens).toBe(10); - expect(tm.steps[0]?.usage.outputTokens).toBe(5); - }); + function waitForEvent( + orchestrator: ReturnType["orchestrator"], + conversationId: string, + eventType: string, + ): Promise { + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (event) => { + if (event.type === eventType) { + unsub(); + resolve(event); + } + }); + }); + } + + it("emits user-message first — pre-subscriber receives user-message before turn-start", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "Hello" }, + { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-um-first", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-um-first", text: "What is 2+2?" }); + + const sealed = waitForEvent(orchestrator, "conv-um-first", "turn-sealed"); + await sealed; + unsub(); + + expect(events.length).toBeGreaterThan(1); + expect(events[0]?.type).toBe("user-message"); + const um = events[0] as AgentEvent & { type: "user-message" }; + expect(um.text).toBe("What is 2+2?"); + expect(um.conversationId).toBe("conv-um-first"); + expect(um.turnId).toMatch(/^turn-/); + expect(events[1]?.type).toBe("turn-start"); + }); + + it("late-join replays user-message — buffer starts with user-message", async () => { + const store = createInMemoryStore(); + let emitBarrierResolve: (() => void) | undefined; + const emitBarrier = new Promise((resolve) => { + emitBarrierResolve = resolve; + }); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + await emitBarrier; + yield { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + }); + + orchestrator.startTurn({ conversationId: "conv-um-late", text: "late prompt" }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const lateEvents: AgentEvent[] = []; + const unsubLate = orchestrator.subscribe("conv-um-late", (e) => lateEvents.push(e)); + + expect(lateEvents.length).toBeGreaterThanOrEqual(1); + expect(lateEvents[0]?.type).toBe("user-message"); + const um = lateEvents[0] as AgentEvent & { type: "user-message" }; + expect(um.text).toBe("late prompt"); + expect(um.turnId).toMatch(/^turn-/); + + emitBarrierResolve?.(); + const sealed = waitForEvent(orchestrator, "conv-um-late", "turn-sealed"); + await sealed; + unsubLate(); + }); + + it("metrics unaffected — user-message does not alter TurnMetrics", async () => { + const store = createInMemoryStore(); + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-um-metrics", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-um-metrics"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.turnId).toMatch(/^turn-/); + expect(tm.usage.inputTokens).toBe(10); + expect(tm.usage.outputTokens).toBe(5); + expect(tm.steps).toHaveLength(1); + expect(tm.steps[0]?.usage.inputTokens).toBe(10); + expect(tm.steps[0]?.usage.outputTokens).toBe(5); + }); }); describe("closeConversation (CR-4c)", () => { - it("aborts an in-flight turn: done.reason 'aborted', partial messages persisted, turn seals", async () => { - const store = createInMemoryStore(); - let releaseStream: (() => void) | undefined; - const barrier = new Promise((resolve) => { - releaseStream = resolve; - }); - const provider: ProviderContract = { - id: "fake", - stream() { - return (async function* () { - yield { type: "text-delta", delta: "Hello" } as ProviderEvent; - await barrier; - yield { type: "text-delta", delta: " world" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const emittedHooks: string[] = []; - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: (hook) => { - emittedHooks.push(hook.id); - }, - }); - - const events: AgentEvent[] = []; - let resolveSealed: (() => void) | undefined; - const sealed = new Promise((resolve) => { - resolveSealed = resolve; - }); - let resolveFirstDelta: (() => void) | undefined; - const firstDelta = new Promise((resolve) => { - resolveFirstDelta = resolve; - }); - orchestrator.subscribe("conv-close", (e) => { - events.push(e); - if (e.type === "text-delta") resolveFirstDelta?.(); - if (e.type === "turn-sealed") resolveSealed?.(); - }); - - orchestrator.startTurn({ conversationId: "conv-close", text: "Hi" }); - await firstDelta; - - const result = orchestrator.closeConversation("conv-close"); - expect(result.abortedTurn).toBe(true); - expect(emittedHooks).toContain("session-orchestrator/conversation-closed"); - - releaseStream?.(); - await sealed; - - const done = events.find((e): e is Extract => e.type === "done"); - expect(done?.reason).toBe("aborted"); - expect(orchestrator.isActive("conv-close")).toBe(false); - - // Durability: the partial turn persisted normally (user msg + partial reply). - const persisted = store.data.get("conv-close") ?? []; - expect(persisted.length).toBeGreaterThanOrEqual(1); - expect(persisted[0]?.role).toBe("user"); - }); - - it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", async () => { - const store = createInMemoryStore(); - const emitted: Array<{ hook: string; payload: unknown }> = []; - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => createFakeProvider([]), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: (hook, payload) => { - emitted.push({ hook: hook.id, payload }); - }, - }); - - const result = orchestrator.closeConversation("conv-never-seen"); - expect(result.abortedTurn).toBe(false); - // The conversation-closed hook is emitted synchronously; the - // status-changed hook resolves the workspace id async before emitting. - expect(emitted).toEqual([ - { - hook: "session-orchestrator/conversation-closed", - payload: { conversationId: "conv-never-seen" }, - }, - ]); - // Flush the async getWorkspaceId resolution so the status-changed emit lands. - await new Promise((resolve) => setImmediate(resolve)); - expect(emitted).toContainEqual({ - hook: "session-orchestrator/conversation-status-changed", - payload: { conversationId: "conv-never-seen", status: "closed", workspaceId: "default" }, - }); - - // Closing again is still safe. - expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false); - }); + it("aborts an in-flight turn: done.reason 'aborted', partial messages persisted, turn seals", async () => { + const store = createInMemoryStore(); + let releaseStream: (() => void) | undefined; + const barrier = new Promise((resolve) => { + releaseStream = resolve; + }); + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + await barrier; + yield { type: "text-delta", delta: " world" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const emittedHooks: string[] = []; + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook) => { + emittedHooks.push(hook.id); + }, + }); + + const events: AgentEvent[] = []; + let resolveSealed: (() => void) | undefined; + const sealed = new Promise((resolve) => { + resolveSealed = resolve; + }); + let resolveFirstDelta: (() => void) | undefined; + const firstDelta = new Promise((resolve) => { + resolveFirstDelta = resolve; + }); + orchestrator.subscribe("conv-close", (e) => { + events.push(e); + if (e.type === "text-delta") resolveFirstDelta?.(); + if (e.type === "turn-sealed") resolveSealed?.(); + }); + + orchestrator.startTurn({ conversationId: "conv-close", text: "Hi" }); + await firstDelta; + + const result = orchestrator.closeConversation("conv-close"); + expect(result.abortedTurn).toBe(true); + expect(emittedHooks).toContain("session-orchestrator/conversation-closed"); + + releaseStream?.(); + await sealed; + + const done = events.find((e): e is Extract => e.type === "done"); + expect(done?.reason).toBe("aborted"); + expect(orchestrator.isActive("conv-close")).toBe(false); + + // Durability: the partial turn persisted normally (user msg + partial reply). + const persisted = store.data.get("conv-close") ?? []; + expect(persisted.length).toBeGreaterThanOrEqual(1); + expect(persisted[0]?.role).toBe("user"); + }); + + it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", async () => { + const store = createInMemoryStore(); + const emitted: Array<{ hook: string; payload: unknown }> = []; + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => createFakeProvider([]), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + emitted.push({ hook: hook.id, payload }); + }, + }); + + const result = orchestrator.closeConversation("conv-never-seen"); + expect(result.abortedTurn).toBe(false); + // The conversation-closed hook is emitted synchronously; the + // status-changed hook resolves the workspace id async before emitting. + expect(emitted).toEqual([ + { + hook: "session-orchestrator/conversation-closed", + payload: { conversationId: "conv-never-seen" }, + }, + ]); + // Flush the async getWorkspaceId resolution so the status-changed emit lands. + await new Promise((resolve) => setImmediate(resolve)); + expect(emitted).toContainEqual({ + hook: "session-orchestrator/conversation-status-changed", + payload: { conversationId: "conv-never-seen", status: "closed", workspaceId: "default" }, + }); + + // Closing again is still safe. + expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false); + }); }); // --- workspace id on conversationOpened / conversationStatusChanged payloads --- describe("workspace id broadcast payloads", () => { - it("conversationStatusChanged payload carries the workspace id from the store", async () => { - const base = createInMemoryStore(); - // Pre-assign a non-default workspace so we can assert it's threaded - // through (not the per-turn start option, which differs). - await base.setWorkspaceId("conv-ws-broadcast", "team-workspace"); - - const emitted: Array<{ hook: string; payload: ConversationStatusChangedPayload }> = []; - const provider = createFakeProvider([ - [ - { type: "text-delta", delta: "ok" }, - { type: "finish", reason: "stop" }, - ], - ]); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: base, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: (hook, payload) => { - if (hook.id === "session-orchestrator/conversation-status-changed") { - emitted.push({ - hook: hook.id, - payload: payload as ConversationStatusChangedPayload, - }); - } - }, - }); - - // Pass a DIFFERENT per-turn workspaceId to prove the payload uses the - // persisted store value, not the start option. - await orchestrator.handleMessage({ - conversationId: "conv-ws-broadcast", - text: "hi", - onEvent: () => {}, - workspaceId: "should-not-appear", - }); - // Flush the async getWorkspaceId resolutions. - await new Promise((resolve) => setImmediate(resolve)); - - expect(emitted.length).toBeGreaterThanOrEqual(2); - for (const e of emitted) { - expect(e.payload.workspaceId).toBe("team-workspace"); - } - - // closeConversation also threads the persisted workspace id. - const closeEmitted: ConversationStatusChangedPayload[] = []; - const { orchestrator: orchestrator2 } = createSessionOrchestrator({ - conversationStore: base, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: (hook, payload) => { - if (hook.id === "session-orchestrator/conversation-status-changed") { - closeEmitted.push(payload as ConversationStatusChangedPayload); - } - }, - }); - orchestrator2.closeConversation("conv-ws-broadcast"); - await new Promise((resolve) => setImmediate(resolve)); - const closed = closeEmitted.find((p) => p.status === "closed"); - expect(closed).toBeDefined(); - expect(closed?.workspaceId).toBe("team-workspace"); - }); - - it("conversationOpened payload carries the workspace id (type-level construct)", () => { - // conversationOpened is emitted by a sibling transport unit, so this - // package only owns the payload TYPE. This regression test pins the - // type to require workspaceId (a missing field would fail to compile) - // and verifies the persisted value flows through at construction time. - const payload: ConversationOpenedPayload = { - conversationId: "conv-open", - workspaceId: "open-workspace", - }; - expect(payload.workspaceId).toBe("open-workspace"); - expect(payload.conversationId).toBe("conv-open"); - }); + it("conversationStatusChanged payload carries the workspace id from the store", async () => { + const base = createInMemoryStore(); + // Pre-assign a non-default workspace so we can assert it's threaded + // through (not the per-turn start option, which differs). + await base.setWorkspaceId("conv-ws-broadcast", "team-workspace"); + + const emitted: Array<{ hook: string; payload: ConversationStatusChangedPayload }> = []; + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: base, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + if (hook.id === "session-orchestrator/conversation-status-changed") { + emitted.push({ + hook: hook.id, + payload: payload as ConversationStatusChangedPayload, + }); + } + }, + }); + + // Pass a DIFFERENT per-turn workspaceId to prove the payload uses the + // persisted store value, not the start option. + await orchestrator.handleMessage({ + conversationId: "conv-ws-broadcast", + text: "hi", + onEvent: () => {}, + workspaceId: "should-not-appear", + }); + // Flush the async getWorkspaceId resolutions. + await new Promise((resolve) => setImmediate(resolve)); + + expect(emitted.length).toBeGreaterThanOrEqual(2); + for (const e of emitted) { + expect(e.payload.workspaceId).toBe("team-workspace"); + } + + // closeConversation also threads the persisted workspace id. + const closeEmitted: ConversationStatusChangedPayload[] = []; + const { orchestrator: orchestrator2 } = createSessionOrchestrator({ + conversationStore: base, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + if (hook.id === "session-orchestrator/conversation-status-changed") { + closeEmitted.push(payload as ConversationStatusChangedPayload); + } + }, + }); + orchestrator2.closeConversation("conv-ws-broadcast"); + await new Promise((resolve) => setImmediate(resolve)); + const closed = closeEmitted.find((p) => p.status === "closed"); + expect(closed).toBeDefined(); + expect(closed?.workspaceId).toBe("team-workspace"); + }); + + it("conversationOpened payload carries the workspace id (type-level construct)", () => { + // conversationOpened is emitted by a sibling transport unit, so this + // package only owns the payload TYPE. This regression test pins the + // type to require workspaceId (a missing field would fail to compile) + // and verifies the persisted value flows through at construction time. + const payload: ConversationOpenedPayload = { + conversationId: "conv-open", + workspaceId: "open-workspace", + }; + expect(payload.workspaceId).toBe("open-workspace"); + expect(payload.conversationId).toBe("conv-open"); + }); }); describe("reasoning effort resolution", () => { - it("override wins over stored → provider receives the override level", async () => { - const store = createInMemoryStore(); - await store.setReasoningEffort("conv-effort-override", "low"); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-effort-override", - text: "hi", - onEvent: () => {}, - reasoningEffort: "max", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.reasoningEffort).toBe("max"); - }); - - it("no override, store has a value → provider receives the stored value", async () => { - const store = createInMemoryStore(); - await store.setReasoningEffort("conv-effort-stored", "xhigh"); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-effort-stored", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.reasoningEffort).toBe("xhigh"); - }); - - it("no override, store empty → provider receives 'high' (default)", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-effort-default", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.reasoningEffort).toBe("high"); - }); - - it("warm receives the same resolved effort as a real turn for the same conversation", async () => { - const store = createInMemoryStore(); - await store.append("conv-warm-effort", [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - ]); - await store.setReasoningEffort("conv-warm-effort", "medium"); - - let warmOpts: ProviderStreamOptions | undefined; - - const provider: ProviderContract = { - id: "p", - stream(_messages, _tools, opts) { - warmOpts = opts; - return (async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - emit: () => {}, - }; - - const { orchestrator, activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - await warmService.warm("conv-warm-effort"); - - await orchestrator.handleMessage({ - conversationId: "conv-warm-effort", - text: "hi", - onEvent: () => {}, - }); - - expect(warmOpts?.reasoningEffort).toBe("medium"); - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.reasoningEffort).toBe("medium"); - expect(warmOpts?.reasoningEffort).toBe(captured[0]?.providerOpts?.reasoningEffort); - }); + it("override wins over stored → provider receives the override level", async () => { + const store = createInMemoryStore(); + await store.setReasoningEffort("conv-effort-override", "low"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-override", + text: "hi", + onEvent: () => {}, + reasoningEffort: "max", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("max"); + }); + + it("no override, store has a value → provider receives the stored value", async () => { + const store = createInMemoryStore(); + await store.setReasoningEffort("conv-effort-stored", "xhigh"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-stored", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("xhigh"); + }); + + it("no override, store empty → provider receives 'high' (default)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-default", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("high"); + }); + + it("warm receives the same resolved effort as a real turn for the same conversation", async () => { + const store = createInMemoryStore(); + await store.append("conv-warm-effort", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + await store.setReasoningEffort("conv-warm-effort", "medium"); + + let warmOpts: ProviderStreamOptions | undefined; + + const provider: ProviderContract = { + id: "p", + stream(_messages, _tools, opts) { + warmOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + emit: () => {}, + }; + + const { orchestrator, activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-effort"); + + await orchestrator.handleMessage({ + conversationId: "conv-warm-effort", + text: "hi", + onEvent: () => {}, + }); + + expect(warmOpts?.reasoningEffort).toBe("medium"); + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("medium"); + expect(warmOpts?.reasoningEffort).toBe(captured[0]?.providerOpts?.reasoningEffort); + }); }); // --- Workspace integration (workspaceId threading + effective cwd) --- describe("workspace integration", () => { - function waitForSealed( - orchestrator: ReturnType["orchestrator"], - conversationId: string, - ): Promise { - return new Promise((resolve) => { - const unsub = orchestrator.subscribe(conversationId, (e) => { - if (e.type === "turn-sealed") { - unsub(); - resolve(); - } - }); - }); - } - - it("startTurn stamps workspaceId on new conversation", async () => { - const base = createInMemoryStore(); - const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; - const store: ConversationStore = { - ...base, - async setWorkspaceId(conversationId, workspaceId) { - setWorkspaceIdCalls.push({ conversationId, workspaceId }); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: createCapturingRunTurn().captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-stamp", - text: "hi", - onEvent: () => {}, - workspaceId: "my-workspace", - }); - - expect(setWorkspaceIdCalls).toContainEqual({ - conversationId: "conv-ws-stamp", - workspaceId: "my-workspace", - }); - }); - - it("startTurn defaults workspaceId to default", async () => { - const base = createInMemoryStore(); - const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; - const store: ConversationStore = { - ...base, - async setWorkspaceId(conversationId, workspaceId) { - setWorkspaceIdCalls.push({ conversationId, workspaceId }); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: createCapturingRunTurn().captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-default", - text: "hi", - onEvent: () => {}, - }); - - expect(setWorkspaceIdCalls).toContainEqual({ - conversationId: "conv-ws-default", - workspaceId: "default", - }); - }); - - it("startTurn auto-creates workspace if missing", async () => { - const base = createInMemoryStore(); - const ensureWorkspaceCalls: string[] = []; - const store: ConversationStore = { - ...base, - async ensureWorkspace(id) { - ensureWorkspaceCalls.push(id); - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: createCapturingRunTurn().captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-autocreate", - text: "hi", - onEvent: () => {}, - workspaceId: "brand-new-workspace", - }); - - expect(ensureWorkspaceCalls).toContain("brand-new-workspace"); - }); - - it("startTurn uses effective cwd when no explicit cwd", async () => { - const base = createInMemoryStore(); - const store: ConversationStore = { - ...base, - async getEffectiveCwd() { - return "/workspace/default/cwd"; - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-effcwd", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/workspace/default/cwd"); - }); - - it("startTurn explicit cwd overrides workspace default", async () => { - const base = createInMemoryStore(); - const store: ConversationStore = { - ...base, - async getEffectiveCwd(_conversationId, overrideCwd) { - return overrideCwd ?? "/workspace/default/cwd"; - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-override", - text: "hi", - onEvent: () => {}, - cwd: "/explicit/cwd", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/explicit/cwd"); - }); - - it("startTurn effective cwd null when nothing set", async () => { - const store = createInMemoryStore(); - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-ws-null-cwd", - text: "hi", - onEvent: () => {}, - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBeUndefined(); - }); - - it("warm uses effective cwd", async () => { - const base = createInMemoryStore(); - await base.append("conv-warm-effcwd", [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - ]); - const store: ConversationStore = { - ...base, - async getEffectiveCwd() { - return "/workspace/warm/cwd"; - }, - }; - - let assemblyCwd: string | undefined = "UNSET"; - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: (assembly: ToolAssembly) => { - assemblyCwd = assembly.cwd; - return Promise.resolve(assembly); - }, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - await warmService.warm("conv-warm-effcwd"); - expect(assemblyCwd).toBe("/workspace/warm/cwd"); - }); - - it("enqueue threads workspaceId", async () => { - const base = createInMemoryStore(); - const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; - const store: ConversationStore = { - ...base, - async setWorkspaceId(conversationId, workspaceId) { - setWorkspaceIdCalls.push({ conversationId, workspaceId }); - }, - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: createCapturingRunTurn().captureRunTurn, - }); - - orchestrator.enqueue({ - conversationId: "conv-enq-ws", - text: "hello", - workspaceId: "enqueued-ws", - }); - await waitForSealed(orchestrator, "conv-enq-ws"); - - expect(setWorkspaceIdCalls).toContainEqual({ - conversationId: "conv-enq-ws", - workspaceId: "enqueued-ws", - }); - }); - - // --- cwd-timing invariant: workspace assigned BEFORE getEffectiveCwd --- - - it("new conversation: workspace assigned before getEffectiveCwd resolves (relative per-turn cwd)", async () => { - // A fake store that implements the REAL getEffectiveCwd algorithm: - // a relative overrideCwd is resolved against the workspace's - // defaultCwd via path.resolve. Different workspaces have different - // defaultCwds so we can assert which workspace was active when - // getEffectiveCwd ran. - const workspaceDefaultCwds = new Map([ - ["default", null], - ["my-workspace", "/projects/my-workspace"], - ]); - const assignedWorkspaceIds = new Map(); - const callOrder: string[] = []; - - const store: ConversationStore = { - ...createInMemoryStore(), - async getConversationMeta(conversationId) { - // A conversation is "known" once setWorkspaceId has been called - // (matching the real store, where setWorkspaceId creates a meta - // row). This lets us assert the ordering: getConversationMeta - // sees null first (new), then setWorkspaceId is called, then - // getEffectiveCwd runs and sees the assigned workspace. - const wsId = assignedWorkspaceIds.get(conversationId); - return wsId !== undefined - ? { - id: conversationId, - createdAt: 0, - lastActivityAt: 0, - title: "Untitled", - status: "idle", - workspaceId: wsId, - } - : null; - }, - async ensureWorkspace(id) { - callOrder.push(`ensureWorkspace:${id}`); - return { - id, - title: id, - defaultCwd: workspaceDefaultCwds.get(id) ?? null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceId(conversationId, workspaceId) { - callOrder.push(`setWorkspaceId:${workspaceId}`); - assignedWorkspaceIds.set(conversationId, workspaceId); - }, - async getWorkspaceId(conversationId) { - return assignedWorkspaceIds.get(conversationId) ?? "default"; - }, - async getWorkspace(id) { - const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async getEffectiveCwd(conversationId, overrideCwd) { - // Real algorithm: relative cwd resolved against workspace defaultCwd. - const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; - callOrder.push(`getEffectiveCwd(workspace=${wsId})`); - const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null; - const conversationCwd = overrideCwd ?? null; - if (conversationCwd === null) { - return workspaceCwd; - } - if (conversationCwd.startsWith("/")) { - return conversationCwd; - } - return pathResolve(workspaceCwd ?? "/server-default", conversationCwd); - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-cwd-timing", - text: "hi", - onEvent: () => {}, - cwd: "arch-rewrite", - workspaceId: "my-workspace", - }); - - // The workspace was assigned before getEffectiveCwd ran. - const ensureIdx = callOrder.indexOf("ensureWorkspace:my-workspace"); - const setWsIdx = callOrder.indexOf("setWorkspaceId:my-workspace"); - const effCwdIdx = callOrder.indexOf("getEffectiveCwd(workspace=my-workspace)"); - expect(ensureIdx).toBeGreaterThanOrEqual(0); - expect(setWsIdx).toBeGreaterThan(ensureIdx); - expect(effCwdIdx).toBeGreaterThan(setWsIdx); - - // The relative cwd "arch-rewrite" resolved against my-workspace's - // defaultCwd "/projects/my-workspace", NOT against the default - // workspace's null (→ server default / process.cwd()). - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/projects/my-workspace/arch-rewrite"); - }); - - it("new conversation with no per-turn cwd: workspace assigned, effective cwd = workspace defaultCwd", async () => { - const workspaceDefaultCwds = new Map([ - ["default", null], - ["my-workspace", "/projects/my-workspace"], - ]); - const assignedWorkspaceIds = new Map(); - - const store: ConversationStore = { - ...createInMemoryStore(), - async getConversationMeta(conversationId) { - const wsId = assignedWorkspaceIds.get(conversationId); - return wsId !== undefined - ? { - id: conversationId, - createdAt: 0, - lastActivityAt: 0, - title: "Untitled", - status: "idle", - workspaceId: wsId, - } - : null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: workspaceDefaultCwds.get(id) ?? null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceId(conversationId, workspaceId) { - assignedWorkspaceIds.set(conversationId, workspaceId); - }, - async getWorkspaceId(conversationId) { - return assignedWorkspaceIds.get(conversationId) ?? "default"; - }, - async getWorkspace(id) { - const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async getEffectiveCwd(conversationId, overrideCwd) { - const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; - const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null; - const conversationCwd = overrideCwd ?? null; - if (conversationCwd === null) { - return workspaceCwd; - } - if (conversationCwd.startsWith("/")) { - return conversationCwd; - } - return pathResolve(workspaceCwd ?? "/server-default", conversationCwd); - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-cwd-timing-no-cwd", - text: "hi", - onEvent: () => {}, - workspaceId: "my-workspace", - }); - - // No per-turn cwd → effective cwd = workspace defaultCwd. - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("/projects/my-workspace"); - }); - - it("existing conversation: workspace NOT re-assigned, effective cwd resolves as before", async () => { - const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; - const base = createInMemoryStore(); - // Pre-populate the conversation so getConversationMeta returns non-null - // (existing conversation with history + workspace already assigned). - await base.append("conv-existing", [ - { role: "user", chunks: [{ type: "text", text: "previous turn" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]); - - const store: ConversationStore = { - ...base, - async setWorkspaceId(conversationId, workspaceId) { - setWorkspaceIdCalls.push({ conversationId, workspaceId }); - }, - async getEffectiveCwd(_conversationId, overrideCwd) { - return overrideCwd ?? "/existing/workspace/cwd"; - }, - }; - - const { captured, captureRunTurn } = createCapturingRunTurn(); - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-existing", - text: "follow up", - onEvent: () => {}, - cwd: "arch-rewrite", - workspaceId: "should-not-be-stamped", - }); - - // setWorkspaceId was NOT called (existing conversation keeps its workspace). - expect(setWorkspaceIdCalls).toHaveLength(0); - - // Effective cwd still resolves (here via the fake store's override). - expect(captured).toHaveLength(1); - expect(captured[0]?.cwd).toBe("arch-rewrite"); - }); + function waitForSealed( + orchestrator: ReturnType["orchestrator"], + conversationId: string, + ): Promise { + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + unsub(); + resolve(); + } + }); + }); + } + + it("startTurn stamps workspaceId on new conversation", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-stamp", + text: "hi", + onEvent: () => {}, + workspaceId: "my-workspace", + }); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-ws-stamp", + workspaceId: "my-workspace", + }); + }); + + it("startTurn defaults workspaceId to default", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-default", + text: "hi", + onEvent: () => {}, + }); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-ws-default", + workspaceId: "default", + }); + }); + + it("startTurn auto-creates workspace if missing", async () => { + const base = createInMemoryStore(); + const ensureWorkspaceCalls: string[] = []; + const store: ConversationStore = { + ...base, + async ensureWorkspace(id) { + ensureWorkspaceCalls.push(id); + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-autocreate", + text: "hi", + onEvent: () => {}, + workspaceId: "brand-new-workspace", + }); + + expect(ensureWorkspaceCalls).toContain("brand-new-workspace"); + }); + + it("startTurn uses effective cwd when no explicit cwd", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async getEffectiveCwd() { + return "/workspace/default/cwd"; + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-effcwd", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/workspace/default/cwd"); + }); + + it("startTurn explicit cwd overrides workspace default", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async getEffectiveCwd(_conversationId, overrideCwd) { + return overrideCwd ?? "/workspace/default/cwd"; + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-override", + text: "hi", + onEvent: () => {}, + cwd: "/explicit/cwd", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/explicit/cwd"); + }); + + it("startTurn effective cwd null when nothing set", async () => { + const store = createInMemoryStore(); + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-null-cwd", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBeUndefined(); + }); + + it("warm uses effective cwd", async () => { + const base = createInMemoryStore(); + await base.append("conv-warm-effcwd", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + const store: ConversationStore = { + ...base, + async getEffectiveCwd() { + return "/workspace/warm/cwd"; + }, + }; + + let assemblyCwd: string | undefined = "UNSET"; + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: (assembly: ToolAssembly) => { + assemblyCwd = assembly.cwd; + return Promise.resolve(assembly); + }, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-effcwd"); + expect(assemblyCwd).toBe("/workspace/warm/cwd"); + }); + + it("enqueue threads workspaceId", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + orchestrator.enqueue({ + conversationId: "conv-enq-ws", + text: "hello", + workspaceId: "enqueued-ws", + }); + await waitForSealed(orchestrator, "conv-enq-ws"); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-enq-ws", + workspaceId: "enqueued-ws", + }); + }); + + // --- cwd-timing invariant: workspace assigned BEFORE getEffectiveCwd --- + + it("new conversation: workspace assigned before getEffectiveCwd resolves (relative per-turn cwd)", async () => { + // A fake store that implements the REAL getEffectiveCwd algorithm: + // a relative overrideCwd is resolved against the workspace's + // defaultCwd via path.resolve. Different workspaces have different + // defaultCwds so we can assert which workspace was active when + // getEffectiveCwd ran. + const workspaceDefaultCwds = new Map([ + ["default", null], + ["my-workspace", "/projects/my-workspace"], + ]); + const assignedWorkspaceIds = new Map(); + const callOrder: string[] = []; + + const store: ConversationStore = { + ...createInMemoryStore(), + async getConversationMeta(conversationId) { + // A conversation is "known" once setWorkspaceId has been called + // (matching the real store, where setWorkspaceId creates a meta + // row). This lets us assert the ordering: getConversationMeta + // sees null first (new), then setWorkspaceId is called, then + // getEffectiveCwd runs and sees the assigned workspace. + const wsId = assignedWorkspaceIds.get(conversationId); + return wsId !== undefined + ? { + id: conversationId, + createdAt: 0, + lastActivityAt: 0, + title: "Untitled", + status: "idle", + workspaceId: wsId, + } + : null; + }, + async ensureWorkspace(id) { + callOrder.push(`ensureWorkspace:${id}`); + return { + id, + title: id, + defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceId(conversationId, workspaceId) { + callOrder.push(`setWorkspaceId:${workspaceId}`); + assignedWorkspaceIds.set(conversationId, workspaceId); + }, + async getWorkspaceId(conversationId) { + return assignedWorkspaceIds.get(conversationId) ?? "default"; + }, + async getWorkspace(id) { + const defaultCwd = workspaceDefaultCwds.get(id) ?? null; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async getEffectiveCwd(conversationId, overrideCwd) { + // Real algorithm: relative cwd resolved against workspace defaultCwd. + const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; + callOrder.push(`getEffectiveCwd(workspace=${wsId})`); + const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null; + const conversationCwd = overrideCwd ?? null; + if (conversationCwd === null) { + return workspaceCwd; + } + if (conversationCwd.startsWith("/")) { + return conversationCwd; + } + return pathResolve(workspaceCwd ?? "/server-default", conversationCwd); + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-cwd-timing", + text: "hi", + onEvent: () => {}, + cwd: "arch-rewrite", + workspaceId: "my-workspace", + }); + + // The workspace was assigned before getEffectiveCwd ran. + const ensureIdx = callOrder.indexOf("ensureWorkspace:my-workspace"); + const setWsIdx = callOrder.indexOf("setWorkspaceId:my-workspace"); + const effCwdIdx = callOrder.indexOf("getEffectiveCwd(workspace=my-workspace)"); + expect(ensureIdx).toBeGreaterThanOrEqual(0); + expect(setWsIdx).toBeGreaterThan(ensureIdx); + expect(effCwdIdx).toBeGreaterThan(setWsIdx); + + // The relative cwd "arch-rewrite" resolved against my-workspace's + // defaultCwd "/projects/my-workspace", NOT against the default + // workspace's null (→ server default / process.cwd()). + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/projects/my-workspace/arch-rewrite"); + }); + + it("new conversation with no per-turn cwd: workspace assigned, effective cwd = workspace defaultCwd", async () => { + const workspaceDefaultCwds = new Map([ + ["default", null], + ["my-workspace", "/projects/my-workspace"], + ]); + const assignedWorkspaceIds = new Map(); + + const store: ConversationStore = { + ...createInMemoryStore(), + async getConversationMeta(conversationId) { + const wsId = assignedWorkspaceIds.get(conversationId); + return wsId !== undefined + ? { + id: conversationId, + createdAt: 0, + lastActivityAt: 0, + title: "Untitled", + status: "idle", + workspaceId: wsId, + } + : null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceId(conversationId, workspaceId) { + assignedWorkspaceIds.set(conversationId, workspaceId); + }, + async getWorkspaceId(conversationId) { + return assignedWorkspaceIds.get(conversationId) ?? "default"; + }, + async getWorkspace(id) { + const defaultCwd = workspaceDefaultCwds.get(id) ?? null; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async getEffectiveCwd(conversationId, overrideCwd) { + const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; + const workspaceCwd = workspaceDefaultCwds.get(wsId) ?? null; + const conversationCwd = overrideCwd ?? null; + if (conversationCwd === null) { + return workspaceCwd; + } + if (conversationCwd.startsWith("/")) { + return conversationCwd; + } + return pathResolve(workspaceCwd ?? "/server-default", conversationCwd); + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-cwd-timing-no-cwd", + text: "hi", + onEvent: () => {}, + workspaceId: "my-workspace", + }); + + // No per-turn cwd → effective cwd = workspace defaultCwd. + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/projects/my-workspace"); + }); + + it("existing conversation: workspace NOT re-assigned, effective cwd resolves as before", async () => { + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const base = createInMemoryStore(); + // Pre-populate the conversation so getConversationMeta returns non-null + // (existing conversation with history + workspace already assigned). + await base.append("conv-existing", [ + { role: "user", chunks: [{ type: "text", text: "previous turn" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]); + + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + async getEffectiveCwd(_conversationId, overrideCwd) { + return overrideCwd ?? "/existing/workspace/cwd"; + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-existing", + text: "follow up", + onEvent: () => {}, + cwd: "arch-rewrite", + workspaceId: "should-not-be-stamped", + }); + + // setWorkspaceId was NOT called (existing conversation keeps its workspace). + expect(setWorkspaceIdCalls).toHaveLength(0); + + // Effective cwd still resolves (here via the fake store's override). + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("arch-rewrite"); + }); }); describe("getEffectiveCwd override (per-turn cwd resolution)", () => { - it("turn start with a per-turn cwd → getEffectiveCwd called with that cwd as overrideCwd", async () => { - const base = createInMemoryStore(); - const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = - []; - const store: ConversationStore = { - ...base, - async getEffectiveCwd(conversationId, overrideCwd) { - effectiveCwdCalls.push({ conversationId, overrideCwd }); - return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); - }, - }; - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-turn-override", - text: "hi", - onEvent: () => {}, - cwd: "arch-rewrite", - }); - - expect(effectiveCwdCalls).toHaveLength(1); - expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite"); - }); - - it("turn start with no per-turn cwd → getEffectiveCwd called with undefined override", async () => { - const base = createInMemoryStore(); - const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = - []; - const store: ConversationStore = { - ...base, - async getEffectiveCwd(conversationId, overrideCwd) { - effectiveCwdCalls.push({ conversationId, overrideCwd }); - return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); - }, - }; - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - }); - - await orchestrator.handleMessage({ - conversationId: "conv-turn-no-override", - text: "hi", - onEvent: () => {}, - }); - - expect(effectiveCwdCalls).toHaveLength(1); - expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined(); - }); - - it("warm with opts.cwd → getEffectiveCwd called with opts.cwd as override", async () => { - const base = createInMemoryStore(); - await base.append("conv-warm-override", [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - ]); - const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = - []; - const store: ConversationStore = { - ...base, - async getEffectiveCwd(conversationId, overrideCwd) { - effectiveCwdCalls.push({ conversationId, overrideCwd }); - return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); - }, - }; - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - await warmService.warm("conv-warm-override", { cwd: "arch-rewrite" }); - - expect(effectiveCwdCalls).toHaveLength(1); - expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite"); - }); - - it("warm without opts.cwd → getEffectiveCwd called with undefined override", async () => { - const base = createInMemoryStore(); - await base.append("conv-warm-no-override", [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - ]); - const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = - []; - const store: ConversationStore = { - ...base, - async getEffectiveCwd(conversationId, overrideCwd) { - effectiveCwdCalls.push({ conversationId, overrideCwd }); - return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); - }, - }; - - const provider: ProviderContract = { - id: "p", - stream: async function* () { - yield { - type: "usage", - usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, - } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; - - const deps = { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - emit: () => {}, - }; - - const { activeConversations } = createSessionOrchestrator(deps); - const warmService = createWarmService(deps, activeConversations); - - await warmService.warm("conv-warm-no-override"); - - expect(effectiveCwdCalls).toHaveLength(1); - expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined(); - }); + it("turn start with a per-turn cwd → getEffectiveCwd called with that cwd as overrideCwd", async () => { + const base = createInMemoryStore(); + const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = + []; + const store: ConversationStore = { + ...base, + async getEffectiveCwd(conversationId, overrideCwd) { + effectiveCwdCalls.push({ conversationId, overrideCwd }); + return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); + }, + }; + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-turn-override", + text: "hi", + onEvent: () => {}, + cwd: "arch-rewrite", + }); + + expect(effectiveCwdCalls).toHaveLength(1); + expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite"); + }); + + it("turn start with no per-turn cwd → getEffectiveCwd called with undefined override", async () => { + const base = createInMemoryStore(); + const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = + []; + const store: ConversationStore = { + ...base, + async getEffectiveCwd(conversationId, overrideCwd) { + effectiveCwdCalls.push({ conversationId, overrideCwd }); + return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); + }, + }; + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-turn-no-override", + text: "hi", + onEvent: () => {}, + }); + + expect(effectiveCwdCalls).toHaveLength(1); + expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined(); + }); + + it("warm with opts.cwd → getEffectiveCwd called with opts.cwd as override", async () => { + const base = createInMemoryStore(); + await base.append("conv-warm-override", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = + []; + const store: ConversationStore = { + ...base, + async getEffectiveCwd(conversationId, overrideCwd) { + effectiveCwdCalls.push({ conversationId, overrideCwd }); + return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); + }, + }; + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-override", { cwd: "arch-rewrite" }); + + expect(effectiveCwdCalls).toHaveLength(1); + expect(effectiveCwdCalls[0]?.overrideCwd).toBe("arch-rewrite"); + }); + + it("warm without opts.cwd → getEffectiveCwd called with undefined override", async () => { + const base = createInMemoryStore(); + await base.append("conv-warm-no-override", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + const effectiveCwdCalls: Array<{ conversationId: string; overrideCwd: string | undefined }> = + []; + const store: ConversationStore = { + ...base, + async getEffectiveCwd(conversationId, overrideCwd) { + effectiveCwdCalls.push({ conversationId, overrideCwd }); + return overrideCwd ?? (await base.getEffectiveCwd(conversationId)); + }, + }; + + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-no-override"); + + expect(effectiveCwdCalls).toHaveLength(1); + expect(effectiveCwdCalls[0]?.overrideCwd).toBeUndefined(); + }); }); // --- System prompt integration --- function createFakeSystemPromptService( - constructImpl: ( - conversationId: string, - cwd: string, - context?: { readonly model?: string; readonly computerId?: string }, - ) => Promise, - getWithMetaImpl: (conversationId: string) => Promise<{ - readonly prompt: string | null; - readonly cwd: string | null; - readonly computerId: string | null; - }> = () => Promise.resolve({ prompt: null, cwd: null, computerId: null }), + constructImpl: ( + conversationId: string, + cwd: string, + context?: { readonly model?: string; readonly computerId?: string }, + ) => Promise, + getWithMetaImpl: (conversationId: string) => Promise<{ + readonly prompt: string | null; + readonly cwd: string | null; + readonly computerId: string | null; + }> = () => Promise.resolve({ prompt: null, cwd: null, computerId: null }), ): SystemPromptService { - return { - construct: constructImpl, - async get(conversationId) { - const meta = await getWithMetaImpl(conversationId); - return meta.prompt; - }, - getWithMeta: getWithMetaImpl, - async getTemplate() { - return ""; - }, - async setTemplate() {}, - }; + return { + construct: constructImpl, + async get(conversationId) { + const meta = await getWithMetaImpl(conversationId); + return meta.prompt; + }, + getWithMeta: getWithMetaImpl, + async getTemplate() { + return ""; + }, + async setTemplate() {}, + }; } describe("system prompt: regular turn flow", () => { - it("First turn: construct called — new conversation (meta null) → construct called with conversationId + cwd + model → result set on providerOpts.systemPrompt", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const constructCalls: Array<{ - conversationId: string; - cwd: string; - model: string | undefined; - }> = []; - const getCalls: string[] = []; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveSystemPrompt: () => - createFakeSystemPromptService( - async (conversationId, cwd, context) => { - constructCalls.push({ - conversationId, - cwd, - model: context?.model, - }); - return "CONSTRUCTED_PROMPT"; - }, - async (conversationId) => { - getCalls.push(conversationId); - return { prompt: null, cwd: null, computerId: null }; - }, - ), - }); - - await orchestrator.handleMessage({ - conversationId: "conv-sp-first", - text: "hi", - onEvent: () => {}, - cwd: "/work/dir", - modelName: "my-model", - }); - - expect(constructCalls).toHaveLength(1); - expect(constructCalls[0]?.conversationId).toBe("conv-sp-first"); - expect(constructCalls[0]?.cwd).toBe("/work/dir"); - expect(constructCalls[0]?.model).toBe("my-model"); - expect(getCalls).toHaveLength(0); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.systemPrompt).toBe("CONSTRUCTED_PROMPT"); - }); - - it("Subsequent turn: stored cwd === effective cwd → uses cached prompt (no construct)", async () => { - const store = createInMemoryStore(); - // Seed an existing conversation so getConversationMeta returns non-null. - await store.append("conv-sp-sub", [ - { role: "user", chunks: [{ type: "text", text: "first" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]); - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const constructCalls: string[] = []; - const getWithMetaCalls: string[] = []; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveSystemPrompt: () => - createFakeSystemPromptService( - async (conversationId) => { - constructCalls.push(conversationId); - return "SHOULD_NOT_BE_USED"; - }, - async (conversationId) => { - getWithMetaCalls.push(conversationId); - return { prompt: "PERSISTED_PROMPT", cwd: "/work/dir", computerId: null }; - }, - ), - }); - - await orchestrator.handleMessage({ - conversationId: "conv-sp-sub", - text: "second", - onEvent: () => {}, - cwd: "/work/dir", - }); - - expect(getWithMetaCalls).toHaveLength(1); - expect(getWithMetaCalls[0]).toBe("conv-sp-sub"); - expect(constructCalls).toHaveLength(0); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.systemPrompt).toBe("PERSISTED_PROMPT"); - }); - - it("Subsequent turn: no stored prompt (getWithMeta returns null) → calls construct", async () => { - const store = createInMemoryStore(); - await store.append("conv-sp-null", [ - { role: "user", chunks: [{ type: "text", text: "first" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]); - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const constructCalls: Array<{ - conversationId: string; - cwd: string; - model: string | undefined; - }> = []; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveSystemPrompt: () => - createFakeSystemPromptService( - async (conversationId, cwd, context) => { - constructCalls.push({ conversationId, cwd, model: context?.model }); - return "RECONSTRUCTED_PROMPT"; - }, - async () => ({ prompt: null, cwd: null, computerId: null }), - ), - }); - - await orchestrator.handleMessage({ - conversationId: "conv-sp-null", - text: "second", - onEvent: () => {}, - cwd: "/work/dir", - modelName: "my-model", - }); - - expect(constructCalls).toHaveLength(1); - expect(constructCalls[0]?.conversationId).toBe("conv-sp-null"); - expect(constructCalls[0]?.cwd).toBe("/work/dir"); - expect(constructCalls[0]?.model).toBe("my-model"); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.systemPrompt).toBe("RECONSTRUCTED_PROMPT"); - }); - - it("Subsequent turn: stored cwd ≠ effective cwd → calls construct with new cwd (prompt rebuilt)", async () => { - const store = createInMemoryStore(); - await store.append("conv-sp-cwd-change", [ - { role: "user", chunks: [{ type: "text", text: "first" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]); - - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const constructCalls: Array<{ - conversationId: string; - cwd: string; - model: string | undefined; - }> = []; - const getWithMetaCalls: string[] = []; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveSystemPrompt: () => - createFakeSystemPromptService( - async (conversationId, cwd, context) => { - constructCalls.push({ conversationId, cwd, model: context?.model }); - return "REBUILT_PROMPT"; - }, - async (conversationId) => { - getWithMetaCalls.push(conversationId); - // Stored prompt was built against an OLD cwd. - return { prompt: "STALE_PROMPT", cwd: "/old/dir", computerId: null }; - }, - ), - }); - - await orchestrator.handleMessage({ - conversationId: "conv-sp-cwd-change", - text: "second", - onEvent: () => {}, - // Current turn's effective cwd differs from the stored cwd. - cwd: "/new/dir", - modelName: "my-model", - }); - - expect(getWithMetaCalls).toHaveLength(1); - expect(getWithMetaCalls[0]).toBe("conv-sp-cwd-change"); - - expect(constructCalls).toHaveLength(1); - expect(constructCalls[0]?.conversationId).toBe("conv-sp-cwd-change"); - expect(constructCalls[0]?.cwd).toBe("/new/dir"); - expect(constructCalls[0]?.model).toBe("my-model"); - - expect(captured).toHaveLength(1); - // The rebuilt prompt is used — NOT the stale cached one. - expect(captured[0]?.providerOpts?.systemPrompt).toBe("REBUILT_PROMPT"); - }); - - it("Service unavailable: no system prompt — resolveSystemPrompt is undefined → providerOpts.systemPrompt is NOT set", async () => { - const store = createInMemoryStore(); - const provider: ProviderContract = { id: "p", stream: async function* () {} }; - const { captured, captureRunTurn } = createCapturingRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - // resolveSystemPrompt omitted entirely - }); - - await orchestrator.handleMessage({ - conversationId: "conv-sp-none", - text: "hi", - onEvent: () => {}, - cwd: "/work", - }); - - expect(captured).toHaveLength(1); - expect(captured[0]?.providerOpts?.systemPrompt).toBeUndefined(); - }); + it("First turn: construct called — new conversation (meta null) → construct called with conversationId + cwd + model → result set on providerOpts.systemPrompt", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + const getCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService( + async (conversationId, cwd, context) => { + constructCalls.push({ + conversationId, + cwd, + model: context?.model, + }); + return "CONSTRUCTED_PROMPT"; + }, + async (conversationId) => { + getCalls.push(conversationId); + return { prompt: null, cwd: null, computerId: null }; + }, + ), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-first", + text: "hi", + onEvent: () => {}, + cwd: "/work/dir", + modelName: "my-model", + }); + + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-sp-first"); + expect(constructCalls[0]?.cwd).toBe("/work/dir"); + expect(constructCalls[0]?.model).toBe("my-model"); + expect(getCalls).toHaveLength(0); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.systemPrompt).toBe("CONSTRUCTED_PROMPT"); + }); + + it("Subsequent turn: stored cwd === effective cwd → uses cached prompt (no construct)", async () => { + const store = createInMemoryStore(); + // Seed an existing conversation so getConversationMeta returns non-null. + await store.append("conv-sp-sub", [ + { role: "user", chunks: [{ type: "text", text: "first" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const constructCalls: string[] = []; + const getWithMetaCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService( + async (conversationId) => { + constructCalls.push(conversationId); + return "SHOULD_NOT_BE_USED"; + }, + async (conversationId) => { + getWithMetaCalls.push(conversationId); + return { prompt: "PERSISTED_PROMPT", cwd: "/work/dir", computerId: null }; + }, + ), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-sub", + text: "second", + onEvent: () => {}, + cwd: "/work/dir", + }); + + expect(getWithMetaCalls).toHaveLength(1); + expect(getWithMetaCalls[0]).toBe("conv-sp-sub"); + expect(constructCalls).toHaveLength(0); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.systemPrompt).toBe("PERSISTED_PROMPT"); + }); + + it("Subsequent turn: no stored prompt (getWithMeta returns null) → calls construct", async () => { + const store = createInMemoryStore(); + await store.append("conv-sp-null", [ + { role: "user", chunks: [{ type: "text", text: "first" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService( + async (conversationId, cwd, context) => { + constructCalls.push({ conversationId, cwd, model: context?.model }); + return "RECONSTRUCTED_PROMPT"; + }, + async () => ({ prompt: null, cwd: null, computerId: null }), + ), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-null", + text: "second", + onEvent: () => {}, + cwd: "/work/dir", + modelName: "my-model", + }); + + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-sp-null"); + expect(constructCalls[0]?.cwd).toBe("/work/dir"); + expect(constructCalls[0]?.model).toBe("my-model"); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.systemPrompt).toBe("RECONSTRUCTED_PROMPT"); + }); + + it("Subsequent turn: stored cwd ≠ effective cwd → calls construct with new cwd (prompt rebuilt)", async () => { + const store = createInMemoryStore(); + await store.append("conv-sp-cwd-change", [ + { role: "user", chunks: [{ type: "text", text: "first" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + const getWithMetaCalls: string[] = []; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService( + async (conversationId, cwd, context) => { + constructCalls.push({ conversationId, cwd, model: context?.model }); + return "REBUILT_PROMPT"; + }, + async (conversationId) => { + getWithMetaCalls.push(conversationId); + // Stored prompt was built against an OLD cwd. + return { prompt: "STALE_PROMPT", cwd: "/old/dir", computerId: null }; + }, + ), + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-cwd-change", + text: "second", + onEvent: () => {}, + // Current turn's effective cwd differs from the stored cwd. + cwd: "/new/dir", + modelName: "my-model", + }); + + expect(getWithMetaCalls).toHaveLength(1); + expect(getWithMetaCalls[0]).toBe("conv-sp-cwd-change"); + + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-sp-cwd-change"); + expect(constructCalls[0]?.cwd).toBe("/new/dir"); + expect(constructCalls[0]?.model).toBe("my-model"); + + expect(captured).toHaveLength(1); + // The rebuilt prompt is used — NOT the stale cached one. + expect(captured[0]?.providerOpts?.systemPrompt).toBe("REBUILT_PROMPT"); + }); + + it("Service unavailable: no system prompt — resolveSystemPrompt is undefined → providerOpts.systemPrompt is NOT set", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + // resolveSystemPrompt omitted entirely + }); + + await orchestrator.handleMessage({ + conversationId: "conv-sp-none", + text: "hi", + onEvent: () => {}, + cwd: "/work", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.systemPrompt).toBeUndefined(); + }); }); describe("system prompt: compaction flow", () => { - function seedHistory( - store: ReturnType, - conversationId: string, - count: number, - ): void { - const messages: ChatMessage[] = []; - for (let i = 0; i < count; i++) { - messages.push({ - role: i % 2 === 0 ? "user" : "assistant", - chunks: [{ type: "text", text: `message ${i}` }], - }); - } - store.data.set(conversationId, messages); - } - - it("Compaction: construct + append — compaction flow calls construct → result appended with COMPACTION_SYSTEM_PROMPT → combined string set as systemPrompt", async () => { - const store = createInMemoryStore(); - seedHistory(store, "conv-compact-sp", 15); - - const constructCalls: Array<{ - conversationId: string; - cwd: string; - model: string | undefined; - }> = []; - - let capturedSystemPrompt: string | undefined; - const provider: ProviderContract = { - id: "compaction-provider", - stream(_messages, _tools, opts) { - capturedSystemPrompt = opts?.systemPrompt; - return (async function* () { - yield { type: "text-delta", delta: "Summary text" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const compactionService = createCompactionService( - { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveSystemPrompt: () => - createFakeSystemPromptService(async (conversationId, cwd, context) => { - constructCalls.push({ conversationId, cwd, model: context?.model }); - return "RECONSTRUCTED_PROMPT"; - }), - emit: () => {}, - }, - new Set(), - ); - - const result = await compactionService.compact("conv-compact-sp", { - modelName: "compaction-model", - }); - - expect("summary" in result).toBe(true); - expect(constructCalls).toHaveLength(1); - expect(constructCalls[0]?.conversationId).toBe("conv-compact-sp"); - expect(constructCalls[0]?.model).toBe("compaction-model"); - - // The system prompt sent to the provider must be the constructed prompt - // appended with the COMPACTION_SYSTEM_PROMPT. - expect(capturedSystemPrompt).toBeDefined(); - expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED_PROMPT\n\n")).toBe(true); - expect(capturedSystemPrompt).toContain("conversation summarizer"); - }); - - it("Compaction: fallback when service unavailable — compaction flow with no service → COMPACTION_SYSTEM_PROMPT alone", async () => { - const store = createInMemoryStore(); - seedHistory(store, "conv-compact-nosp", 15); - - let capturedSystemPrompt: string | undefined; - const provider: ProviderContract = { - id: "compaction-provider", - stream(_messages, _tools, opts) { - capturedSystemPrompt = opts?.systemPrompt; - return (async function* () { - yield { type: "text-delta", delta: "Summary text" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - })(); - }, - }; - - const compactionService = createCompactionService( - { - conversationStore: store, - resolveProvider: () => provider, - resolveTools: () => [], - applyToolsFilter: identityApplyToolsFilter, - runTurn, - // resolveSystemPrompt omitted — service unavailable - emit: () => {}, - }, - new Set(), - ); - - const result = await compactionService.compact("conv-compact-nosp", { - modelName: "compaction-model", - }); - - expect("summary" in result).toBe(true); - expect(capturedSystemPrompt).toBeDefined(); - // Must be the COMPACTION_SYSTEM_PROMPT alone — no constructed prefix. - expect(capturedSystemPrompt).toContain("conversation summarizer"); - expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED")).toBe(false); - }); + function seedHistory( + store: ReturnType, + conversationId: string, + count: number, + ): void { + const messages: ChatMessage[] = []; + for (let i = 0; i < count; i++) { + messages.push({ + role: i % 2 === 0 ? "user" : "assistant", + chunks: [{ type: "text", text: `message ${i}` }], + }); + } + store.data.set(conversationId, messages); + } + + it("Compaction: construct + append — compaction flow calls construct → result appended with COMPACTION_SYSTEM_PROMPT → combined string set as systemPrompt", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-compact-sp", 15); + + const constructCalls: Array<{ + conversationId: string; + cwd: string; + model: string | undefined; + }> = []; + + let capturedSystemPrompt: string | undefined; + const provider: ProviderContract = { + id: "compaction-provider", + stream(_messages, _tools, opts) { + capturedSystemPrompt = opts?.systemPrompt; + return (async function* () { + yield { type: "text-delta", delta: "Summary text" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const compactionService = createCompactionService( + { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveSystemPrompt: () => + createFakeSystemPromptService(async (conversationId, cwd, context) => { + constructCalls.push({ conversationId, cwd, model: context?.model }); + return "RECONSTRUCTED_PROMPT"; + }), + emit: () => {}, + }, + new Set(), + ); + + const result = await compactionService.compact("conv-compact-sp", { + modelName: "compaction-model", + }); + + expect("summary" in result).toBe(true); + expect(constructCalls).toHaveLength(1); + expect(constructCalls[0]?.conversationId).toBe("conv-compact-sp"); + expect(constructCalls[0]?.model).toBe("compaction-model"); + + // The system prompt sent to the provider must be the constructed prompt + // appended with the COMPACTION_SYSTEM_PROMPT. + expect(capturedSystemPrompt).toBeDefined(); + expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED_PROMPT\n\n")).toBe(true); + expect(capturedSystemPrompt).toContain("conversation summarizer"); + }); + + it("Compaction: fallback when service unavailable — compaction flow with no service → COMPACTION_SYSTEM_PROMPT alone", async () => { + const store = createInMemoryStore(); + seedHistory(store, "conv-compact-nosp", 15); + + let capturedSystemPrompt: string | undefined; + const provider: ProviderContract = { + id: "compaction-provider", + stream(_messages, _tools, opts) { + capturedSystemPrompt = opts?.systemPrompt; + return (async function* () { + yield { type: "text-delta", delta: "Summary text" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const compactionService = createCompactionService( + { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + // resolveSystemPrompt omitted — service unavailable + emit: () => {}, + }, + new Set(), + ); + + const result = await compactionService.compact("conv-compact-nosp", { + modelName: "compaction-model", + }); + + expect("summary" in result).toBe(true); + expect(capturedSystemPrompt).toBeDefined(); + // Must be the COMPACTION_SYSTEM_PROMPT alone — no constructed prefix. + expect(capturedSystemPrompt).toContain("conversation summarizer"); + expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED")).toBe(false); + }); }); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 4aa77f7..a785645 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -1,78 +1,78 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { - AgentEvent, - ChatMessage, - CompactionResult, - ConversationStatus, - EventHookDescriptor, - Logger, - ModelInfo, - ProviderContract, - ProviderEvent, - ProviderStreamOptions, - ReasoningEffort, - RetryStrategy, - RunTurnInput, - RunTurnResult, - ToolContract, - ToolDispatchPolicy, - UsageEvent, + AgentEvent, + ChatMessage, + CompactionResult, + ConversationStatus, + EventHookDescriptor, + Logger, + ModelInfo, + ProviderContract, + ProviderEvent, + ProviderStreamOptions, + ReasoningEffort, + RetryStrategy, + RunTurnInput, + RunTurnResult, + ToolContract, + ToolDispatchPolicy, + UsageEvent, } from "@dispatch/kernel"; import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel"; import type { MessageQueueService, QueuedMessage } from "@dispatch/message-queue"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { createMetricsAccumulator } from "./metrics.js"; import { - buildUserMessage, - defaultDispatchPolicy, - delayFor, - generateTurnId, - resolveModelName, - resolveReasoningEffort, + buildUserMessage, + defaultDispatchPolicy, + delayFor, + generateTurnId, + resolveModelName, + resolveReasoningEffort, } from "./pure.js"; import type { ToolAssembly } from "./tools-filter.js"; // --- Broadcast hub types --- export interface StartTurnInput { - readonly conversationId: string; - readonly text: string; - readonly modelName?: string; - readonly cwd?: string; - /** - * The computer to execute this turn's tools on (SSH config alias). Mirrors - * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. - * Omitted/`undefined` = use the persisted per-conversation / workspace - * default (LOCAL when none set). The orchestrator never interprets it — it - * forwards the alias string verbatim (like cwd forwards a path). - */ - readonly computerId?: string; - readonly reasoningEffort?: ReasoningEffort; - /** - * The workspace this conversation belongs to. Defaults to `"default"` when - * omitted. On the first turn for a new conversation, the workspaceId is - * persisted (the workspace is auto-created if missing) so subsequent turns - * resolve the effective cwd from the workspace's `defaultCwd`. - */ - readonly workspaceId?: string; + readonly conversationId: string; + readonly text: string; + readonly modelName?: string; + readonly cwd?: string; + /** + * The computer to execute this turn's tools on (SSH config alias). Mirrors + * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. + * Omitted/`undefined` = use the persisted per-conversation / workspace + * default (LOCAL when none set). The orchestrator never interprets it — it + * forwards the alias string verbatim (like cwd forwards a path). + */ + readonly computerId?: string; + readonly reasoningEffort?: ReasoningEffort; + /** + * The workspace this conversation belongs to. Defaults to `"default"` when + * omitted. On the first turn for a new conversation, the workspaceId is + * persisted (the workspace is auto-created if missing) so subsequent turns + * resolve the effective cwd from the workspace's `defaultCwd`. + */ + readonly workspaceId?: string; } export type StartTurnResult = - | { readonly started: true; readonly turnId: string } - | { readonly started: false; readonly reason: "already-active" }; + | { readonly started: true; readonly turnId: string } + | { readonly started: false; readonly reason: "already-active" }; /** Input to `SessionOrchestrator.enqueue` — the single entry transports call. */ export interface EnqueueInput { - readonly conversationId: string; - readonly text: string; - /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ - readonly workspaceId?: string; - /** - * Per-turn computer override (SSH alias), threaded to `startTurn` when the - * conversation is idle (the message starts a turn). Additive optional — - * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). - */ - readonly computerId?: string; + readonly conversationId: string; + readonly text: string; + /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ + readonly workspaceId?: string; + /** + * Per-turn computer override (SSH alias), threaded to `startTurn` when the + * conversation is idle (the message starts a turn). Additive optional — + * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). + */ + readonly computerId?: string; } /** @@ -84,41 +84,41 @@ export interface EnqueueInput { * is dropped, see `enqueue` docs). */ export interface EnqueueResult { - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; } export type TurnEventListener = (event: AgentEvent) => void; interface ActiveTurn { - buffer: AgentEvent[]; - turnId: string; - /** Aborts this turn's kernel runTurn (closeConversation). */ - controller: AbortController; + buffer: AgentEvent[]; + turnId: string; + /** Aborts this turn's kernel runTurn (closeConversation). */ + controller: AbortController; } // --- Lifecycle event hooks --- /** Context carried on turn-lifecycle events, enough to replicate the turn's request prefix. */ export interface TurnLifecyclePayload { - readonly conversationId: string; - readonly cwd?: string; - /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ - readonly computerId?: string; - readonly modelName?: string; + readonly conversationId: string; + readonly cwd?: string; + /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ + readonly computerId?: string; + readonly modelName?: string; } /** Fired when a turn STARTS driving a conversation (consumers cancel warming timers). */ export const turnStarted: EventHookDescriptor = - defineEventHook("session-orchestrator/turn-started"); + defineEventHook("session-orchestrator/turn-started"); /** Fired when a turn SETTLES (sealed) for a conversation (consumers arm warming timers). */ export const turnSettled: EventHookDescriptor = - defineEventHook("session-orchestrator/turn-settled"); + defineEventHook("session-orchestrator/turn-settled"); /** Payload for the conversationClosed bus event. */ export interface ConversationClosedPayload { - readonly conversationId: string; + readonly conversationId: string; } /** @@ -127,17 +127,17 @@ export interface ConversationClosedPayload { * disables its schedule). Emitted by `SessionOrchestrator.closeConversation`. */ export const conversationClosed: EventHookDescriptor = - defineEventHook("session-orchestrator/conversation-closed"); + defineEventHook("session-orchestrator/conversation-closed"); /** Payload for the conversationOpened bus event. */ export interface ConversationOpenedPayload { - readonly conversationId: string; - /** - * The conversation's actual persisted workspace id (resolved from the - * store, not the per-turn start option), so a frontend can open/focus the - * tab in the correct workspace. Falls back to `"default"`. - */ - readonly workspaceId: string; + readonly conversationId: string; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can open/focus the + * tab in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -147,18 +147,18 @@ export interface ConversationOpenedPayload { * open/focus a tab — the backend just signals. */ export const conversationOpened: EventHookDescriptor = - defineEventHook("session-orchestrator/conversation-opened"); + defineEventHook("session-orchestrator/conversation-opened"); /** Payload for the conversationStatusChanged bus event. */ export interface ConversationStatusChangedPayload { - readonly conversationId: string; - readonly status: ConversationStatus; - /** - * The conversation's actual persisted workspace id (resolved from the - * store, not the per-turn start option), so a frontend can sync the tab - * in the correct workspace. Falls back to `"default"`. - */ - readonly workspaceId: string; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can sync the tab + * in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -167,16 +167,16 @@ export interface ConversationStatusChangedPayload { * message to all connected frontend clients so tabs sync across devices. */ export const conversationStatusChanged: EventHookDescriptor = - defineEventHook( - "session-orchestrator/conversation-status-changed", - ); + defineEventHook( + "session-orchestrator/conversation-status-changed", + ); /** Payload for the conversationCompacted bus event. */ export interface ConversationCompactedPayload { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -185,163 +185,163 @@ export interface ConversationCompactedPayload { * broadcasts a `conversation.compacted` WS message so the FE reloads history. */ export const conversationCompacted: EventHookDescriptor = - defineEventHook("session-orchestrator/conversation-compacted"); + defineEventHook("session-orchestrator/conversation-compacted"); /** Payload for the warmCompleted bus event. */ export interface WarmCompletedPayload { - readonly conversationId: string; - readonly usage: WarmResult; + readonly conversationId: string; + readonly usage: WarmResult; } /** Fired when a warm probe succeeds (both automatic and manual paths). */ export const warmCompleted: EventHookDescriptor = - defineEventHook("session-orchestrator/warm-completed"); + defineEventHook("session-orchestrator/warm-completed"); // --- Warm service --- export interface WarmResult { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; } export interface WarmService { - readonly warm: ( - conversationId: string, - opts?: { readonly cwd?: string; readonly modelName?: string }, - ) => Promise; + readonly warm: ( + conversationId: string, + opts?: { readonly cwd?: string; readonly modelName?: string }, + ) => Promise; } export const cacheWarmHandle: ServiceHandle = defineService( - "session-orchestrator/warm", + "session-orchestrator/warm", ); // --- Compaction service --- export interface CompactionService { - /** - * Compact a conversation: summarize old messages and replace history with - * the summary + the most recent `keepLastN` messages. Returns the result - * or an error object. No-ops if the conversation is too short (≤ keepLastN - * messages). When `auto` is true, checks the compact-threshold setting and - * only compacts if the last turn's input tokens exceeded it. - */ - readonly compact: ( - conversationId: string, - opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean }, - ) => Promise; + /** + * Compact a conversation: summarize old messages and replace history with + * the summary + the most recent `keepLastN` messages. Returns the result + * or an error object. No-ops if the conversation is too short (≤ keepLastN + * messages). When `auto` is true, checks the compact-threshold setting and + * only compacts if the last turn's input tokens exceeded it. + */ + readonly compact: ( + conversationId: string, + opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean }, + ) => Promise; } export const compactionHandle: ServiceHandle = defineService( - "session-orchestrator/compaction", + "session-orchestrator/compaction", ); export interface SessionOrchestrator { - startTurn(input: StartTurnInput): StartTurnResult; - /** - * The single entry transports call to deliver a user message. Owns the - * idle→startTurn vs active→queue decision (no separate `isActive` race — - * `startTurn`'s single-flight guard is authoritative). When the conversation - * is idle, starts a turn (the message is the opening prompt). When active, - * enqueues onto the steering queue (if the message-queue extension is - * loaded); with no queue extension loaded the message is dropped and the - * returned snapshot is empty (degraded — feature off). - */ - enqueue(input: EnqueueInput): EnqueueResult; - subscribe(conversationId: string, listener: TurnEventListener): () => void; - isActive(conversationId: string): boolean; - /** - * Explicitly close a conversation (the user closed its tab — distinct from a - * socket disconnect, which never touches the turn): aborts any in-flight turn - * (the kernel finishes with `finishReason: "aborted"`, partial messages are - * persisted and the turn seals normally) and emits the `conversationClosed` - * hook so per-conversation background work (cache-warming) stops. - * Idempotent — closing an idle/unknown conversation just emits the hook. - */ - closeConversation(conversationId: string): { readonly abortedTurn: boolean }; - /** - * Stop an in-flight generation WITHOUT closing the conversation. Aborts - * the turn's AbortController — the kernel finishes with - * `finishReason: "aborted"`, partial messages are persisted, and the turn - * seals normally (status transitions active → idle via the normal settle - * path). Idempotent — stopping an idle/unknown conversation is a no-op. - */ - stopTurn(conversationId: string): { readonly abortedTurn: boolean }; - handleMessage(input: { - conversationId: string; - text: string; - onEvent: (event: AgentEvent) => void; - modelName?: string; - cwd?: string; - computerId?: string; - reasoningEffort?: ReasoningEffort; - workspaceId?: string; - }): Promise; + startTurn(input: StartTurnInput): StartTurnResult; + /** + * The single entry transports call to deliver a user message. Owns the + * idle→startTurn vs active→queue decision (no separate `isActive` race — + * `startTurn`'s single-flight guard is authoritative). When the conversation + * is idle, starts a turn (the message is the opening prompt). When active, + * enqueues onto the steering queue (if the message-queue extension is + * loaded); with no queue extension loaded the message is dropped and the + * returned snapshot is empty (degraded — feature off). + */ + enqueue(input: EnqueueInput): EnqueueResult; + subscribe(conversationId: string, listener: TurnEventListener): () => void; + isActive(conversationId: string): boolean; + /** + * Explicitly close a conversation (the user closed its tab — distinct from a + * socket disconnect, which never touches the turn): aborts any in-flight turn + * (the kernel finishes with `finishReason: "aborted"`, partial messages are + * persisted and the turn seals normally) and emits the `conversationClosed` + * hook so per-conversation background work (cache-warming) stops. + * Idempotent — closing an idle/unknown conversation just emits the hook. + */ + closeConversation(conversationId: string): { readonly abortedTurn: boolean }; + /** + * Stop an in-flight generation WITHOUT closing the conversation. Aborts + * the turn's AbortController — the kernel finishes with + * `finishReason: "aborted"`, partial messages are persisted, and the turn + * seals normally (status transitions active → idle via the normal settle + * path). Idempotent — stopping an idle/unknown conversation is a no-op. + */ + stopTurn(conversationId: string): { readonly abortedTurn: boolean }; + handleMessage(input: { + conversationId: string; + text: string; + onEvent: (event: AgentEvent) => void; + modelName?: string; + cwd?: string; + computerId?: string; + reasoningEffort?: ReasoningEffort; + workspaceId?: string; + }): Promise; } export const sessionOrchestratorHandle = defineService( - "session-orchestrator/orchestrator", + "session-orchestrator/orchestrator", ); export interface SessionOrchestratorDeps { - readonly conversationStore: ConversationStore; - readonly resolveProvider: () => ProviderContract; - readonly resolveTools: () => readonly ToolContract[]; - readonly resolveDispatch?: () => ToolDispatchPolicy; - readonly resolveModel?: ( - modelName: string, - ) => { provider: ProviderContract; model: string } | undefined; - /** - * Resolve full `ModelInfo` (including `contextWindow`) for a model name. - * Used by the compaction service to calculate the auto-compact threshold - * as a percentage of the context window. - */ - readonly resolveModelInfo?: (modelName: string) => Promise; - readonly runTurn: (input: RunTurnInput) => Promise; - /** - * Lazily resolves the message-queue service (the steering queue), or - * `undefined` when the message-queue extension isn't loaded (the feature - * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops - * messages when active). host-bin wires this via `host.getService`; the - * orchestrator calls it per-turn / per-enqueue so activation order with the - * message-queue extension doesn't matter. Injected (not ambient) so a turn - * stays reproducible from its inputs and tests use a fake queue. - */ - readonly resolveQueue?: () => MessageQueueService | undefined; - /** - * Lazily resolves the compaction service, or `undefined` when not loaded. - * Used for automatic compaction after a turn settles (if the compact - * threshold is exceeded). Lazy so activation order doesn't matter. - */ - readonly resolveCompaction?: () => CompactionService | undefined; - /** - * Lazily resolves the system-prompt service, or `undefined` when the - * system-prompt extension isn't loaded. Used to construct the per- - * conversation system prompt once (first turn) and reuse it (cache-safe) on - * subsequent turns, and to reconstruct it on compaction. Lazy so activation - * order doesn't matter. - */ - readonly resolveSystemPrompt?: () => SystemPromptService | undefined; - /** Apply the per-turn tools filter chain. Injected for testability. */ - readonly applyToolsFilter: (assembly: ToolAssembly) => Promise; - /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ - readonly logger?: Logger; - /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ - readonly now?: () => number; - /** Emit a lifecycle event hook to subscribers. Injected from host. */ - readonly emit?: (hook: EventHookDescriptor, payload: TPayload) => void; + readonly conversationStore: ConversationStore; + readonly resolveProvider: () => ProviderContract; + readonly resolveTools: () => readonly ToolContract[]; + readonly resolveDispatch?: () => ToolDispatchPolicy; + readonly resolveModel?: ( + modelName: string, + ) => { provider: ProviderContract; model: string } | undefined; + /** + * Resolve full `ModelInfo` (including `contextWindow`) for a model name. + * Used by the compaction service to calculate the auto-compact threshold + * as a percentage of the context window. + */ + readonly resolveModelInfo?: (modelName: string) => Promise; + readonly runTurn: (input: RunTurnInput) => Promise; + /** + * Lazily resolves the message-queue service (the steering queue), or + * `undefined` when the message-queue extension isn't loaded (the feature + * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops + * messages when active). host-bin wires this via `host.getService`; the + * orchestrator calls it per-turn / per-enqueue so activation order with the + * message-queue extension doesn't matter. Injected (not ambient) so a turn + * stays reproducible from its inputs and tests use a fake queue. + */ + readonly resolveQueue?: () => MessageQueueService | undefined; + /** + * Lazily resolves the compaction service, or `undefined` when not loaded. + * Used for automatic compaction after a turn settles (if the compact + * threshold is exceeded). Lazy so activation order doesn't matter. + */ + readonly resolveCompaction?: () => CompactionService | undefined; + /** + * Lazily resolves the system-prompt service, or `undefined` when the + * system-prompt extension isn't loaded. Used to construct the per- + * conversation system prompt once (first turn) and reuse it (cache-safe) on + * subsequent turns, and to reconstruct it on compaction. Lazy so activation + * order doesn't matter. + */ + readonly resolveSystemPrompt?: () => SystemPromptService | undefined; + /** Apply the per-turn tools filter chain. Injected for testability. */ + readonly applyToolsFilter: (assembly: ToolAssembly) => Promise; + /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ + readonly logger?: Logger; + /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ + readonly now?: () => number; + /** Emit a lifecycle event hook to subscribers. Injected from host. */ + readonly emit?: (hook: EventHookDescriptor, payload: TPayload) => void; } /** Deps for the warm service — emit is REQUIRED so warmCompleted is never silently dropped. */ export type WarmServiceDeps = SessionOrchestratorDeps & { - readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; + readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; }; export interface SessionOrchestratorBundle { - readonly orchestrator: SessionOrchestrator; - /** The shared active-conversations set, for use by createWarmService. */ - readonly activeConversations: ReadonlySet; + readonly orchestrator: SessionOrchestrator; + /** The shared active-conversations set, for use by createWarmService. */ + readonly activeConversations: ReadonlySet; } /** @@ -354,848 +354,848 @@ export interface SessionOrchestratorBundle { * turn `aborted`). The kernel imports no timer; this is the shell-provided I/O. */ export function createRetryStrategy(): RetryStrategy { - const sleep = (ms: number, signal: AbortSignal): Promise => { - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new Error("aborted")); - return; - } - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new Error("aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - }; - return { delayFor, sleep }; + const sleep = (ms: number, signal: AbortSignal): Promise => { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("aborted")); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new Error("aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + }; + return { delayFor, sleep }; } export function createSessionOrchestrator( - deps: SessionOrchestratorDeps, + deps: SessionOrchestratorDeps, ): SessionOrchestratorBundle { - const activeConversations = new Set(); - const subscribers = new Map>(); - const activeTurns = new Map(); - // One stateless retry strategy shared by every turn (delayFor is pure; sleep - // is a stateless setTimeout closure). Wired into each RunTurnInput.retry. - const retryStrategy = createRetryStrategy(); - - function emitToHub(conversationId: string, event: AgentEvent): void { - const turn = activeTurns.get(conversationId); - if (turn !== undefined) { - turn.buffer.push(event); - } - const listeners = subscribers.get(conversationId); - if (listeners !== undefined) { - for (const listener of listeners) { - listener(event); - } - } - } - - /** - * Post-seal carry: if a steering queue is available and non-empty, drain it, - * combine, and start a NEW detached turn whose opening `user-message` carries - * the combined text (no `steering` event — that's only for mid-turn drain). - * Returns true iff a new turn was started. Called from `runTurnDetached`'s - * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard - * passes) and BEFORE `activeConversations.delete` (skipped when carried, since - * the new turn re-adds it). May chain — the new turn's own finally re-checks. - */ - function tryCarryQueue(conversationId: string): boolean { - const queue = deps.resolveQueue?.(); - if (queue === undefined) return false; - if (queue.getQueue(conversationId).length === 0) return false; - const drained = queue.drain(conversationId); - const combined = drained.map((q) => q.text).join("\n\n"); - const result = orchestrator.startTurn({ conversationId, text: combined }); - return result.started; - } - - function runTurnDetached( - conversationId: string, - text: string, - modelName: string | undefined, - cwd: string | undefined, - computerId: string | undefined, - reasoningEffortOverride: ReasoningEffort | undefined, - workspaceId: string, - ): void { - const turnId = generateTurnId(); - const controller = new AbortController(); - activeTurns.set(conversationId, { buffer: [], turnId, controller }); - activeConversations.add(conversationId); - - emitToHub(conversationId, { type: "user-message", conversationId, turnId, text }); - - // For a NEW conversation the workspace MUST be assigned (persisted) - // BEFORE getEffectiveCwd runs, so the effective cwd resolves against - // the intended workspace's defaultCwd rather than the stale "default" - // workspace returned by getWorkspaceId for a not-yet-persisted - // conversation. Detect newness via getConversationMeta === null - // (equivalent to history.length === 0 in practice). Existing - // conversations keep their assigned workspace — never overwritten. - // The newness flag is also reused to decide whether to construct - // (first turn) or get (subsequent turn) the system prompt — see the - // providerOpts assembly below. - const workspaceSetupPromise = (async (): Promise => { - const meta = await deps.conversationStore.getConversationMeta(conversationId); - if (meta === null) { - await deps.conversationStore.ensureWorkspace(workspaceId); - await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); - return true; - } - return false; - })(); - - // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the - // per-turn cwd as the overrideCwd when present. A relative per-turn cwd - // (e.g. "arch-rewrite") must be resolved against the workspace's - // defaultCwd via the same workspace-relative algorithm the persisted cwd - // uses — NOT used raw (which would resolve against process.cwd() and - // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd. - // Chained after workspaceSetupPromise so the workspace is assigned - // first for new conversations (the timing invariant this enforces). - const effectiveCwdPromise = workspaceSetupPromise.then(() => - deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), - ); - - // Resolve the effective computer the SAME way cwd resolves — pass the - // per-turn computerId as the overrideAlias. When computerId is - // undefined, getEffectiveComputer reads the persisted per-conversation - // computerId → workspace defaultComputerId → null (LOCAL). Chained - // after workspaceSetupPromise (same timing invariant as cwd). The - // orchestrator never interprets the alias — it forwards the string - // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. - const effectiveComputerIdPromise = workspaceSetupPromise.then(() => - deps.conversationStore - .getEffectiveComputer(conversationId, computerId) - .then((c) => c ?? undefined), - ); - - const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); - // Resolve the persisted model (if any) in parallel with the other - // per-conversation reads. The effective model name is - // per-turn override → persisted → (undefined → default provider), the - // same resolution chain as `resolveReasoningEffort`. - const storedModelPromise = deps.conversationStore.getModel(conversationId); - - const payloadPromise = Promise.all([ - effectiveCwdPromise, - effectiveComputerIdPromise, - storedEffortPromise, - storedModelPromise, - ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { - const effectiveModelName = resolveModelName(modelName, storedModel); - return { - conversationId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), - }; - }); - - payloadPromise.then((payload) => { - deps.emit?.(turnStarted, payload); - // Resolve the persisted workspace id (not the per-turn start option) - // before emitting so the broadcast carries the correct workspace. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "active", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "active"); - }); - - void (async () => { - let sealed = false; - try { - const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = - await Promise.all([ - effectiveCwdPromise, - effectiveComputerIdPromise, - storedEffortPromise, - workspaceSetupPromise, - storedModelPromise, - ]); - - if (cwd !== undefined) { - await deps.conversationStore.setCwd(conversationId, cwd); - } - - // Persist the per-turn computer override, mirroring the cwd - // persistence above. Only stamped when a computerId was actually - // provided — NOT when it resolved to undefined (LOCAL) via the - // workspace default. Idempotent when the value is unchanged. - if (computerId !== undefined) { - await deps.conversationStore.setComputerId(conversationId, computerId); - } - - const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); - // Effective model name: per-turn override → persisted → undefined - // (→ default provider). Resolved here so every downstream consumer - // (resolveModel, system prompt, payload) sees the same model as if - // the caller had passed it explicitly. - const effectiveModelName = resolveModelName(modelName, storedModel); - - const history = await deps.conversationStore.load(conversationId); - const userMsg = buildUserMessage(text); - - // Workspace assignment for new conversations happens BEFORE - // effective-cwd resolution (see workspaceSetupPromise above) so - // getEffectiveCwd resolves against the intended workspace, not - // the stale "default". The history-load + append flow below is - // otherwise unchanged. - - let provider: ProviderContract; - let modelOverride: string | undefined; - - if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(effectiveModelName); - if (resolved === undefined) { - emitToHub(conversationId, { - type: "error", - conversationId, - turnId, - message: `unknown model: ${effectiveModelName}`, - }); - return; - } - provider = resolved.provider; - modelOverride = resolved.model; - // Persist the resolved model so it sticks for future turns - // and browser sessions (per-conversation model persistence). - // Only stamped when a model was actually used — NOT on the - // default-provider fallthrough (nothing to persist). Idempotent - // when the value is unchanged (re-stamps the same persisted - // model). The early `return` above means an unknown model is - // never persisted. - await deps.conversationStore.setModel(conversationId, effectiveModelName); - } else { - provider = deps.resolveProvider(); - } - - const baseTools = deps.resolveTools(); - const assembled = await deps.applyToolsFilter({ - tools: baseTools, - conversationId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }); - const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); - const turnLogger = deps.logger?.child({ conversationId, turnId }); - const metrics = createMetricsAccumulator(); - - const emitAndAccumulate = (event: AgentEvent): void => { - metrics.ingest(event); - emitToHub(conversationId, event); - }; - - // Resolve the system prompt for this turn (cache-safe). On the - // FIRST turn of a new conversation, construct it once (resolves all - // template variables + persists the result). On subsequent turns, - // reuse the persisted prompt via `getWithMeta` — but ONLY when the - // stored cwd matches the current effective cwd. If the cwd changed - // since the prompt was constructed (or no prompt was ever stored), - // reconstruct against the new cwd so the prompt is never stale. - // This preserves the cache-safe design (construct once per cwd, - // reuse on subsequent turns with the same cwd) while fixing the bug - // where a cwd change left the prompt stale. When the system-prompt - // service isn't loaded, no system prompt is sent (current behavior - // preserved). - const systemPromptService = deps.resolveSystemPrompt?.(); - let systemPrompt: string | undefined; - if (systemPromptService !== undefined) { - if (isNewConversation) { - systemPrompt = await systemPromptService.construct( - conversationId, - effectiveCwd ?? process.cwd(), - { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }, - ); - } else { - const meta = await systemPromptService.getWithMeta(conversationId); - const currentCwd = effectiveCwd ?? process.cwd(); - const currentComputerId = effectiveComputerId ?? null; - // Invalidate when cwd OR computerId changed (switching computers - // must rebuild the prompt against the remote OS/hostname). - if ( - meta.prompt !== null && - meta.cwd === currentCwd && - meta.computerId === currentComputerId - ) { - systemPrompt = meta.prompt; - } else { - systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }); - } - } - } - - const providerOpts: ProviderStreamOptions = { - reasoningEffort: resolvedEffort, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(systemPrompt !== undefined ? { systemPrompt } : {}), - }; - - // Resolve the steering queue once for this turn. When present, wire - // `drainSteering`: the kernel calls it at the tool-result boundary and - // appends whatever it returns as user-role messages alongside the tool - // results (mid-turn steering). The wrapper emits a `steering` AgentEvent - // into the hub (buffered for late-join like `user-message`) so a - // frontend can place a user bubble in the transcript live; the kernel - // only appends the returned messages — it does NOT emit the event. - const queue = deps.resolveQueue?.(); - const drainSteering = - queue === undefined - ? undefined - : (): readonly ChatMessage[] => { - const queued = queue.drain(conversationId); - if (queued.length === 0) return []; - const steerText = queued.map((q) => q.text).join("\n\n"); - emitToHub(conversationId, { - type: "steering", - conversationId, - turnId, - text: steerText, - }); - return [{ role: "user", chunks: [{ type: "text", text: steerText }] }]; - }; - - const opts: RunTurnInput = { - provider, - messages: [...history, userMsg], - tools: assembled.tools, - dispatch, - emit: emitAndAccumulate, - conversationId, - turnId, - signal: controller.signal, - providerOpts, - retry: retryStrategy, - ...(turnLogger !== undefined ? { logger: turnLogger } : {}), - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - ...(deps.now !== undefined ? { now: deps.now } : {}), - ...(drainSteering !== undefined ? { drainSteering } : {}), - }; - - // Persist the user message at turn start so it has a seq - // number before the first step generates. This enables the - // FE to syncTail during generation (CR-6). - await deps.conversationStore.append(conversationId, [userMsg]); - - let stepsPersisted = false; - const result = await deps.runTurn({ - ...opts, - // Incremental persistence: persist each step's messages - // as they are finalized. Seq numbers are assigned during - // generation, so the FE can GET /conversations/:id?sinceSeq=N - // mid-turn and pick up committed chunks (CR-6). - onStepComplete: async (stepMessages) => { - await deps.conversationStore.append(conversationId, stepMessages); - stepsPersisted = true; - }, - }); - - // Fallback: if onStepComplete was never called (e.g., a fake - // runTurn in tests), persist all result messages as a batch. - if (!stepsPersisted && result.messages.length > 0) { - await deps.conversationStore.append(conversationId, result.messages); - } - - const turnMetrics = metrics.build(turnId); - await deps.conversationStore.appendMetrics(conversationId, turnMetrics); - - emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId }); - sealed = true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - emitToHub(conversationId, { - type: "error", - conversationId, - turnId, - message, - }); - } finally { - activeTurns.delete(conversationId); - // Post-seal carry: if the turn sealed with a non-empty steering queue - // (no tool call fired → drainSteering never drained it), start a NEW - // detached turn whose opening user-message carries the combined text. - // The new turn re-adds to activeTurns + activeConversations, so skip - // the activeConversations.delete when carried. May chain (user keeps - // steering) — each carried turn's own finally re-checks the queue. - const carried = sealed && tryCarryQueue(conversationId); - if (!carried) { - activeConversations.delete(conversationId); - } - void payloadPromise.then((payload) => { - deps.emit?.(turnSettled, payload); - if (!carried) { - // Resolve the persisted workspace id before emitting so the - // broadcast carries the correct workspace. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "idle", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "idle"); - // Fire-and-forget auto-compaction: check threshold and - // compact if exceeded. Non-blocking — the next turn - // starts fresh either way. - const compaction = deps.resolveCompaction?.(); - if (compaction !== undefined) { - void compaction - .compact(conversationId, { - auto: true, - ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), - }) - .catch(() => {}); - } - } - }); - } - })(); - } - - const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) { - if (activeTurns.has(conversationId)) { - return { started: false, reason: "already-active" }; - } - runTurnDetached( - conversationId, - text, - modelName, - cwd, - computerId, - reasoningEffort, - workspaceId ?? "default", - ); - const turn = activeTurns.get(conversationId); - const turnId = turn !== undefined ? turn.turnId : ""; - return { started: true, turnId }; - }, - - enqueue({ conversationId, text, workspaceId, computerId }) { - const result = orchestrator.startTurn({ - conversationId, - text, - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(computerId !== undefined ? { computerId } : {}), - }); - if (result.started) { - return { startedTurn: true, queue: [] }; - } - // Already active → enqueue onto the steering queue. When the - // message-queue extension isn't loaded this degrades: the message is - // dropped and the snapshot is empty (feature off). - const queue = deps.resolveQueue?.(); - const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : []; - return { startedTurn: false, queue: snapshot }; - }, - - subscribe(conversationId, listener) { - let listeners = subscribers.get(conversationId); - if (listeners === undefined) { - listeners = new Set(); - subscribers.set(conversationId, listeners); - } - const turn = activeTurns.get(conversationId); - if (turn !== undefined) { - const snapshot = [...turn.buffer]; - listeners.add(listener); - for (const event of snapshot) { - listener(event); - } - } else { - listeners.add(listener); - } - return () => { - const set = subscribers.get(conversationId); - if (set !== undefined) { - set.delete(listener); - if (set.size === 0) { - subscribers.delete(conversationId); - } - } - }; - }, - - isActive(conversationId) { - return activeTurns.has(conversationId); - }, - - closeConversation(conversationId) { - const turn = activeTurns.get(conversationId); - const abortedTurn = turn !== undefined; - if (turn !== undefined) { - turn.controller.abort(); - } - deps.emit?.(conversationClosed, { conversationId }); - // Resolve the persisted workspace id before emitting so the - // broadcast carries the correct workspace. The hook is - // fire-and-forget; closeConversation stays synchronous (returns - // immediately) while the status-changed emit resolves async. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "closed", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "closed"); - return { abortedTurn }; - }, - - stopTurn(conversationId) { - const turn = activeTurns.get(conversationId); - const abortedTurn = turn !== undefined; - if (turn !== undefined) { - turn.controller.abort(); - } - return { abortedTurn }; - }, - - async handleMessage({ - conversationId, - text, - onEvent, - modelName, - cwd, - computerId, - reasoningEffort, - workspaceId, - }) { - const turnInput: StartTurnInput = { - conversationId, - text, - ...(modelName !== undefined ? { modelName } : {}), - ...(cwd !== undefined ? { cwd } : {}), - ...(computerId !== undefined ? { computerId } : {}), - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - }; - const result = orchestrator.startTurn(turnInput); - if (!result.started) { - const errorTurnId = generateTurnId(); - onEvent({ - type: "error", - conversationId, - turnId: errorTurnId, - message: "turn already active for this conversation", - }); - return; - } - - await new Promise((resolve) => { - const unsubscribe = orchestrator.subscribe(conversationId, (event) => { - onEvent(event); - if (event.type === "turn-sealed" || event.type === "error") { - unsubscribe(); - resolve(); - } - }); - }); - }, - }; - - return { orchestrator, activeConversations }; + const activeConversations = new Set(); + const subscribers = new Map>(); + const activeTurns = new Map(); + // One stateless retry strategy shared by every turn (delayFor is pure; sleep + // is a stateless setTimeout closure). Wired into each RunTurnInput.retry. + const retryStrategy = createRetryStrategy(); + + function emitToHub(conversationId: string, event: AgentEvent): void { + const turn = activeTurns.get(conversationId); + if (turn !== undefined) { + turn.buffer.push(event); + } + const listeners = subscribers.get(conversationId); + if (listeners !== undefined) { + for (const listener of listeners) { + listener(event); + } + } + } + + /** + * Post-seal carry: if a steering queue is available and non-empty, drain it, + * combine, and start a NEW detached turn whose opening `user-message` carries + * the combined text (no `steering` event — that's only for mid-turn drain). + * Returns true iff a new turn was started. Called from `runTurnDetached`'s + * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard + * passes) and BEFORE `activeConversations.delete` (skipped when carried, since + * the new turn re-adds it). May chain — the new turn's own finally re-checks. + */ + function tryCarryQueue(conversationId: string): boolean { + const queue = deps.resolveQueue?.(); + if (queue === undefined) return false; + if (queue.getQueue(conversationId).length === 0) return false; + const drained = queue.drain(conversationId); + const combined = drained.map((q) => q.text).join("\n\n"); + const result = orchestrator.startTurn({ conversationId, text: combined }); + return result.started; + } + + function runTurnDetached( + conversationId: string, + text: string, + modelName: string | undefined, + cwd: string | undefined, + computerId: string | undefined, + reasoningEffortOverride: ReasoningEffort | undefined, + workspaceId: string, + ): void { + const turnId = generateTurnId(); + const controller = new AbortController(); + activeTurns.set(conversationId, { buffer: [], turnId, controller }); + activeConversations.add(conversationId); + + emitToHub(conversationId, { type: "user-message", conversationId, turnId, text }); + + // For a NEW conversation the workspace MUST be assigned (persisted) + // BEFORE getEffectiveCwd runs, so the effective cwd resolves against + // the intended workspace's defaultCwd rather than the stale "default" + // workspace returned by getWorkspaceId for a not-yet-persisted + // conversation. Detect newness via getConversationMeta === null + // (equivalent to history.length === 0 in practice). Existing + // conversations keep their assigned workspace — never overwritten. + // The newness flag is also reused to decide whether to construct + // (first turn) or get (subsequent turn) the system prompt — see the + // providerOpts assembly below. + const workspaceSetupPromise = (async (): Promise => { + const meta = await deps.conversationStore.getConversationMeta(conversationId); + if (meta === null) { + await deps.conversationStore.ensureWorkspace(workspaceId); + await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); + return true; + } + return false; + })(); + + // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the + // per-turn cwd as the overrideCwd when present. A relative per-turn cwd + // (e.g. "arch-rewrite") must be resolved against the workspace's + // defaultCwd via the same workspace-relative algorithm the persisted cwd + // uses — NOT used raw (which would resolve against process.cwd() and + // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd. + // Chained after workspaceSetupPromise so the workspace is assigned + // first for new conversations (the timing invariant this enforces). + const effectiveCwdPromise = workspaceSetupPromise.then(() => + deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), + ); + + // Resolve the effective computer the SAME way cwd resolves — pass the + // per-turn computerId as the overrideAlias. When computerId is + // undefined, getEffectiveComputer reads the persisted per-conversation + // computerId → workspace defaultComputerId → null (LOCAL). Chained + // after workspaceSetupPromise (same timing invariant as cwd). The + // orchestrator never interprets the alias — it forwards the string + // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. + const effectiveComputerIdPromise = workspaceSetupPromise.then(() => + deps.conversationStore + .getEffectiveComputer(conversationId, computerId) + .then((c) => c ?? undefined), + ); + + const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); + // Resolve the persisted model (if any) in parallel with the other + // per-conversation reads. The effective model name is + // per-turn override → persisted → (undefined → default provider), the + // same resolution chain as `resolveReasoningEffort`. + const storedModelPromise = deps.conversationStore.getModel(conversationId); + + const payloadPromise = Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + storedModelPromise, + ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { + const effectiveModelName = resolveModelName(modelName, storedModel); + return { + conversationId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), + }; + }); + + payloadPromise.then((payload) => { + deps.emit?.(turnStarted, payload); + // Resolve the persisted workspace id (not the per-turn start option) + // before emitting so the broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "active", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "active"); + }); + + void (async () => { + let sealed = false; + try { + const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = + await Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + workspaceSetupPromise, + storedModelPromise, + ]); + + if (cwd !== undefined) { + await deps.conversationStore.setCwd(conversationId, cwd); + } + + // Persist the per-turn computer override, mirroring the cwd + // persistence above. Only stamped when a computerId was actually + // provided — NOT when it resolved to undefined (LOCAL) via the + // workspace default. Idempotent when the value is unchanged. + if (computerId !== undefined) { + await deps.conversationStore.setComputerId(conversationId, computerId); + } + + const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); + // Effective model name: per-turn override → persisted → undefined + // (→ default provider). Resolved here so every downstream consumer + // (resolveModel, system prompt, payload) sees the same model as if + // the caller had passed it explicitly. + const effectiveModelName = resolveModelName(modelName, storedModel); + + const history = await deps.conversationStore.load(conversationId); + const userMsg = buildUserMessage(text); + + // Workspace assignment for new conversations happens BEFORE + // effective-cwd resolution (see workspaceSetupPromise above) so + // getEffectiveCwd resolves against the intended workspace, not + // the stale "default". The history-load + append flow below is + // otherwise unchanged. + + let provider: ProviderContract; + let modelOverride: string | undefined; + + if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(effectiveModelName); + if (resolved === undefined) { + emitToHub(conversationId, { + type: "error", + conversationId, + turnId, + message: `unknown model: ${effectiveModelName}`, + }); + return; + } + provider = resolved.provider; + modelOverride = resolved.model; + // Persist the resolved model so it sticks for future turns + // and browser sessions (per-conversation model persistence). + // Only stamped when a model was actually used — NOT on the + // default-provider fallthrough (nothing to persist). Idempotent + // when the value is unchanged (re-stamps the same persisted + // model). The early `return` above means an unknown model is + // never persisted. + await deps.conversationStore.setModel(conversationId, effectiveModelName); + } else { + provider = deps.resolveProvider(); + } + + const baseTools = deps.resolveTools(); + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }); + const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); + const turnLogger = deps.logger?.child({ conversationId, turnId }); + const metrics = createMetricsAccumulator(); + + const emitAndAccumulate = (event: AgentEvent): void => { + metrics.ingest(event); + emitToHub(conversationId, event); + }; + + // Resolve the system prompt for this turn (cache-safe). On the + // FIRST turn of a new conversation, construct it once (resolves all + // template variables + persists the result). On subsequent turns, + // reuse the persisted prompt via `getWithMeta` — but ONLY when the + // stored cwd matches the current effective cwd. If the cwd changed + // since the prompt was constructed (or no prompt was ever stored), + // reconstruct against the new cwd so the prompt is never stale. + // This preserves the cache-safe design (construct once per cwd, + // reuse on subsequent turns with the same cwd) while fixing the bug + // where a cwd change left the prompt stale. When the system-prompt + // service isn't loaded, no system prompt is sent (current behavior + // preserved). + const systemPromptService = deps.resolveSystemPrompt?.(); + let systemPrompt: string | undefined; + if (systemPromptService !== undefined) { + if (isNewConversation) { + systemPrompt = await systemPromptService.construct( + conversationId, + effectiveCwd ?? process.cwd(), + { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }, + ); + } else { + const meta = await systemPromptService.getWithMeta(conversationId); + const currentCwd = effectiveCwd ?? process.cwd(); + const currentComputerId = effectiveComputerId ?? null; + // Invalidate when cwd OR computerId changed (switching computers + // must rebuild the prompt against the remote OS/hostname). + if ( + meta.prompt !== null && + meta.cwd === currentCwd && + meta.computerId === currentComputerId + ) { + systemPrompt = meta.prompt; + } else { + systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }); + } + } + } + + const providerOpts: ProviderStreamOptions = { + reasoningEffort: resolvedEffort, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), + }; + + // Resolve the steering queue once for this turn. When present, wire + // `drainSteering`: the kernel calls it at the tool-result boundary and + // appends whatever it returns as user-role messages alongside the tool + // results (mid-turn steering). The wrapper emits a `steering` AgentEvent + // into the hub (buffered for late-join like `user-message`) so a + // frontend can place a user bubble in the transcript live; the kernel + // only appends the returned messages — it does NOT emit the event. + const queue = deps.resolveQueue?.(); + const drainSteering = + queue === undefined + ? undefined + : (): readonly ChatMessage[] => { + const queued = queue.drain(conversationId); + if (queued.length === 0) return []; + const steerText = queued.map((q) => q.text).join("\n\n"); + emitToHub(conversationId, { + type: "steering", + conversationId, + turnId, + text: steerText, + }); + return [{ role: "user", chunks: [{ type: "text", text: steerText }] }]; + }; + + const opts: RunTurnInput = { + provider, + messages: [...history, userMsg], + tools: assembled.tools, + dispatch, + emit: emitAndAccumulate, + conversationId, + turnId, + signal: controller.signal, + providerOpts, + retry: retryStrategy, + ...(turnLogger !== undefined ? { logger: turnLogger } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + ...(deps.now !== undefined ? { now: deps.now } : {}), + ...(drainSteering !== undefined ? { drainSteering } : {}), + }; + + // Persist the user message at turn start so it has a seq + // number before the first step generates. This enables the + // FE to syncTail during generation (CR-6). + await deps.conversationStore.append(conversationId, [userMsg]); + + let stepsPersisted = false; + const result = await deps.runTurn({ + ...opts, + // Incremental persistence: persist each step's messages + // as they are finalized. Seq numbers are assigned during + // generation, so the FE can GET /conversations/:id?sinceSeq=N + // mid-turn and pick up committed chunks (CR-6). + onStepComplete: async (stepMessages) => { + await deps.conversationStore.append(conversationId, stepMessages); + stepsPersisted = true; + }, + }); + + // Fallback: if onStepComplete was never called (e.g., a fake + // runTurn in tests), persist all result messages as a batch. + if (!stepsPersisted && result.messages.length > 0) { + await deps.conversationStore.append(conversationId, result.messages); + } + + const turnMetrics = metrics.build(turnId); + await deps.conversationStore.appendMetrics(conversationId, turnMetrics); + + emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId }); + sealed = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitToHub(conversationId, { + type: "error", + conversationId, + turnId, + message, + }); + } finally { + activeTurns.delete(conversationId); + // Post-seal carry: if the turn sealed with a non-empty steering queue + // (no tool call fired → drainSteering never drained it), start a NEW + // detached turn whose opening user-message carries the combined text. + // The new turn re-adds to activeTurns + activeConversations, so skip + // the activeConversations.delete when carried. May chain (user keeps + // steering) — each carried turn's own finally re-checks the queue. + const carried = sealed && tryCarryQueue(conversationId); + if (!carried) { + activeConversations.delete(conversationId); + } + void payloadPromise.then((payload) => { + deps.emit?.(turnSettled, payload); + if (!carried) { + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "idle", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "idle"); + // Fire-and-forget auto-compaction: check threshold and + // compact if exceeded. Non-blocking — the next turn + // starts fresh either way. + const compaction = deps.resolveCompaction?.(); + if (compaction !== undefined) { + void compaction + .compact(conversationId, { + auto: true, + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }) + .catch(() => {}); + } + } + }); + } + })(); + } + + const orchestrator: SessionOrchestrator = { + startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) { + if (activeTurns.has(conversationId)) { + return { started: false, reason: "already-active" }; + } + runTurnDetached( + conversationId, + text, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId ?? "default", + ); + const turn = activeTurns.get(conversationId); + const turnId = turn !== undefined ? turn.turnId : ""; + return { started: true, turnId }; + }, + + enqueue({ conversationId, text, workspaceId, computerId }) { + const result = orchestrator.startTurn({ + conversationId, + text, + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(computerId !== undefined ? { computerId } : {}), + }); + if (result.started) { + return { startedTurn: true, queue: [] }; + } + // Already active → enqueue onto the steering queue. When the + // message-queue extension isn't loaded this degrades: the message is + // dropped and the snapshot is empty (feature off). + const queue = deps.resolveQueue?.(); + const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : []; + return { startedTurn: false, queue: snapshot }; + }, + + subscribe(conversationId, listener) { + let listeners = subscribers.get(conversationId); + if (listeners === undefined) { + listeners = new Set(); + subscribers.set(conversationId, listeners); + } + const turn = activeTurns.get(conversationId); + if (turn !== undefined) { + const snapshot = [...turn.buffer]; + listeners.add(listener); + for (const event of snapshot) { + listener(event); + } + } else { + listeners.add(listener); + } + return () => { + const set = subscribers.get(conversationId); + if (set !== undefined) { + set.delete(listener); + if (set.size === 0) { + subscribers.delete(conversationId); + } + } + }; + }, + + isActive(conversationId) { + return activeTurns.has(conversationId); + }, + + closeConversation(conversationId) { + const turn = activeTurns.get(conversationId); + const abortedTurn = turn !== undefined; + if (turn !== undefined) { + turn.controller.abort(); + } + deps.emit?.(conversationClosed, { conversationId }); + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. The hook is + // fire-and-forget; closeConversation stays synchronous (returns + // immediately) while the status-changed emit resolves async. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "closed", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "closed"); + return { abortedTurn }; + }, + + stopTurn(conversationId) { + const turn = activeTurns.get(conversationId); + const abortedTurn = turn !== undefined; + if (turn !== undefined) { + turn.controller.abort(); + } + return { abortedTurn }; + }, + + async handleMessage({ + conversationId, + text, + onEvent, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId, + }) { + const turnInput: StartTurnInput = { + conversationId, + text, + ...(modelName !== undefined ? { modelName } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }; + const result = orchestrator.startTurn(turnInput); + if (!result.started) { + const errorTurnId = generateTurnId(); + onEvent({ + type: "error", + conversationId, + turnId: errorTurnId, + message: "turn already active for this conversation", + }); + return; + } + + await new Promise((resolve) => { + const unsubscribe = orchestrator.subscribe(conversationId, (event) => { + onEvent(event); + if (event.type === "turn-sealed" || event.type === "error") { + unsubscribe(); + resolve(); + } + }); + }); + }, + }; + + return { orchestrator, activeConversations }; } export function createWarmService( - deps: WarmServiceDeps, - activeConversations: ReadonlySet, + deps: WarmServiceDeps, + activeConversations: ReadonlySet, ): WarmService { - return { - async warm(conversationId, opts) { - if (activeConversations.has(conversationId)) { - return { error: "conversation is generating" }; - } - - const history = await deps.conversationStore.load(conversationId); - if (history.length === 0) { - return { error: "no history" }; - } - - let provider: ProviderContract; - let modelOverride: string | undefined; - - // Resolve the model the SAME way the real turn does: per-turn override - // → persisted per-conversation model → default provider. A mismatch here - // silently busts the prompt cache (the model block of the prompt prefix - // diverges from the real turn's). Warm is a probe — it does NOT persist - // (no setModel), it only reads so it sends the same model the next real - // turn will. See notes/observability-design.md §3.1. - const storedModel = await deps.conversationStore.getModel(conversationId); - const effectiveModelName = resolveModelName(opts?.modelName, storedModel); - - if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(effectiveModelName); - if (resolved === undefined) { - return { error: `unknown model: ${effectiveModelName}` }; - } - provider = resolved.provider; - modelOverride = resolved.model; - } else { - provider = deps.resolveProvider(); - } - - const baseTools = deps.resolveTools(); - // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd - // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the - // `load_skill` description per-cwd). If the warm assembles tools under a - // different cwd than the real turn, the tools block — the FIRST bytes of - // the prompt-cache prefix — diverges and the cache misses entirely (0%). - // A manual reheat sends no cwd, so without this fallback it would warm the - // wrong prefix. See notes/observability-design.md §3.1. - const cwd = - (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined; - const assembled = await deps.applyToolsFilter({ - tools: baseTools, - conversationId, - ...(cwd !== undefined ? { cwd } : {}), - }); - - // Resolve reasoning effort the SAME way the real turn does (stored → "high"; - // no per-turn override on warm). A mismatch here silently busts the prompt cache. - const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId); - const resolvedEffort = resolveReasoningEffort(undefined, storedEffort); - - const probeMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "reply with just a ." }], - }; - const messages = [...history, probeMsg]; - - // Capture the warm send as a `provider.request` span, flagged `warm: true` - // so it can be diffed against the corresponding real turn's request (the - // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md - // §3.1). Without this the warm body is invisible and the cache bust is - // undebuggable. The child-bound `warm` attribute flows into the span the - // provider opens (kernel logger merges child attrs into span attributes). - const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); - const providerOpts: ProviderStreamOptions = { - maxTokens: 1, - reasoningEffort: resolvedEffort, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(warmLogger !== undefined ? { logger: warmLogger } : {}), - }; - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - - for await (const event of provider.stream(messages, assembled.tools, providerOpts)) { - if ((event as ProviderEvent).type === "usage") { - const usageEvent = event as UsageEvent; - inputTokens = usageEvent.usage.inputTokens; - outputTokens = usageEvent.usage.outputTokens; - cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0; - cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0; - } - } - - const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }; - deps.emit(warmCompleted, { conversationId, usage: result }); - return result; - }, - }; + return { + async warm(conversationId, opts) { + if (activeConversations.has(conversationId)) { + return { error: "conversation is generating" }; + } + + const history = await deps.conversationStore.load(conversationId); + if (history.length === 0) { + return { error: "no history" }; + } + + let provider: ProviderContract; + let modelOverride: string | undefined; + + // Resolve the model the SAME way the real turn does: per-turn override + // → persisted per-conversation model → default provider. A mismatch here + // silently busts the prompt cache (the model block of the prompt prefix + // diverges from the real turn's). Warm is a probe — it does NOT persist + // (no setModel), it only reads so it sends the same model the next real + // turn will. See notes/observability-design.md §3.1. + const storedModel = await deps.conversationStore.getModel(conversationId); + const effectiveModelName = resolveModelName(opts?.modelName, storedModel); + + if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(effectiveModelName); + if (resolved === undefined) { + return { error: `unknown model: ${effectiveModelName}` }; + } + provider = resolved.provider; + modelOverride = resolved.model; + } else { + provider = deps.resolveProvider(); + } + + const baseTools = deps.resolveTools(); + // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd + // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the + // `load_skill` description per-cwd). If the warm assembles tools under a + // different cwd than the real turn, the tools block — the FIRST bytes of + // the prompt-cache prefix — diverges and the cache misses entirely (0%). + // A manual reheat sends no cwd, so without this fallback it would warm the + // wrong prefix. See notes/observability-design.md §3.1. + const cwd = + (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined; + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(cwd !== undefined ? { cwd } : {}), + }); + + // Resolve reasoning effort the SAME way the real turn does (stored → "high"; + // no per-turn override on warm). A mismatch here silently busts the prompt cache. + const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId); + const resolvedEffort = resolveReasoningEffort(undefined, storedEffort); + + const probeMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "reply with just a ." }], + }; + const messages = [...history, probeMsg]; + + // Capture the warm send as a `provider.request` span, flagged `warm: true` + // so it can be diffed against the corresponding real turn's request (the + // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md + // §3.1). Without this the warm body is invisible and the cache bust is + // undebuggable. The child-bound `warm` attribute flows into the span the + // provider opens (kernel logger merges child attrs into span attributes). + const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); + const providerOpts: ProviderStreamOptions = { + maxTokens: 1, + reasoningEffort: resolvedEffort, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(warmLogger !== undefined ? { logger: warmLogger } : {}), + }; + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + + for await (const event of provider.stream(messages, assembled.tools, providerOpts)) { + if ((event as ProviderEvent).type === "usage") { + const usageEvent = event as UsageEvent; + inputTokens = usageEvent.usage.inputTokens; + outputTokens = usageEvent.usage.outputTokens; + cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0; + cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0; + } + } + + const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }; + deps.emit(warmCompleted, { conversationId, usage: result }); + return result; + }, + }; } const DEFAULT_KEEP_LAST_N = 10; const DEFAULT_COMPACT_PERCENT = 85; const COMPACTION_SYSTEM_PROMPT = - "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + - "Focus on key decisions, context, file paths, and any unresolved questions. " + - "The summary must preserve enough detail for the conversation to continue with full context."; + "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + + "Focus on key decisions, context, file paths, and any unresolved questions. " + + "The summary must preserve enough detail for the conversation to continue with full context."; function formatMessagesForSummary(messages: readonly ChatMessage[]): string { - return messages - .map((msg) => { - const text = msg.chunks - .map((c) => { - if (c.type === "text") return c.text; - if (c.type === "tool-call") return `[tool: ${c.toolName}]`; - if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`; - return ""; - }) - .join(""); - return `${msg.role}: ${text}`; - }) - .join("\n\n"); + return messages + .map((msg) => { + const text = msg.chunks + .map((c) => { + if (c.type === "text") return c.text; + if (c.type === "tool-call") return `[tool: ${c.toolName}]`; + if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`; + return ""; + }) + .join(""); + return `${msg.role}: ${text}`; + }) + .join("\n\n"); } export function createCompactionService( - deps: SessionOrchestratorDeps & { - readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; - }, - activeConversations: ReadonlySet, + deps: SessionOrchestratorDeps & { + readonly emit: (hook: EventHookDescriptor, payload: TPayload) => void; + }, + activeConversations: ReadonlySet, ): CompactionService { - return { - async compact(conversationId, opts) { - if (activeConversations.has(conversationId)) { - return { error: "conversation is generating" }; - } - - const history = await deps.conversationStore.load(conversationId); - const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N; - - if (history.length <= keepLastN) { - return { error: "conversation too short to compact" }; - } - - // Auto mode: check if contextSize exceeds percent of contextWindow. - if (opts?.auto === true) { - const stored = await deps.conversationStore.getCompactPercent(conversationId); - const percent = stored ?? DEFAULT_COMPACT_PERCENT; - if (percent <= 0) return { error: "auto-compact disabled" }; - const metrics = await deps.conversationStore.loadMetrics(conversationId); - const lastTurn = metrics[metrics.length - 1]; - if (lastTurn === undefined) return { error: "no metrics" }; - const contextSize = lastTurn.contextSize; - if (contextSize === undefined) return { error: "no context size" }; - - // Resolve the model's context window. - const modelName = opts.modelName; - if (modelName === undefined || deps.resolveModelInfo === undefined) { - return { error: "cannot resolve model info" }; - } - const info = await deps.resolveModelInfo(modelName); - if (info?.contextWindow === undefined) { - return { error: "model context window unknown" }; - } - const threshold = Math.floor(info.contextWindow * (percent / 100)); - if (contextSize < threshold) return { error: "threshold not exceeded" }; - } - - // Split: old messages to summarize + recent messages to keep. - const toSummarize = history.slice(0, history.length - keepLastN); - const toKeep = history.slice(history.length - keepLastN); - - // Resolve provider - let provider: ProviderContract; - let modelOverride: string | undefined; - if (opts?.modelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(opts.modelName); - if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` }; - provider = resolved.provider; - modelOverride = resolved.model; - } else { - provider = deps.resolveProvider(); - } - - // Build the summarization request: system prompt + conversation text + instruction - const conversationText = formatMessagesForSummary(toSummarize); - const summaryRequest: ChatMessage = { - role: "user", - chunks: [ - { - type: "text", - text: `Please summarize the following conversation:\n\n${conversationText}`, - }, - ], - }; - - const providerOpts: ProviderStreamOptions = { - maxTokens: 2000, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(deps.logger !== undefined - ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) } - : {}), - }; - - // Reconstruct the system prompt on compaction (fresh variable - // resolution — files/cwd/time may have changed since construction). - // The construct call also persists the result for future turns. When - // the system-prompt service is unavailable, fall back to the - // compaction-only system prompt (current behavior, no regression). - const systemPromptService = deps.resolveSystemPrompt?.(); - let compactionSystemPrompt: string; - if (systemPromptService !== undefined) { - const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd(); - const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId); - const computerId = await deps.conversationStore.getEffectiveComputer(conversationId); - const constructed = await systemPromptService.construct(conversationId, cwd, { - ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}), - workspaceId, - ...(computerId !== null ? { computerId } : {}), - }); - compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`; - } else { - compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT; - } - - // Call the provider and accumulate the summary - let summary = ""; - for await (const event of provider.stream([summaryRequest], [], { - ...providerOpts, - systemPrompt: compactionSystemPrompt, - })) { - if ((event as ProviderEvent).type === "text-delta") { - summary += (event as { delta: string }).delta; - } else if ((event as ProviderEvent).type === "error") { - return { error: (event as { message: string }).message }; - } - } - - if (summary.trim().length === 0) { - return { error: "model produced empty summary" }; - } - - // Non-destructive: fork the full pre-compaction history to a new - // archive conversation. The original conversation keeps its ID - // (so messaging between agents still works) and gets the compacted - // content. The archive inherits the original's compactedFrom, - // creating a chain: A → Y → X → ... - const archiveId = crypto.randomUUID(); - await deps.conversationStore.forkHistory(conversationId, archiveId); - - // Replace history: [system: summary] + recent messages - const summaryMessage: ChatMessage = { - role: "system", - chunks: [ - { - type: "text", - text: `The following is a summary of the previous conversation:\n\n${summary}`, - }, - ], - }; - - await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]); - await deps.conversationStore.setCompactedFrom(conversationId, archiveId); - - const result: CompactionResult = { - summary, - newConversationId: archiveId, - messagesSummarized: toSummarize.length, - messagesKept: toKeep.length, - }; - - deps.emit(conversationCompacted, { - conversationId, - newConversationId: archiveId, - messagesSummarized: toSummarize.length, - messagesKept: toKeep.length, - }); - - return result; - }, - }; + return { + async compact(conversationId, opts) { + if (activeConversations.has(conversationId)) { + return { error: "conversation is generating" }; + } + + const history = await deps.conversationStore.load(conversationId); + const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N; + + if (history.length <= keepLastN) { + return { error: "conversation too short to compact" }; + } + + // Auto mode: check if contextSize exceeds percent of contextWindow. + if (opts?.auto === true) { + const stored = await deps.conversationStore.getCompactPercent(conversationId); + const percent = stored ?? DEFAULT_COMPACT_PERCENT; + if (percent <= 0) return { error: "auto-compact disabled" }; + const metrics = await deps.conversationStore.loadMetrics(conversationId); + const lastTurn = metrics[metrics.length - 1]; + if (lastTurn === undefined) return { error: "no metrics" }; + const contextSize = lastTurn.contextSize; + if (contextSize === undefined) return { error: "no context size" }; + + // Resolve the model's context window. + const modelName = opts.modelName; + if (modelName === undefined || deps.resolveModelInfo === undefined) { + return { error: "cannot resolve model info" }; + } + const info = await deps.resolveModelInfo(modelName); + if (info?.contextWindow === undefined) { + return { error: "model context window unknown" }; + } + const threshold = Math.floor(info.contextWindow * (percent / 100)); + if (contextSize < threshold) return { error: "threshold not exceeded" }; + } + + // Split: old messages to summarize + recent messages to keep. + const toSummarize = history.slice(0, history.length - keepLastN); + const toKeep = history.slice(history.length - keepLastN); + + // Resolve provider + let provider: ProviderContract; + let modelOverride: string | undefined; + if (opts?.modelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(opts.modelName); + if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` }; + provider = resolved.provider; + modelOverride = resolved.model; + } else { + provider = deps.resolveProvider(); + } + + // Build the summarization request: system prompt + conversation text + instruction + const conversationText = formatMessagesForSummary(toSummarize); + const summaryRequest: ChatMessage = { + role: "user", + chunks: [ + { + type: "text", + text: `Please summarize the following conversation:\n\n${conversationText}`, + }, + ], + }; + + const providerOpts: ProviderStreamOptions = { + maxTokens: 2000, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(deps.logger !== undefined + ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) } + : {}), + }; + + // Reconstruct the system prompt on compaction (fresh variable + // resolution — files/cwd/time may have changed since construction). + // The construct call also persists the result for future turns. When + // the system-prompt service is unavailable, fall back to the + // compaction-only system prompt (current behavior, no regression). + const systemPromptService = deps.resolveSystemPrompt?.(); + let compactionSystemPrompt: string; + if (systemPromptService !== undefined) { + const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd(); + const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId); + const computerId = await deps.conversationStore.getEffectiveComputer(conversationId); + const constructed = await systemPromptService.construct(conversationId, cwd, { + ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}), + workspaceId, + ...(computerId !== null ? { computerId } : {}), + }); + compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`; + } else { + compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT; + } + + // Call the provider and accumulate the summary + let summary = ""; + for await (const event of provider.stream([summaryRequest], [], { + ...providerOpts, + systemPrompt: compactionSystemPrompt, + })) { + if ((event as ProviderEvent).type === "text-delta") { + summary += (event as { delta: string }).delta; + } else if ((event as ProviderEvent).type === "error") { + return { error: (event as { message: string }).message }; + } + } + + if (summary.trim().length === 0) { + return { error: "model produced empty summary" }; + } + + // Non-destructive: fork the full pre-compaction history to a new + // archive conversation. The original conversation keeps its ID + // (so messaging between agents still works) and gets the compacted + // content. The archive inherits the original's compactedFrom, + // creating a chain: A → Y → X → ... + const archiveId = crypto.randomUUID(); + await deps.conversationStore.forkHistory(conversationId, archiveId); + + // Replace history: [system: summary] + recent messages + const summaryMessage: ChatMessage = { + role: "system", + chunks: [ + { + type: "text", + text: `The following is a summary of the previous conversation:\n\n${summary}`, + }, + ], + }; + + await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]); + await deps.conversationStore.setCompactedFrom(conversationId, archiveId); + + const result: CompactionResult = { + summary, + newConversationId: archiveId, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }; + + deps.emit(conversationCompacted, { + conversationId, + newConversationId: archiveId, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }); + + return result; + }, + }; } diff --git a/packages/session-orchestrator/src/pure.test.ts b/packages/session-orchestrator/src/pure.test.ts index 2cbe15f..c75cb82 100644 --- a/packages/session-orchestrator/src/pure.test.ts +++ b/packages/session-orchestrator/src/pure.test.ts @@ -1,163 +1,163 @@ import type { ProviderContract } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { - buildUserMessage, - cumulativeSleepMs, - defaultDispatchPolicy, - delayFor, - generateTurnId, - RETRY_BUDGET_MS, - RETRY_SCHEDULE_MS, - RETRY_TAIL_MS, - resolveReasoningEffort, - selectFirstProvider, + buildUserMessage, + cumulativeSleepMs, + defaultDispatchPolicy, + delayFor, + generateTurnId, + RETRY_BUDGET_MS, + RETRY_SCHEDULE_MS, + RETRY_TAIL_MS, + resolveReasoningEffort, + selectFirstProvider, } from "./pure.js"; describe("buildUserMessage", () => { - it("creates a user message with a single text chunk", () => { - const msg = buildUserMessage("hello world"); - expect(msg.role).toBe("user"); - expect(msg.chunks).toHaveLength(1); - expect(msg.chunks[0]).toEqual({ type: "text", text: "hello world" }); - }); - - it("preserves empty text", () => { - const msg = buildUserMessage(""); - expect(msg.role).toBe("user"); - expect(msg.chunks[0]).toEqual({ type: "text", text: "" }); - }); + it("creates a user message with a single text chunk", () => { + const msg = buildUserMessage("hello world"); + expect(msg.role).toBe("user"); + expect(msg.chunks).toHaveLength(1); + expect(msg.chunks[0]).toEqual({ type: "text", text: "hello world" }); + }); + + it("preserves empty text", () => { + const msg = buildUserMessage(""); + expect(msg.role).toBe("user"); + expect(msg.chunks[0]).toEqual({ type: "text", text: "" }); + }); }); describe("selectFirstProvider", () => { - it("returns the first provider from a non-empty map", () => { - const provider: ProviderContract = { - id: "test-provider", - stream: async function* () {}, - }; - const providers = new Map(); - providers.set("test-provider", provider); - - expect(selectFirstProvider(providers)).toBe(provider); - }); - - it("throws when the map is empty", () => { - const providers = new Map(); - expect(() => selectFirstProvider(providers)).toThrow("No providers registered"); - }); - - it("returns the first inserted provider when multiple exist", () => { - const first: ProviderContract = { id: "first", stream: async function* () {} }; - const second: ProviderContract = { id: "second", stream: async function* () {} }; - const providers = new Map(); - providers.set("first", first); - providers.set("second", second); - - expect(selectFirstProvider(providers).id).toBe("first"); - }); + it("returns the first provider from a non-empty map", () => { + const provider: ProviderContract = { + id: "test-provider", + stream: async function* () {}, + }; + const providers = new Map(); + providers.set("test-provider", provider); + + expect(selectFirstProvider(providers)).toBe(provider); + }); + + it("throws when the map is empty", () => { + const providers = new Map(); + expect(() => selectFirstProvider(providers)).toThrow("No providers registered"); + }); + + it("returns the first inserted provider when multiple exist", () => { + const first: ProviderContract = { id: "first", stream: async function* () {} }; + const second: ProviderContract = { id: "second", stream: async function* () {} }; + const providers = new Map(); + providers.set("first", first); + providers.set("second", second); + + expect(selectFirstProvider(providers).id).toBe("first"); + }); }); describe("defaultDispatchPolicy", () => { - it("returns maxConcurrent: 1, eager: true", () => { - expect(defaultDispatchPolicy()).toEqual({ maxConcurrent: 1, eager: true }); - }); + it("returns maxConcurrent: 1, eager: true", () => { + expect(defaultDispatchPolicy()).toEqual({ maxConcurrent: 1, eager: true }); + }); }); describe("generateTurnId", () => { - it("returns a string starting with 'turn-'", () => { - const id = generateTurnId(); - expect(id).toMatch(/^turn-/); - }); - - it("returns unique ids", () => { - const ids = new Set(Array.from({ length: 100 }, () => generateTurnId())); - expect(ids.size).toBe(100); - }); + it("returns a string starting with 'turn-'", () => { + const id = generateTurnId(); + expect(id).toMatch(/^turn-/); + }); + + it("returns unique ids", () => { + const ids = new Set(Array.from({ length: 100 }, () => generateTurnId())); + expect(ids.size).toBe(100); + }); }); describe("resolveReasoningEffort", () => { - it("override wins over stored", () => { - expect(resolveReasoningEffort("low", "high")).toBe("low"); - expect(resolveReasoningEffort("max", "medium")).toBe("max"); - }); - - it("stored wins over default", () => { - expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); - expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); - }); - - it("default is 'high' when both are absent", () => { - expect(resolveReasoningEffort(undefined, null)).toBe("high"); - }); - - it("all 5 levels pass through as override", () => { - expect(resolveReasoningEffort("low", null)).toBe("low"); - expect(resolveReasoningEffort("medium", null)).toBe("medium"); - expect(resolveReasoningEffort("high", null)).toBe("high"); - expect(resolveReasoningEffort("xhigh", null)).toBe("xhigh"); - expect(resolveReasoningEffort("max", null)).toBe("max"); - }); - - it("all 5 levels pass through as stored", () => { - expect(resolveReasoningEffort(undefined, "low")).toBe("low"); - expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); - expect(resolveReasoningEffort(undefined, "high")).toBe("high"); - expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); - expect(resolveReasoningEffort(undefined, "max")).toBe("max"); - }); + it("override wins over stored", () => { + expect(resolveReasoningEffort("low", "high")).toBe("low"); + expect(resolveReasoningEffort("max", "medium")).toBe("max"); + }); + + it("stored wins over default", () => { + expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); + expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); + }); + + it("default is 'high' when both are absent", () => { + expect(resolveReasoningEffort(undefined, null)).toBe("high"); + }); + + it("all 5 levels pass through as override", () => { + expect(resolveReasoningEffort("low", null)).toBe("low"); + expect(resolveReasoningEffort("medium", null)).toBe("medium"); + expect(resolveReasoningEffort("high", null)).toBe("high"); + expect(resolveReasoningEffort("xhigh", null)).toBe("xhigh"); + expect(resolveReasoningEffort("max", null)).toBe("max"); + }); + + it("all 5 levels pass through as stored", () => { + expect(resolveReasoningEffort(undefined, "low")).toBe("low"); + expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); + expect(resolveReasoningEffort(undefined, "high")).toBe("high"); + expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); + expect(resolveReasoningEffort(undefined, "max")).toBe("max"); + }); }); describe("retry backoff schedule (delayFor)", () => { - it("emits the stepped head: 5s, 10s, 30s, 60s, 5m, 10m, 15m, 30m", () => { - expect(delayFor(0)).toBe(5_000); - expect(delayFor(1)).toBe(10_000); - expect(delayFor(2)).toBe(30_000); - expect(delayFor(3)).toBe(60_000); - expect(delayFor(4)).toBe(300_000); - expect(delayFor(5)).toBe(600_000); - expect(delayFor(6)).toBe(900_000); - expect(delayFor(7)).toBe(1_800_000); - }); - - it("repeats 30m after the head", () => { - expect(delayFor(8)).toBe(RETRY_TAIL_MS); - expect(delayFor(9)).toBe(RETRY_TAIL_MS); - expect(delayFor(20)).toBe(RETRY_TAIL_MS); - }); - - it("gives up (returns undefined) once cumulative sleep exceeds 8h", () => { - // Head sums to 3,705,000 ms; +1,800,000 per extra step. 8h = 28,800,000. - // attempt 20 cumulative = 3,705,000 + 13*1,800,000 = 27,105,000 (< 8h) → retry. - expect(delayFor(20)).toBe(RETRY_TAIL_MS); - // attempt 21 cumulative = 27,105,000 + 1,800,000 = 28,905,000 (> 8h) → stop. - expect(delayFor(21)).toBeUndefined(); - }); - - it("cumulativeSleepMs matches the sum of the schedule", () => { - expect(cumulativeSleepMs(0)).toBe(5_000); - expect(cumulativeSleepMs(1)).toBe(15_000); - expect(cumulativeSleepMs(7)).toBe(RETRY_SCHEDULE_MS.reduce((a, b) => a + b, 0)); - // 8h budget is 28,800,000 ms. - expect(RETRY_BUDGET_MS).toBe(8 * 60 * 60 * 1000); - // The last retry (attempt 20) keeps cumulative under budget. - expect(cumulativeSleepMs(20)).toBeLessThanOrEqual(RETRY_BUDGET_MS); - // The next (attempt 21) exceeds it. - expect(cumulativeSleepMs(21)).toBeGreaterThan(RETRY_BUDGET_MS); - }); - - it("the full schedule has 21 retries then stops", () => { - const schedule: number[] = []; - let attempt = 0; - while (true) { - const delay = delayFor(attempt); - if (delay === undefined) break; - schedule.push(delay); - attempt++; - } - expect(schedule).toHaveLength(21); - expect(schedule[0]).toBe(5_000); - expect(schedule.at(-1)).toBe(RETRY_TAIL_MS); - // 8 stepped head + 13 tail repeats. - expect(schedule.slice(0, 8)).toEqual([...RETRY_SCHEDULE_MS]); - expect(schedule.slice(8).every((d) => d === RETRY_TAIL_MS)).toBe(true); - }); + it("emits the stepped head: 5s, 10s, 30s, 60s, 5m, 10m, 15m, 30m", () => { + expect(delayFor(0)).toBe(5_000); + expect(delayFor(1)).toBe(10_000); + expect(delayFor(2)).toBe(30_000); + expect(delayFor(3)).toBe(60_000); + expect(delayFor(4)).toBe(300_000); + expect(delayFor(5)).toBe(600_000); + expect(delayFor(6)).toBe(900_000); + expect(delayFor(7)).toBe(1_800_000); + }); + + it("repeats 30m after the head", () => { + expect(delayFor(8)).toBe(RETRY_TAIL_MS); + expect(delayFor(9)).toBe(RETRY_TAIL_MS); + expect(delayFor(20)).toBe(RETRY_TAIL_MS); + }); + + it("gives up (returns undefined) once cumulative sleep exceeds 8h", () => { + // Head sums to 3,705,000 ms; +1,800,000 per extra step. 8h = 28,800,000. + // attempt 20 cumulative = 3,705,000 + 13*1,800,000 = 27,105,000 (< 8h) → retry. + expect(delayFor(20)).toBe(RETRY_TAIL_MS); + // attempt 21 cumulative = 27,105,000 + 1,800,000 = 28,905,000 (> 8h) → stop. + expect(delayFor(21)).toBeUndefined(); + }); + + it("cumulativeSleepMs matches the sum of the schedule", () => { + expect(cumulativeSleepMs(0)).toBe(5_000); + expect(cumulativeSleepMs(1)).toBe(15_000); + expect(cumulativeSleepMs(7)).toBe(RETRY_SCHEDULE_MS.reduce((a, b) => a + b, 0)); + // 8h budget is 28,800,000 ms. + expect(RETRY_BUDGET_MS).toBe(8 * 60 * 60 * 1000); + // The last retry (attempt 20) keeps cumulative under budget. + expect(cumulativeSleepMs(20)).toBeLessThanOrEqual(RETRY_BUDGET_MS); + // The next (attempt 21) exceeds it. + expect(cumulativeSleepMs(21)).toBeGreaterThan(RETRY_BUDGET_MS); + }); + + it("the full schedule has 21 retries then stops", () => { + const schedule: number[] = []; + let attempt = 0; + while (true) { + const delay = delayFor(attempt); + if (delay === undefined) break; + schedule.push(delay); + attempt++; + } + expect(schedule).toHaveLength(21); + expect(schedule[0]).toBe(5_000); + expect(schedule.at(-1)).toBe(RETRY_TAIL_MS); + // 8 stepped head + 13 tail repeats. + expect(schedule.slice(0, 8)).toEqual([...RETRY_SCHEDULE_MS]); + expect(schedule.slice(8).every((d) => d === RETRY_TAIL_MS)).toBe(true); + }); }); diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts index a028cbe..2208e8f 100644 --- a/packages/session-orchestrator/src/pure.ts +++ b/packages/session-orchestrator/src/pure.ts @@ -1,12 +1,12 @@ import type { - ChatMessage, - ProviderContract, - ReasoningEffort, - ToolDispatchPolicy, + ChatMessage, + ProviderContract, + ReasoningEffort, + ToolDispatchPolicy, } from "@dispatch/kernel"; export function buildUserMessage(text: string): ChatMessage { - return { role: "user", chunks: [{ type: "text", text }] }; + return { role: "user", chunks: [{ type: "text", text }] }; } // ── Provider-error retry backoff schedule ─────────────────────────────────── @@ -20,7 +20,7 @@ export function buildUserMessage(text: string): ChatMessage { * After the head is exhausted, {@link RETRY_TAIL_MS} (30m) repeats. */ export const RETRY_SCHEDULE_MS = [ - 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, + 5_000, 10_000, 30_000, 60_000, 300_000, 600_000, 900_000, 1_800_000, ] as const; /** Tail delay (ms) repeated after the stepped head: 30 minutes. */ @@ -34,11 +34,11 @@ export const RETRY_BUDGET_MS = 8 * 60 * 60 * 1000; * Pure — no I/O, no clock. */ export function cumulativeSleepMs(attempt: number): number { - let sum = 0; - for (let i = 0; i <= attempt; i++) { - sum += i < RETRY_SCHEDULE_MS.length ? (RETRY_SCHEDULE_MS[i] ?? RETRY_TAIL_MS) : RETRY_TAIL_MS; - } - return sum; + let sum = 0; + for (let i = 0; i <= attempt; i++) { + sum += i < RETRY_SCHEDULE_MS.length ? (RETRY_SCHEDULE_MS[i] ?? RETRY_TAIL_MS) : RETRY_TAIL_MS; + } + return sum; } /** @@ -50,10 +50,10 @@ export function cumulativeSleepMs(attempt: number): number { * cumulative scheduled sleep is reached, then give up. */ export function delayFor(attempt: number): number | undefined { - const scheduled = RETRY_SCHEDULE_MS[attempt]; - const delay = scheduled !== undefined ? scheduled : RETRY_TAIL_MS; - if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop - return delay; + const scheduled = RETRY_SCHEDULE_MS[attempt]; + const delay = scheduled !== undefined ? scheduled : RETRY_TAIL_MS; + if (cumulativeSleepMs(attempt) > RETRY_BUDGET_MS) return undefined; // over budget → stop + return delay; } /** @@ -62,10 +62,10 @@ export function delayFor(attempt: number): number | undefined { * Pure — no I/O, no ambient state. */ export function resolveReasoningEffort( - override: ReasoningEffort | undefined, - stored: ReasoningEffort | null, + override: ReasoningEffort | undefined, + stored: ReasoningEffort | null, ): ReasoningEffort { - return override ?? stored ?? "high"; + return override ?? stored ?? "high"; } /** @@ -79,30 +79,30 @@ export function resolveReasoningEffort( * "no model override" code path untouched. Pure — no I/O, no ambient state. */ export function resolveModelName( - override: string | undefined, - stored: string | null, + override: string | undefined, + stored: string | null, ): string | undefined { - return override ?? stored ?? undefined; + return override ?? stored ?? undefined; } export function selectFirstProvider( - providers: ReadonlyMap, + providers: ReadonlyMap, ): ProviderContract { - const first = providers.values().next(); - if (first.done === true || first.value === undefined) { - throw new Error("No providers registered — at least one provider is required to run a turn."); - } - return first.value; + const first = providers.values().next(); + if (first.done === true || first.value === undefined) { + throw new Error("No providers registered — at least one provider is required to run a turn."); + } + return first.value; } export function resolveTools(tools: ReadonlyMap): readonly unknown[] { - return [...tools.values()]; + return [...tools.values()]; } export function defaultDispatchPolicy(): ToolDispatchPolicy { - return { maxConcurrent: 1, eager: true }; + return { maxConcurrent: 1, eager: true }; } export function generateTurnId(): string { - return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index adf5d9a..a09a441 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -1,15 +1,15 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { - AgentEvent, - ChatMessage, - ProviderContract, - ProviderEvent, - ReasoningEffort, - RunTurnInput, - RunTurnResult, - StoredChunk, - ToolContract, - TurnMetrics, + AgentEvent, + ChatMessage, + ProviderContract, + ProviderEvent, + ReasoningEffort, + RunTurnInput, + RunTurnResult, + StoredChunk, + ToolContract, + TurnMetrics, } from "@dispatch/kernel"; import { runTurn } from "@dispatch/kernel"; import { createMessageQueueService } from "@dispatch/message-queue"; @@ -21,177 +21,177 @@ import type { ToolAssembly } from "./tools-filter.js"; // a shared test-helper module wired between test files is a coupling smell) --- function createInMemoryStore(): ConversationStore & { - readonly data: Map; - readonly metricsData: Map; - readonly cwdData: Map; - readonly effortData: Map; - readonly modelData: Map; + readonly data: Map; + readonly metricsData: Map; + readonly cwdData: Map; + readonly effortData: Map; + readonly modelData: Map; } { - const data = new Map(); - const metricsData = new Map(); - const cwdData = new Map(); - const effortData = new Map(); - const modelData = new Map(); - return { - data, - metricsData, - cwdData, - effortData, - modelData, - async append(conversationId, messages) { - const existing = data.get(conversationId) ?? []; - data.set(conversationId, [...existing, ...messages]); - }, - async load(conversationId) { - return [...(data.get(conversationId) ?? [])]; - }, - async loadSince(conversationId, sinceSeq) { - const messages = data.get(conversationId) ?? []; - const result: StoredChunk[] = []; - let seq = 1; - for (const msg of messages) { - for (const chunk of msg.chunks) { - if (sinceSeq === undefined || seq > sinceSeq) { - result.push({ seq, role: msg.role, chunk }); - } - seq++; - } - } - return result; - }, - async appendMetrics(conversationId, metrics) { - const existing = metricsData.get(conversationId) ?? []; - metricsData.set(conversationId, [...existing, metrics]); - }, - async loadMetrics(conversationId) { - return [...(metricsData.get(conversationId) ?? [])]; - }, - async getCwd(conversationId) { - return cwdData.get(conversationId) ?? null; - }, - async setCwd(conversationId, cwd) { - cwdData.set(conversationId, cwd); - }, - async clearCwd(conversationId) { - cwdData.delete(conversationId); - }, - async getComputerId() { - return null; - }, - async setComputerId() {}, - async clearComputerId() {}, - async getReasoningEffort(conversationId) { - return effortData.get(conversationId) ?? null; - }, - async setReasoningEffort(conversationId, effort) { - effortData.set(conversationId, effort); - }, - async getModel(conversationId) { - return modelData.get(conversationId) ?? null; - }, - async setModel(conversationId, model) { - if (model === "") { - modelData.delete(conversationId); - } else { - modelData.set(conversationId, model); - } - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle(id, title) { - return { - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd(conversationId) { - return cwdData.get(conversationId) ?? null; - }, - async getEffectiveComputer() { - return null; - }, - }; + const data = new Map(); + const metricsData = new Map(); + const cwdData = new Map(); + const effortData = new Map(); + const modelData = new Map(); + return { + data, + metricsData, + cwdData, + effortData, + modelData, + async append(conversationId, messages) { + const existing = data.get(conversationId) ?? []; + data.set(conversationId, [...existing, ...messages]); + }, + async load(conversationId) { + return [...(data.get(conversationId) ?? [])]; + }, + async loadSince(conversationId, sinceSeq) { + const messages = data.get(conversationId) ?? []; + const result: StoredChunk[] = []; + let seq = 1; + for (const msg of messages) { + for (const chunk of msg.chunks) { + if (sinceSeq === undefined || seq > sinceSeq) { + result.push({ seq, role: msg.role, chunk }); + } + seq++; + } + } + return result; + }, + async appendMetrics(conversationId, metrics) { + const existing = metricsData.get(conversationId) ?? []; + metricsData.set(conversationId, [...existing, metrics]); + }, + async loadMetrics(conversationId) { + return [...(metricsData.get(conversationId) ?? [])]; + }, + async getCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdData.set(conversationId, cwd); + }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, + async getReasoningEffort(conversationId) { + return effortData.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + effortData.set(conversationId, effort); + }, + async getModel(conversationId) { + return modelData.get(conversationId) ?? null; + }, + async setModel(conversationId, model) { + if (model === "") { + modelData.delete(conversationId); + } else { + modelData.set(conversationId, model); + } + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle(id, title) { + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async getEffectiveComputer() { + return null; + }, + }; } function identityApplyToolsFilter(assembly: ToolAssembly): Promise { - return Promise.resolve(assembly); + return Promise.resolve(assembly); } function noTools(): readonly ToolContract[] { - return []; + return []; } function simpleProvider(): ProviderContract { - return { - id: "fake", - stream: async function* () { - yield { type: "text-delta", delta: "ok" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; + return { + id: "fake", + stream: async function* () { + yield { type: "text-delta", delta: "ok" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; } /** @@ -201,395 +201,395 @@ function simpleProvider(): ProviderContract { * of @dispatch/* — it's a plain fake of the outermost runTurn edge. */ function createDrainingCaptureRunTurn(): { - captured: RunTurnInput[]; - drainedMessages: ChatMessage[]; - wasDrainCalled: () => boolean; - runTurn: (input: RunTurnInput) => Promise; + captured: RunTurnInput[]; + drainedMessages: ChatMessage[]; + wasDrainCalled: () => boolean; + runTurn: (input: RunTurnInput) => Promise; } { - const captured: RunTurnInput[] = []; - const drainedMessages: ChatMessage[] = []; - let drainCalled = false; - return { - captured, - drainedMessages, - wasDrainCalled: () => drainCalled, - runTurn: async (input) => { - captured.push(input); - if (input.drainSteering !== undefined) { - drainCalled = true; - const drained = input.drainSteering(); - drainedMessages.push(...drained); - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }, - }; + const captured: RunTurnInput[] = []; + const drainedMessages: ChatMessage[] = []; + let drainCalled = false; + return { + captured, + drainedMessages, + wasDrainCalled: () => drainCalled, + runTurn: async (input) => { + captured.push(input); + if (input.drainSteering !== undefined) { + drainCalled = true; + const drained = input.drainSteering(); + drainedMessages.push(...drained); + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }, + }; } function waitForSealed( - orchestrator: ReturnType["orchestrator"], - conversationId: string, + orchestrator: ReturnType["orchestrator"], + conversationId: string, ): Promise { - return new Promise((resolve) => { - const unsub = orchestrator.subscribe(conversationId, (e) => { - if (e.type === "turn-sealed") { - unsub(); - resolve(); - } - }); - }); + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + unsub(); + resolve(); + } + }); + }); } function waitForSealedCount( - orchestrator: ReturnType["orchestrator"], - conversationId: string, - count: number, + orchestrator: ReturnType["orchestrator"], + conversationId: string, + count: number, ): Promise { - return new Promise((resolve) => { - let seen = 0; - const unsub = orchestrator.subscribe(conversationId, (e) => { - if (e.type === "turn-sealed") { - seen++; - if (seen >= count) { - unsub(); - resolve(); - } - } - }); - }); + return new Promise((resolve) => { + let seen = 0; + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + seen++; + if (seen >= count) { + unsub(); + resolve(); + } + } + }); + }); } function isSteering(e: AgentEvent): e is Extract { - return e.type === "steering"; + return e.type === "steering"; } function isUserMessage(e: AgentEvent): e is Extract { - return e.type === "user-message"; + return e.type === "user-message"; } function createTestQueue() { - return createMessageQueueService({ - id: () => `q-${Math.random().toString(36).slice(2, 8)}`, - now: () => 1000, - notify: () => {}, - }); + return createMessageQueueService({ + id: () => `q-${Math.random().toString(36).slice(2, 8)}`, + now: () => 1000, + notify: () => {}, + }); } // --- drainSteering (mid-turn, at the tool-result boundary) --- describe("drainSteering", () => { - it("drainSteering drains the queue + emits a steering event + returns one combined user message", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - queue.enqueue("conv-drain", "first"); - queue.enqueue("conv-drain", "second"); - - const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-drain", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-drain", text: "go" }); - await waitForSealed(orchestrator, "conv-drain"); - unsub(); - - // drainSteering was wired on the RunTurnInput - expect(captured).toHaveLength(1); - expect(captured[0]?.drainSteering).toBeDefined(); - expect(typeof captured[0]?.drainSteering).toBe("function"); - - // The fake runTurn called drainSteering → returned one combined user message - expect(drainedMessages).toHaveLength(1); - const steerMsg = drainedMessages[0]; - if (steerMsg === undefined) throw new Error("expected drained message"); - expect(steerMsg.role).toBe("user"); - expect(steerMsg.chunks).toHaveLength(1); - const chunk = steerMsg.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("text"); - if (chunk.type === "text") { - expect(chunk.text).toBe("first\n\nsecond"); - } - - // The queue was drained (cleared) - expect(queue.getQueue("conv-drain")).toHaveLength(0); - - // A steering event was emitted into the hub with the combined text - const steering = events.find(isSteering); - expect(steering).toBeDefined(); - expect(steering?.conversationId).toBe("conv-drain"); - expect(steering?.text).toBe("first\n\nsecond"); - expect(steering?.turnId).toMatch(/^turn-/); - }); - - it("drainSteering on an empty queue returns [] and emits nothing", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { - drainedMessages, - wasDrainCalled, - runTurn: captureRunTurn, - } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-empty", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-empty", text: "go" }); - await waitForSealed(orchestrator, "conv-empty"); - unsub(); - - // drainSteering was wired and called, but returned [] - expect(wasDrainCalled()).toBe(true); - expect(drainedMessages).toHaveLength(0); - - // No steering event was emitted - expect(events.filter(isSteering)).toHaveLength(0); - }); - - it("no queue ext (resolveQueue undefined) → drainSteering omitted; turn unchanged", async () => { - const store = createInMemoryStore(); - - const { captured, wasDrainCalled, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - // resolveQueue intentionally omitted — feature degrades off - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-noqueue", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-noqueue", text: "go" }); - await waitForSealed(orchestrator, "conv-noqueue"); - unsub(); - - // drainSteering is absent from the RunTurnInput (not undefined — omitted) - expect(captured).toHaveLength(1); - expect(captured[0]?.drainSteering).toBeUndefined(); - expect(wasDrainCalled()).toBe(false); - - // No steering event; turn sealed normally - expect(events.filter(isSteering)).toHaveLength(0); - expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); - }); + it("drainSteering drains the queue + emits a steering event + returns one combined user message", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + queue.enqueue("conv-drain", "first"); + queue.enqueue("conv-drain", "second"); + + const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-drain", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-drain", text: "go" }); + await waitForSealed(orchestrator, "conv-drain"); + unsub(); + + // drainSteering was wired on the RunTurnInput + expect(captured).toHaveLength(1); + expect(captured[0]?.drainSteering).toBeDefined(); + expect(typeof captured[0]?.drainSteering).toBe("function"); + + // The fake runTurn called drainSteering → returned one combined user message + expect(drainedMessages).toHaveLength(1); + const steerMsg = drainedMessages[0]; + if (steerMsg === undefined) throw new Error("expected drained message"); + expect(steerMsg.role).toBe("user"); + expect(steerMsg.chunks).toHaveLength(1); + const chunk = steerMsg.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("text"); + if (chunk.type === "text") { + expect(chunk.text).toBe("first\n\nsecond"); + } + + // The queue was drained (cleared) + expect(queue.getQueue("conv-drain")).toHaveLength(0); + + // A steering event was emitted into the hub with the combined text + const steering = events.find(isSteering); + expect(steering).toBeDefined(); + expect(steering?.conversationId).toBe("conv-drain"); + expect(steering?.text).toBe("first\n\nsecond"); + expect(steering?.turnId).toMatch(/^turn-/); + }); + + it("drainSteering on an empty queue returns [] and emits nothing", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { + drainedMessages, + wasDrainCalled, + runTurn: captureRunTurn, + } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-empty", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-empty", text: "go" }); + await waitForSealed(orchestrator, "conv-empty"); + unsub(); + + // drainSteering was wired and called, but returned [] + expect(wasDrainCalled()).toBe(true); + expect(drainedMessages).toHaveLength(0); + + // No steering event was emitted + expect(events.filter(isSteering)).toHaveLength(0); + }); + + it("no queue ext (resolveQueue undefined) → drainSteering omitted; turn unchanged", async () => { + const store = createInMemoryStore(); + + const { captured, wasDrainCalled, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + // resolveQueue intentionally omitted — feature degrades off + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-noqueue", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-noqueue", text: "go" }); + await waitForSealed(orchestrator, "conv-noqueue"); + unsub(); + + // drainSteering is absent from the RunTurnInput (not undefined — omitted) + expect(captured).toHaveLength(1); + expect(captured[0]?.drainSteering).toBeUndefined(); + expect(wasDrainCalled()).toBe(false); + + // No steering event; turn sealed normally + expect(events.filter(isSteering)).toHaveLength(0); + expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); + }); }); // --- Post-seal carry (turn ended with a non-empty queue → new turn) --- describe("post-seal carry", () => { - it("post-seal: non-empty queue → a new turn starts with the combined message", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - queue.enqueue("conv-carry", "queued-a"); - queue.enqueue("conv-carry", "queued-b"); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-carry", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-carry", text: "original" }); - // Wait for the original turn + the carried turn to both seal. - await waitForSealedCount(orchestrator, "conv-carry", 2); - unsub(); - - // Two user-message events: the original prompt + the carried combined text. - const userMessages = events.filter(isUserMessage); - expect(userMessages).toHaveLength(2); - expect(userMessages[0]?.text).toBe("original"); - expect(userMessages[1]?.text).toBe("queued-a\n\nqueued-b"); - - // No steering event — the carry case emits user-message, not steering. - expect(events.filter(isSteering)).toHaveLength(0); - - // The queue was drained by the carry. - expect(queue.getQueue("conv-carry")).toHaveLength(0); - - // Both turns persisted (original + carry). - expect(store.data.get("conv-carry")?.length).toBeGreaterThanOrEqual(4); - }); - - it("post-seal: empty queue → no new turn", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-no-carry", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-no-carry", text: "original" }); - await waitForSealed(orchestrator, "conv-no-carry"); - // Give the carry check a chance to run (it's in the finally, synchronous - // after turn-sealed, but await yields first). - await new Promise((resolve) => setTimeout(resolve, 10)); - unsub(); - - // Only one user-message (the original) — no carry turn. - expect(events.filter(isUserMessage)).toHaveLength(1); - expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); - }); + it("post-seal: non-empty queue → a new turn starts with the combined message", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + queue.enqueue("conv-carry", "queued-a"); + queue.enqueue("conv-carry", "queued-b"); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-carry", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-carry", text: "original" }); + // Wait for the original turn + the carried turn to both seal. + await waitForSealedCount(orchestrator, "conv-carry", 2); + unsub(); + + // Two user-message events: the original prompt + the carried combined text. + const userMessages = events.filter(isUserMessage); + expect(userMessages).toHaveLength(2); + expect(userMessages[0]?.text).toBe("original"); + expect(userMessages[1]?.text).toBe("queued-a\n\nqueued-b"); + + // No steering event — the carry case emits user-message, not steering. + expect(events.filter(isSteering)).toHaveLength(0); + + // The queue was drained by the carry. + expect(queue.getQueue("conv-carry")).toHaveLength(0); + + // Both turns persisted (original + carry). + expect(store.data.get("conv-carry")?.length).toBeGreaterThanOrEqual(4); + }); + + it("post-seal: empty queue → no new turn", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-no-carry", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-no-carry", text: "original" }); + await waitForSealed(orchestrator, "conv-no-carry"); + // Give the carry check a chance to run (it's in the finally, synchronous + // after turn-sealed, but await yields first). + await new Promise((resolve) => setTimeout(resolve, 10)); + unsub(); + + // Only one user-message (the original) — no carry turn. + expect(events.filter(isUserMessage)).toHaveLength(1); + expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); + }); }); // --- enqueue facade (the single entry transports call) --- describe("enqueue", () => { - it("enqueue when idle → starts a turn (startedTurn:true)", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const result = orchestrator.enqueue({ conversationId: "conv-idle", text: "hello" }); - expect(result.startedTurn).toBe(true); - expect(result.queue).toHaveLength(0); - - await waitForSealed(orchestrator, "conv-idle"); - - // The turn ran and persisted. - expect(store.data.get("conv-idle")).toBeDefined(); - expect(store.data.get("conv-idle")?.length).toBeGreaterThanOrEqual(2); - }); - - it("enqueue when active → queues (startedTurn:false, snapshot with the message)", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - let resolveFirst: (() => void) | undefined; - const firstBlocker = new Promise((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise => { - callCount++; - if (callCount === 1) { - await firstBlocker; - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingFirstRunTurn, - resolveQueue: () => queue, - }); - - // Start the original turn (it blocks in runTurn). - orchestrator.startTurn({ conversationId: "conv-active", text: "first" }); - // Let the turn reach the blocked runTurn call. - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Enqueue while active. - const result = orchestrator.enqueue({ conversationId: "conv-active", text: "second" }); - expect(result.startedTurn).toBe(false); - expect(result.queue).toHaveLength(1); - expect(result.queue[0]?.text).toBe("second"); - - // The queue holds the enqueued message. - expect(queue.getQueue("conv-active")).toHaveLength(1); - - // Release the original turn → it seals → post-seal carry starts a new - // turn with the enqueued message. Subscribe before releasing to catch - // both turn-sealed events. - const sealed = waitForSealedCount(orchestrator, "conv-active", 2); - resolveFirst?.(); - await sealed; - }); - - it("enqueue when active + no queue ext → startedTurn:false, empty queue (degraded)", async () => { - const store = createInMemoryStore(); - - let resolveFirst: (() => void) | undefined; - const firstBlocker = new Promise((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise => { - callCount++; - if (callCount === 1) { - await firstBlocker; - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingFirstRunTurn, - // resolveQueue omitted — no queue extension loaded (degraded) - }); - - orchestrator.startTurn({ conversationId: "conv-degraded", text: "first" }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Enqueue while active, but no queue ext → message dropped, empty snapshot. - const result = orchestrator.enqueue({ conversationId: "conv-degraded", text: "second" }); - expect(result.startedTurn).toBe(false); - expect(result.queue).toHaveLength(0); - - // Release the original turn; no carry (no queue ext). - const sealed = waitForSealed(orchestrator, "conv-degraded"); - resolveFirst?.(); - await sealed; - }); + it("enqueue when idle → starts a turn (startedTurn:true)", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const result = orchestrator.enqueue({ conversationId: "conv-idle", text: "hello" }); + expect(result.startedTurn).toBe(true); + expect(result.queue).toHaveLength(0); + + await waitForSealed(orchestrator, "conv-idle"); + + // The turn ran and persisted. + expect(store.data.get("conv-idle")).toBeDefined(); + expect(store.data.get("conv-idle")?.length).toBeGreaterThanOrEqual(2); + }); + + it("enqueue when active → queues (startedTurn:false, snapshot with the message)", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + let resolveFirst: (() => void) | undefined; + const firstBlocker = new Promise((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise => { + callCount++; + if (callCount === 1) { + await firstBlocker; + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingFirstRunTurn, + resolveQueue: () => queue, + }); + + // Start the original turn (it blocks in runTurn). + orchestrator.startTurn({ conversationId: "conv-active", text: "first" }); + // Let the turn reach the blocked runTurn call. + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Enqueue while active. + const result = orchestrator.enqueue({ conversationId: "conv-active", text: "second" }); + expect(result.startedTurn).toBe(false); + expect(result.queue).toHaveLength(1); + expect(result.queue[0]?.text).toBe("second"); + + // The queue holds the enqueued message. + expect(queue.getQueue("conv-active")).toHaveLength(1); + + // Release the original turn → it seals → post-seal carry starts a new + // turn with the enqueued message. Subscribe before releasing to catch + // both turn-sealed events. + const sealed = waitForSealedCount(orchestrator, "conv-active", 2); + resolveFirst?.(); + await sealed; + }); + + it("enqueue when active + no queue ext → startedTurn:false, empty queue (degraded)", async () => { + const store = createInMemoryStore(); + + let resolveFirst: (() => void) | undefined; + const firstBlocker = new Promise((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise => { + callCount++; + if (callCount === 1) { + await firstBlocker; + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingFirstRunTurn, + // resolveQueue omitted — no queue extension loaded (degraded) + }); + + orchestrator.startTurn({ conversationId: "conv-degraded", text: "first" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Enqueue while active, but no queue ext → message dropped, empty snapshot. + const result = orchestrator.enqueue({ conversationId: "conv-degraded", text: "second" }); + expect(result.startedTurn).toBe(false); + expect(result.queue).toHaveLength(0); + + // Release the original turn; no carry (no queue ext). + const sealed = waitForSealed(orchestrator, "conv-degraded"); + resolveFirst?.(); + await sealed; + }); }); diff --git a/packages/session-orchestrator/src/tools-filter.test.ts b/packages/session-orchestrator/src/tools-filter.test.ts index 3233469..a3c439b 100644 --- a/packages/session-orchestrator/src/tools-filter.test.ts +++ b/packages/session-orchestrator/src/tools-filter.test.ts @@ -3,73 +3,73 @@ import { describe, expect, it } from "vitest"; import { filterRemoteIncompatibleTools, type ToolAssembly } from "./tools-filter.js"; function fakeTool(name: string): ToolContract { - return { - name, - description: `Fake tool: ${name}`, - parameters: { type: "object" }, - execute: async () => ({ content: "ok" }), - }; + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; } const baseAssembly: ToolAssembly = { - tools: [fakeTool("lsp"), fakeTool("mcp__x"), fakeTool("run_shell")], - conversationId: "conv-1", + tools: [fakeTool("lsp"), fakeTool("mcp__x"), fakeTool("run_shell")], + conversationId: "conv-1", }; describe("filterRemoteIncompatibleTools", () => { - it("REMOTE (computerId set): drops 'lsp' and any '__' namespaced tool, keeps 'run_shell'", () => { - const remote: ToolAssembly = { ...baseAssembly, computerId: "my-server" }; - const result = filterRemoteIncompatibleTools(remote); - const names = result.tools.map((t) => t.name); - expect(names).not.toContain("lsp"); - expect(names).not.toContain("mcp__x"); - expect(names).toContain("run_shell"); - expect(result.tools).toHaveLength(1); - }); + it("REMOTE (computerId set): drops 'lsp' and any '__' namespaced tool, keeps 'run_shell'", () => { + const remote: ToolAssembly = { ...baseAssembly, computerId: "my-server" }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).not.toContain("lsp"); + expect(names).not.toContain("mcp__x"); + expect(names).toContain("run_shell"); + expect(result.tools).toHaveLength(1); + }); - it("REMOTE: preserves computerId + cwd + conversationId in the returned assembly", () => { - const remote: ToolAssembly = { - tools: [fakeTool("lsp"), fakeTool("run_shell")], - conversationId: "conv-2", - cwd: "/work", - computerId: "ssh-host", - }; - const result = filterRemoteIncompatibleTools(remote); - expect(result.computerId).toBe("ssh-host"); - expect(result.cwd).toBe("/work"); - expect(result.conversationId).toBe("conv-2"); - }); + it("REMOTE: preserves computerId + cwd + conversationId in the returned assembly", () => { + const remote: ToolAssembly = { + tools: [fakeTool("lsp"), fakeTool("run_shell")], + conversationId: "conv-2", + cwd: "/work", + computerId: "ssh-host", + }; + const result = filterRemoteIncompatibleTools(remote); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/work"); + expect(result.conversationId).toBe("conv-2"); + }); - it("LOCAL (computerId undefined): passthrough — nothing is dropped", () => { - const local: ToolAssembly = { ...baseAssembly }; - const result = filterRemoteIncompatibleTools(local); - expect(result.tools).toHaveLength(3); - const names = result.tools.map((t) => t.name); - expect(names).toContain("lsp"); - expect(names).toContain("mcp__x"); - expect(names).toContain("run_shell"); - }); + it("LOCAL (computerId undefined): passthrough — nothing is dropped", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result.tools).toHaveLength(3); + const names = result.tools.map((t) => t.name); + expect(names).toContain("lsp"); + expect(names).toContain("mcp__x"); + expect(names).toContain("run_shell"); + }); - it("LOCAL: returns the exact same assembly object (byte-identical)", () => { - const local: ToolAssembly = { ...baseAssembly }; - const result = filterRemoteIncompatibleTools(local); - expect(result).toBe(local); - }); + it("LOCAL: returns the exact same assembly object (byte-identical)", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result).toBe(local); + }); - it("REMOTE: drops multiple MCP-namespaced tools (serverId__toolName pattern)", () => { - const remote: ToolAssembly = { - tools: [ - fakeTool("lsp"), - fakeTool("filesystem__read"), - fakeTool("github__create_issue"), - fakeTool("run_shell"), - fakeTool("write_file"), - ], - conversationId: "conv-3", - computerId: "host", - }; - const result = filterRemoteIncompatibleTools(remote); - const names = result.tools.map((t) => t.name); - expect(names).toEqual(["run_shell", "write_file"]); - }); + it("REMOTE: drops multiple MCP-namespaced tools (serverId__toolName pattern)", () => { + const remote: ToolAssembly = { + tools: [ + fakeTool("lsp"), + fakeTool("filesystem__read"), + fakeTool("github__create_issue"), + fakeTool("run_shell"), + fakeTool("write_file"), + ], + conversationId: "conv-3", + computerId: "host", + }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).toEqual(["run_shell", "write_file"]); + }); }); diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts index 913e574..28e82bf 100644 --- a/packages/session-orchestrator/src/tools-filter.ts +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -2,24 +2,24 @@ import { defineFilter, type FilterDescriptor, type ToolContract } from "@dispatc /** Per-turn tool-assembly value threaded through the `tools` filter chain. */ export interface ToolAssembly { - /** The tool set resolved for this turn (the value filters transform). */ - readonly tools: readonly ToolContract[]; - /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ - readonly cwd?: string; - /** - * The computer this turn executes on (SSH alias), for computer-aware - * filters. Omitted/`undefined` = LOCAL (today's behavior). When set, the - * turn is REMOTE — {@link filterRemoteIncompatibleTools} drops tools that - * cannot run over SFTP (local-process servers). Mirrors `cwd?`. - */ - readonly computerId?: string; - /** The conversation this turn belongs to. */ - readonly conversationId: string; + /** The tool set resolved for this turn (the value filters transform). */ + readonly tools: readonly ToolContract[]; + /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ + readonly cwd?: string; + /** + * The computer this turn executes on (SSH alias), for computer-aware + * filters. Omitted/`undefined` = LOCAL (today's behavior). When set, the + * turn is REMOTE — {@link filterRemoteIncompatibleTools} drops tools that + * cannot run over SFTP (local-process servers). Mirrors `cwd?`. + */ + readonly computerId?: string; + /** The conversation this turn belongs to. */ + readonly conversationId: string; } /** Filter chain run once per turn to transform the tool set before it reaches runTurn. */ export const toolsFilter: FilterDescriptor = defineFilter( - "session-orchestrator/tools", + "session-orchestrator/tools", ); /** @@ -42,18 +42,18 @@ export const toolsFilter: FilterDescriptor = defineFilter { - if (tool.name === "lsp") return false; - if (tool.name.includes("__")) return false; - return true; - }); - return { - tools: filtered, - ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}), - ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}), - conversationId: assembly.conversationId, - }; + // LOCAL — passthrough, byte-identical to today. + if (assembly.computerId === undefined) return assembly; + // REMOTE — drop lsp + MCP-namespaced tools (local-process servers). + const filtered = assembly.tools.filter((tool) => { + if (tool.name === "lsp") return false; + if (tool.name.includes("__")) return false; + return true; + }); + return { + tools: filtered, + ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}), + ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}), + conversationId: assembly.conversationId, + }; } diff --git a/packages/session-orchestrator/tsconfig.json b/packages/session-orchestrator/tsconfig.json index 2ca3bd2..bc729fc 100644 --- a/packages/session-orchestrator/tsconfig.json +++ b/packages/session-orchestrator/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../conversation-store" }, - { "path": "../credential-store" }, - { "path": "../message-queue" }, - { "path": "../system-prompt" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../conversation-store" }, + { "path": "../credential-store" }, + { "path": "../message-queue" }, + { "path": "../system-prompt" } + ] } diff --git a/packages/skills/package.json b/packages/skills/package.json index 514cdc7..a598729 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/skills", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*" - } + "name": "@dispatch/skills", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*" + } } diff --git a/packages/skills/src/extension.ts b/packages/skills/src/extension.ts index e8000e4..1f16586 100644 --- a/packages/skills/src/extension.ts +++ b/packages/skills/src/extension.ts @@ -5,22 +5,22 @@ import { createLoadSkillTool } from "./load-skill.js"; import { makeSkillsToolFilter } from "./tools-filter.js"; export const extension: Extension = { - manifest: { - id: "skills", - name: "Skills", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["session-orchestrator"], - capabilities: { fs: true }, - contributes: { tools: ["load_skill"] }, - }, - activate(host: HostAPI) { - const homeDir = homedir(); - const workdir = process.cwd(); + manifest: { + id: "skills", + name: "Skills", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["session-orchestrator"], + capabilities: { fs: true }, + contributes: { tools: ["load_skill"] }, + }, + activate(host: HostAPI) { + const homeDir = homedir(); + const workdir = process.cwd(); - host.defineTool(createLoadSkillTool({ homeDir, workdir })); - host.addFilter(toolsFilter, makeSkillsToolFilter({ homeDir, workdir })); - }, + host.defineTool(createLoadSkillTool({ homeDir, workdir })); + host.addFilter(toolsFilter, makeSkillsToolFilter({ homeDir, workdir })); + }, }; diff --git a/packages/skills/src/index.ts b/packages/skills/src/index.ts index 12f9f19..ab6b574 100644 --- a/packages/skills/src/index.ts +++ b/packages/skills/src/index.ts @@ -1,13 +1,13 @@ export { extension } from "./extension.js"; export { createLoadSkillTool, type SkillsDeps, scanSkillsDir } from "./load-skill.js"; export { - isPathWithinDir, - isValidSkillName, - mergeCatalog, - parseSkillMeta, - renderDescription, - type SkillEntry, - type SkillMeta, - stripLoadedBody, + isPathWithinDir, + isValidSkillName, + mergeCatalog, + parseSkillMeta, + renderDescription, + type SkillEntry, + type SkillMeta, + stripLoadedBody, } from "./pure.js"; export { makeSkillsToolFilter } from "./tools-filter.js"; diff --git a/packages/skills/src/load-skill.ts b/packages/skills/src/load-skill.ts index ef7fc23..3a6173c 100644 --- a/packages/skills/src/load-skill.ts +++ b/packages/skills/src/load-skill.ts @@ -2,16 +2,16 @@ import { readdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import { - isPathWithinDir, - isValidSkillName, - parseSkillMeta, - type SkillEntry, - stripLoadedBody, + isPathWithinDir, + isValidSkillName, + parseSkillMeta, + type SkillEntry, + stripLoadedBody, } from "./pure.js"; export interface SkillsDeps { - readonly homeDir: string; - readonly workdir: string; + readonly homeDir: string; + readonly workdir: string; } /** @@ -22,36 +22,36 @@ export interface SkillsDeps { * Returns an empty array on any error (fail-open). */ export async function scanSkillsDir(dir: string): Promise { - async function scan(d: string): Promise { - try { - const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); - const skills: SkillEntry[] = []; - for (const entry of entries) { - if (entry.isDirectory()) { - const subSkills = await scan(join(d, entry.name)); - for (const sub of subSkills) { - if (!skills.some((s) => s.name === sub.name)) { - skills.push(sub); - } - } - } else if (entry.isFile() && entry.name.endsWith(".md")) { - const name = entry.name.slice(0, -3); - if (skills.some((s) => s.name === name)) continue; - try { - const content = await readFile(join(d, entry.name), "utf8"); - const meta = parseSkillMeta(content); - skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined }); - } catch { - skills.push({ name }); - } - } - } - return skills; - } catch { - return []; - } - } - return scan(dir); + async function scan(d: string): Promise { + try { + const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); + const skills: SkillEntry[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const subSkills = await scan(join(d, entry.name)); + for (const sub of subSkills) { + if (!skills.some((s) => s.name === sub.name)) { + skills.push(sub); + } + } + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const name = entry.name.slice(0, -3); + if (skills.some((s) => s.name === name)) continue; + try { + const content = await readFile(join(d, entry.name), "utf8"); + const meta = parseSkillMeta(content); + skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined }); + } catch { + skills.push({ name }); + } + } + } + return skills; + } catch { + return []; + } + } + return scan(dir); } /** @@ -60,26 +60,26 @@ export async function scanSkillsDir(dir: string): Promise * Top-level files are found before nested ones (readdir order within each level). */ async function findSkillFile(dir: string, name: string): Promise { - async function search(d: string): Promise { - try { - const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile() && entry.name === `${name}.md`) { - return join(d, entry.name); - } - } - for (const entry of entries) { - if (entry.isDirectory()) { - const found = await search(join(d, entry.name)); - if (found !== null) return found; - } - } - return null; - } catch { - return null; - } - } - return search(dir); + async function search(d: string): Promise { + try { + const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name === `${name}.md`) { + return join(d, entry.name); + } + } + for (const entry of entries) { + if (entry.isDirectory()) { + const found = await search(join(d, entry.name)); + if (found !== null) return found; + } + } + return null; + } catch { + return null; + } + } + return search(dir); } /** @@ -87,81 +87,81 @@ async function findSkillFile(dir: string, name: string): Promise * The tool reads a skill file from disk on execute (uncached). */ export function createLoadSkillTool(deps: SkillsDeps): ToolContract { - const { homeDir, workdir } = deps; - - return { - name: "load_skill", - description: "Load a skill by name. No skills are currently available.", - parameters: { - type: "object", - properties: { - name: { - type: "string", - description: "The name of the skill to load.", - }, - }, - required: ["name"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const obj = args as Record; - const rawName = obj?.name; - - if (typeof rawName !== "string") { - return { content: 'Error: Missing or invalid "name" parameter.', isError: true }; - } - - if (!isValidSkillName(rawName)) { - return { - content: `Error: Invalid skill name "${rawName}". Name must not contain path separators or "..".`, - isError: true, - }; - } - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : resolve(workdir); - const cwdSkillsDir = join(effectiveBase, ".skills"); - const homeSkillsDir = join(resolve(homeDir), ".skills"); - - let filePath: string | null = null; - - try { - filePath = await findSkillFile(cwdSkillsDir, rawName); - } catch { - // Cwd miss — try home - } - - if (filePath === null) { - try { - filePath = await findSkillFile(homeSkillsDir, rawName); - } catch { - // Both miss - } - } - - if (filePath === null) { - return { content: `Error: unknown skill: ${rawName}`, isError: true }; - } - - const resolvedPath = resolve(filePath); - if ( - !isPathWithinDir(resolvedPath, cwdSkillsDir) && - !isPathWithinDir(resolvedPath, homeSkillsDir) - ) { - return { content: "Error: Invalid skill path.", isError: true }; - } - - let content: string; - - try { - content = await readFile(resolvedPath, "utf8"); - } catch { - return { content: `Error: unknown skill: ${rawName}`, isError: true }; - } - - const meta = parseSkillMeta(content); - const body = stripLoadedBody(content, meta.hasMeta); - - return { content: body }; - }, - }; + const { homeDir, workdir } = deps; + + return { + name: "load_skill", + description: "Load a skill by name. No skills are currently available.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: "The name of the skill to load.", + }, + }, + required: ["name"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const obj = args as Record; + const rawName = obj?.name; + + if (typeof rawName !== "string") { + return { content: 'Error: Missing or invalid "name" parameter.', isError: true }; + } + + if (!isValidSkillName(rawName)) { + return { + content: `Error: Invalid skill name "${rawName}". Name must not contain path separators or "..".`, + isError: true, + }; + } + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : resolve(workdir); + const cwdSkillsDir = join(effectiveBase, ".skills"); + const homeSkillsDir = join(resolve(homeDir), ".skills"); + + let filePath: string | null = null; + + try { + filePath = await findSkillFile(cwdSkillsDir, rawName); + } catch { + // Cwd miss — try home + } + + if (filePath === null) { + try { + filePath = await findSkillFile(homeSkillsDir, rawName); + } catch { + // Both miss + } + } + + if (filePath === null) { + return { content: `Error: unknown skill: ${rawName}`, isError: true }; + } + + const resolvedPath = resolve(filePath); + if ( + !isPathWithinDir(resolvedPath, cwdSkillsDir) && + !isPathWithinDir(resolvedPath, homeSkillsDir) + ) { + return { content: "Error: Invalid skill path.", isError: true }; + } + + let content: string; + + try { + content = await readFile(resolvedPath, "utf8"); + } catch { + return { content: `Error: unknown skill: ${rawName}`, isError: true }; + } + + const meta = parseSkillMeta(content); + const body = stripLoadedBody(content, meta.hasMeta); + + return { content: body }; + }, + }; } diff --git a/packages/skills/src/pure.test.ts b/packages/skills/src/pure.test.ts index a8c6af5..1fdadfd 100644 --- a/packages/skills/src/pure.test.ts +++ b/packages/skills/src/pure.test.ts @@ -1,174 +1,174 @@ import { describe, expect, it } from "vitest"; import { - isPathWithinDir, - isValidSkillName, - mergeCatalog, - parseSkillMeta, - renderDescription, - stripLoadedBody, + isPathWithinDir, + isValidSkillName, + mergeCatalog, + parseSkillMeta, + renderDescription, + stripLoadedBody, } from "./pure.js"; describe("parseSkillMeta", () => { - it("extracts summary when line 2 is ---", () => { - const content = "Use this for web searches\n---\nBody content here"; - const result = parseSkillMeta(content); - expect(result.hasMeta).toBe(true); - expect(result.summary).toBe("Use this for web searches"); - }); - - it("extracts summary with trailing whitespace on separator", () => { - const content = "Summary text\n--- \nBody"; - const result = parseSkillMeta(content); - expect(result.hasMeta).toBe(true); - expect(result.summary).toBe("Summary text"); - }); - - it("reports no metadata when line 2 is not ---", () => { - const content = "Some content\nNot a separator\nBody"; - const result = parseSkillMeta(content); - expect(result.hasMeta).toBe(false); - expect(result.summary).toBeUndefined(); - }); - - it("reports no metadata for single-line content", () => { - const content = "Only one line"; - const result = parseSkillMeta(content); - expect(result.hasMeta).toBe(false); - }); - - it("reports no metadata for empty content", () => { - const result = parseSkillMeta(""); - expect(result.hasMeta).toBe(false); - }); - - it("treats empty summary as undefined", () => { - const content = "\n---\nBody"; - const result = parseSkillMeta(content); - expect(result.hasMeta).toBe(true); - expect(result.summary).toBeUndefined(); - }); + it("extracts summary when line 2 is ---", () => { + const content = "Use this for web searches\n---\nBody content here"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBe("Use this for web searches"); + }); + + it("extracts summary with trailing whitespace on separator", () => { + const content = "Summary text\n--- \nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBe("Summary text"); + }); + + it("reports no metadata when line 2 is not ---", () => { + const content = "Some content\nNot a separator\nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(false); + expect(result.summary).toBeUndefined(); + }); + + it("reports no metadata for single-line content", () => { + const content = "Only one line"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(false); + }); + + it("reports no metadata for empty content", () => { + const result = parseSkillMeta(""); + expect(result.hasMeta).toBe(false); + }); + + it("treats empty summary as undefined", () => { + const content = "\n---\nBody"; + const result = parseSkillMeta(content); + expect(result.hasMeta).toBe(true); + expect(result.summary).toBeUndefined(); + }); }); describe("stripLoadedBody", () => { - it("removes the first two lines when metadata is present", () => { - const content = "Summary\n---\nLine 3\nLine 4"; - const result = stripLoadedBody(content, true); - expect(result).toBe("Line 3\nLine 4"); - }); - - it("returns the whole file when malformed", () => { - const content = "No metadata here\nJust content"; - const result = stripLoadedBody(content, false); - expect(result).toBe("No metadata here\nJust content"); - }); - - it("handles file with only metadata and no body", () => { - const content = "Summary\n---\n"; - const result = stripLoadedBody(content, true); - expect(result).toBe(""); - }); + it("removes the first two lines when metadata is present", () => { + const content = "Summary\n---\nLine 3\nLine 4"; + const result = stripLoadedBody(content, true); + expect(result).toBe("Line 3\nLine 4"); + }); + + it("returns the whole file when malformed", () => { + const content = "No metadata here\nJust content"; + const result = stripLoadedBody(content, false); + expect(result).toBe("No metadata here\nJust content"); + }); + + it("handles file with only metadata and no body", () => { + const content = "Summary\n---\n"; + const result = stripLoadedBody(content, true); + expect(result).toBe(""); + }); }); describe("mergeCatalog", () => { - it("merges disjoint entries", () => { - const home = [{ name: "a" }, { name: "b" }]; - const cwd = [{ name: "c" }]; - const result = mergeCatalog(home, cwd); - expect(result.map((e) => e.name)).toEqual(["a", "b", "c"]); - }); - - it("cwd skill shadows home skill of the same name", () => { - const home = [{ name: "shared", summary: "home summary" }]; - const cwd = [{ name: "shared", summary: "cwd summary" }]; - const result = mergeCatalog(home, cwd); - expect(result).toHaveLength(1); - expect(result[0]?.summary).toBe("cwd summary"); - }); - - it("sorts by name", () => { - const home = [{ name: "z" }, { name: "a" }]; - const cwd = [{ name: "m" }]; - const result = mergeCatalog(home, cwd); - expect(result.map((e) => e.name)).toEqual(["a", "m", "z"]); - }); - - it("handles empty inputs", () => { - expect(mergeCatalog([], [])).toEqual([]); - }); + it("merges disjoint entries", () => { + const home = [{ name: "a" }, { name: "b" }]; + const cwd = [{ name: "c" }]; + const result = mergeCatalog(home, cwd); + expect(result.map((e) => e.name)).toEqual(["a", "b", "c"]); + }); + + it("cwd skill shadows home skill of the same name", () => { + const home = [{ name: "shared", summary: "home summary" }]; + const cwd = [{ name: "shared", summary: "cwd summary" }]; + const result = mergeCatalog(home, cwd); + expect(result).toHaveLength(1); + expect(result[0]?.summary).toBe("cwd summary"); + }); + + it("sorts by name", () => { + const home = [{ name: "z" }, { name: "a" }]; + const cwd = [{ name: "m" }]; + const result = mergeCatalog(home, cwd); + expect(result.map((e) => e.name)).toEqual(["a", "m", "z"]); + }); + + it("handles empty inputs", () => { + expect(mergeCatalog([], [])).toEqual([]); + }); }); describe("renderDescription", () => { - it("lists all skills by name and appends summaries only for valid ones", () => { - const catalog = [ - { name: "web-search", summary: "Use for web searches" }, - { name: "malformed-skill" }, - ]; - const result = renderDescription(catalog); - expect(result).toBe( - "Load a skill by name. Available skills:\n- web-search: Use for web searches\n- malformed-skill", - ); - }); - - it("returns a plain message when no skills are available", () => { - const result = renderDescription([]); - expect(result).toBe("Load a skill by name. No skills are currently available."); - }); - - it("lists skills with summaries and without", () => { - const catalog = [ - { name: "alpha", summary: "First skill" }, - { name: "beta" }, - { name: "gamma", summary: "Third skill" }, - ]; - const result = renderDescription(catalog); - expect(result).toContain("- alpha: First skill"); - expect(result).toContain("- beta"); - expect(result).toContain("- gamma: Third skill"); - }); + it("lists all skills by name and appends summaries only for valid ones", () => { + const catalog = [ + { name: "web-search", summary: "Use for web searches" }, + { name: "malformed-skill" }, + ]; + const result = renderDescription(catalog); + expect(result).toBe( + "Load a skill by name. Available skills:\n- web-search: Use for web searches\n- malformed-skill", + ); + }); + + it("returns a plain message when no skills are available", () => { + const result = renderDescription([]); + expect(result).toBe("Load a skill by name. No skills are currently available."); + }); + + it("lists skills with summaries and without", () => { + const catalog = [ + { name: "alpha", summary: "First skill" }, + { name: "beta" }, + { name: "gamma", summary: "Third skill" }, + ]; + const result = renderDescription(catalog); + expect(result).toContain("- alpha: First skill"); + expect(result).toContain("- beta"); + expect(result).toContain("- gamma: Third skill"); + }); }); describe("isValidSkillName", () => { - it("accepts a bare skill name", () => { - expect(isValidSkillName("web-search")).toBe(true); - }); - - it("rejects a name containing /", () => { - expect(isValidSkillName("../escape")).toBe(false); - }); - - it("rejects a name containing \\", () => { - expect(isValidSkillName("path\\name")).toBe(false); - }); - - it("rejects a name containing ..", () => { - expect(isValidSkillName("skill..evil")).toBe(false); - }); - - it("rejects an empty string", () => { - expect(isValidSkillName("")).toBe(false); - }); - - it("rejects a non-string value", () => { - expect(isValidSkillName(123)).toBe(false); - expect(isValidSkillName(null)).toBe(false); - expect(isValidSkillName(undefined)).toBe(false); - }); + it("accepts a bare skill name", () => { + expect(isValidSkillName("web-search")).toBe(true); + }); + + it("rejects a name containing /", () => { + expect(isValidSkillName("../escape")).toBe(false); + }); + + it("rejects a name containing \\", () => { + expect(isValidSkillName("path\\name")).toBe(false); + }); + + it("rejects a name containing ..", () => { + expect(isValidSkillName("skill..evil")).toBe(false); + }); + + it("rejects an empty string", () => { + expect(isValidSkillName("")).toBe(false); + }); + + it("rejects a non-string value", () => { + expect(isValidSkillName(123)).toBe(false); + expect(isValidSkillName(null)).toBe(false); + expect(isValidSkillName(undefined)).toBe(false); + }); }); describe("isPathWithinDir", () => { - it("accepts a path within the directory", () => { - expect(isPathWithinDir("/tmp/base/file.txt", "/tmp/base")).toBe(true); - }); + it("accepts a path within the directory", () => { + expect(isPathWithinDir("/tmp/base/file.txt", "/tmp/base")).toBe(true); + }); - it("accepts the directory itself", () => { - expect(isPathWithinDir("/tmp/base", "/tmp/base")).toBe(true); - }); + it("accepts the directory itself", () => { + expect(isPathWithinDir("/tmp/base", "/tmp/base")).toBe(true); + }); - it("rejects a path outside the directory", () => { - expect(isPathWithinDir("/tmp/other/file.txt", "/tmp/base")).toBe(false); - }); + it("rejects a path outside the directory", () => { + expect(isPathWithinDir("/tmp/other/file.txt", "/tmp/base")).toBe(false); + }); - it("rejects a prefix attack", () => { - expect(isPathWithinDir("/tmp/base-evil/file.txt", "/tmp/base")).toBe(false); - }); + it("rejects a prefix attack", () => { + expect(isPathWithinDir("/tmp/base-evil/file.txt", "/tmp/base")).toBe(false); + }); }); diff --git a/packages/skills/src/pure.ts b/packages/skills/src/pure.ts index 2800967..4cc8e5c 100644 --- a/packages/skills/src/pure.ts +++ b/packages/skills/src/pure.ts @@ -5,14 +5,14 @@ /** A discovered skill entry (name + optional summary from metadata). */ export interface SkillEntry { - readonly name: string; - readonly summary?: string | undefined; + readonly name: string; + readonly summary?: string | undefined; } /** Result of parsing a skill file's metadata. */ export interface SkillMeta { - readonly summary?: string | undefined; - readonly hasMeta: boolean; + readonly summary?: string | undefined; + readonly hasMeta: boolean; } /** @@ -22,16 +22,16 @@ export interface SkillMeta { * Returns `{ hasMeta: false }` when line 2 is not `---` (malformed). */ export function parseSkillMeta(content: string): SkillMeta { - const lines = content.split("\n"); - if (lines.length < 2) { - return { hasMeta: false }; - } - const line2 = lines[1]; - if (line2 === undefined || line2.trim() !== "---") { - return { hasMeta: false }; - } - const summary = lines[0]; - return { hasMeta: true, summary: summary?.trim() === "" ? undefined : summary }; + const lines = content.split("\n"); + if (lines.length < 2) { + return { hasMeta: false }; + } + const line2 = lines[1]; + if (line2 === undefined || line2.trim() !== "---") { + return { hasMeta: false }; + } + const summary = lines[0]; + return { hasMeta: true, summary: summary?.trim() === "" ? undefined : summary }; } /** @@ -40,11 +40,11 @@ export function parseSkillMeta(content: string): SkillMeta { * When hasMeta is false, returns the whole file unchanged. */ export function stripLoadedBody(content: string, hasMeta: boolean): string { - if (!hasMeta) { - return content; - } - const lines = content.split("\n"); - return lines.slice(2).join("\n"); + if (!hasMeta) { + return content; + } + const lines = content.split("\n"); + return lines.slice(2).join("\n"); } /** @@ -52,17 +52,17 @@ export function stripLoadedBody(content: string, hasMeta: boolean): string { * Returns a deduplicated array sorted by name. */ export function mergeCatalog( - homeEntries: readonly SkillEntry[], - cwdEntries: readonly SkillEntry[], + homeEntries: readonly SkillEntry[], + cwdEntries: readonly SkillEntry[], ): readonly SkillEntry[] { - const map = new Map(); - for (const entry of homeEntries) { - map.set(entry.name, entry); - } - for (const entry of cwdEntries) { - map.set(entry.name, entry); - } - return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); + const map = new Map(); + for (const entry of homeEntries) { + map.set(entry.name, entry); + } + for (const entry of cwdEntries) { + map.set(entry.name, entry); + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); } /** @@ -70,18 +70,18 @@ export function mergeCatalog( * Lists all skills by name; appends summary only for skills with valid metadata. */ export function renderDescription(catalog: readonly SkillEntry[]): string { - if (catalog.length === 0) { - return "Load a skill by name. No skills are currently available."; - } - const lines = ["Load a skill by name. Available skills:"]; - for (const entry of catalog) { - if (entry.summary !== undefined) { - lines.push(`- ${entry.name}: ${entry.summary}`); - } else { - lines.push(`- ${entry.name}`); - } - } - return lines.join("\n"); + if (catalog.length === 0) { + return "Load a skill by name. No skills are currently available."; + } + const lines = ["Load a skill by name. Available skills:"]; + for (const entry of catalog) { + if (entry.summary !== undefined) { + lines.push(`- ${entry.name}: ${entry.summary}`); + } else { + lines.push(`- ${entry.name}`); + } + } + return lines.join("\n"); } /** @@ -89,16 +89,16 @@ export function renderDescription(catalog: readonly SkillEntry[]): string { * Returns true if the name is safe, false if it contains `/`, `\`, `..`, or is empty. */ export function isValidSkillName(name: unknown): name is string { - if (typeof name !== "string" || name.length === 0) { - return false; - } - if (name.includes("/") || name.includes("\\")) { - return false; - } - if (name.includes("..")) { - return false; - } - return true; + if (typeof name !== "string" || name.length === 0) { + return false; + } + if (name.includes("/") || name.includes("\\")) { + return false; + } + if (name.includes("..")) { + return false; + } + return true; } /** @@ -106,6 +106,6 @@ export function isValidSkillName(name: unknown): name is string { * Prefix check — catches `..` traversal and absolute paths outside base. */ export function isPathWithinDir(resolvedPath: string, base: string): boolean { - const normalizedBase = base.endsWith("/") ? base : `${base}/`; - return resolvedPath === base || resolvedPath.startsWith(normalizedBase); + const normalizedBase = base.endsWith("/") ? base : `${base}/`; + return resolvedPath === base || resolvedPath.startsWith(normalizedBase); } diff --git a/packages/skills/src/skills.test.ts b/packages/skills/src/skills.test.ts index fe0b437..57e5d63 100644 --- a/packages/skills/src/skills.test.ts +++ b/packages/skills/src/skills.test.ts @@ -8,261 +8,261 @@ import { createLoadSkillTool, scanSkillsDir } from "./load-skill.js"; import { makeSkillsToolFilter } from "./tools-filter.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } let homeDir: string; let workdir: string; beforeEach(async () => { - homeDir = await mkdtemp(join(tmpdir(), "skills-home-test-")); - workdir = await mkdtemp(join(tmpdir(), "skills-workdir-test-")); + homeDir = await mkdtemp(join(tmpdir(), "skills-home-test-")); + workdir = await mkdtemp(join(tmpdir(), "skills-workdir-test-")); }); afterEach(async () => { - await rm(homeDir, { recursive: true, force: true }); - await rm(workdir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("load_skill tool", () => { - it("loads a skill body (strips first two lines) from cwd .skills", async () => { - const skillsDir = join(workdir, ".skills"); - await mkdir(skillsDir); - await writeFile( - join(skillsDir, "web-search.md"), - "Use for web searches\n---\n# Web Search Skill\nDo a web search.", - "utf8", - ); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "web-search" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("# Web Search Skill\nDo a web search."); - }); - - it("falls back to home .skills when not in cwd", async () => { - const homeSkillsDir = join(homeDir, ".skills"); - await mkdir(homeSkillsDir); - await writeFile( - join(homeSkillsDir, "global-skill.md"), - "Global skill summary\n---\nGlobal body content", - "utf8", - ); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "global-skill" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("Global body content"); - }); - - it("returns isError for an unknown skill", async () => { - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "nonexistent" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("unknown skill"); - }); - - it("rejects a name containing a path separator", async () => { - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "../escape" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Invalid skill name"); - }); - - it("rejects a name containing ..", async () => { - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "skill..evil" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Invalid skill name"); - }); - - it("rejects a name containing backslash", async () => { - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "path\\name" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Invalid skill name"); - }); - - it("returns the whole file when malformed (no --- on line 2)", async () => { - const skillsDir = join(workdir, ".skills"); - await mkdir(skillsDir); - await writeFile( - join(skillsDir, "malformed.md"), - "Just some content\nNo separator here\nMore content", - "utf8", - ); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "malformed" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("Just some content\nNo separator here\nMore content"); - }); - - it("cwd skill shadows home skill of the same name", async () => { - const homeSkillsDir = join(homeDir, ".skills"); - await mkdir(homeSkillsDir); - await writeFile(join(homeSkillsDir, "shared.md"), "Home summary\n---\nHome body", "utf8"); - - const cwdSkillsDir = join(workdir, ".skills"); - await mkdir(cwdSkillsDir); - await writeFile(join(cwdSkillsDir, "shared.md"), "Cwd summary\n---\nCwd body", "utf8"); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "shared" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("Cwd body"); - }); - - it("reads from ctx.cwd when set", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "skills-ctx-test-")); - try { - const ctxSkillsDir = join(ctxDir, ".skills"); - await mkdir(ctxSkillsDir); - await writeFile(join(ctxSkillsDir, "ctx-skill.md"), "Ctx summary\n---\nFrom ctx cwd", "utf8"); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const result = await tool.execute({ name: "ctx-skill" }, stubCtx({ cwd: ctxDir })); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("From ctx cwd"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("concurrencySafe is true", () => { - const tool = createLoadSkillTool({ homeDir, workdir }); - expect(tool.concurrencySafe).toBe(true); - }); + it("loads a skill body (strips first two lines) from cwd .skills", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile( + join(skillsDir, "web-search.md"), + "Use for web searches\n---\n# Web Search Skill\nDo a web search.", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "web-search" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("# Web Search Skill\nDo a web search."); + }); + + it("falls back to home .skills when not in cwd", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile( + join(homeSkillsDir, "global-skill.md"), + "Global skill summary\n---\nGlobal body content", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "global-skill" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Global body content"); + }); + + it("returns isError for an unknown skill", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "nonexistent" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("unknown skill"); + }); + + it("rejects a name containing a path separator", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "../escape" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("rejects a name containing ..", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "skill..evil" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("rejects a name containing backslash", async () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "path\\name" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Invalid skill name"); + }); + + it("returns the whole file when malformed (no --- on line 2)", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile( + join(skillsDir, "malformed.md"), + "Just some content\nNo separator here\nMore content", + "utf8", + ); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "malformed" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Just some content\nNo separator here\nMore content"); + }); + + it("cwd skill shadows home skill of the same name", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile(join(homeSkillsDir, "shared.md"), "Home summary\n---\nHome body", "utf8"); + + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "shared.md"), "Cwd summary\n---\nCwd body", "utf8"); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "shared" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("Cwd body"); + }); + + it("reads from ctx.cwd when set", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "skills-ctx-test-")); + try { + const ctxSkillsDir = join(ctxDir, ".skills"); + await mkdir(ctxSkillsDir); + await writeFile(join(ctxSkillsDir, "ctx-skill.md"), "Ctx summary\n---\nFrom ctx cwd", "utf8"); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const result = await tool.execute({ name: "ctx-skill" }, stubCtx({ cwd: ctxDir })); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("From ctx cwd"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("concurrencySafe is true", () => { + const tool = createLoadSkillTool({ homeDir, workdir }); + expect(tool.concurrencySafe).toBe(true); + }); }); describe("scanSkillsDir", () => { - it("scans .md files and parses metadata", async () => { - const skillsDir = join(workdir, ".skills"); - await mkdir(skillsDir); - await writeFile(join(skillsDir, "valid.md"), "Summary\n---\nBody", "utf8"); - await writeFile(join(skillsDir, "malformed.md"), "No separator\nBody", "utf8"); - await writeFile(join(skillsDir, "other.txt"), "Not a skill", "utf8"); - - const result = await scanSkillsDir(skillsDir); - - expect(result).toHaveLength(2); - const valid = result.find((e) => e.name === "valid"); - expect(valid?.summary).toBe("Summary"); - const malformed = result.find((e) => e.name === "malformed"); - expect(malformed?.summary).toBeUndefined(); - }); - - it("returns empty array for nonexistent directory", async () => { - const result = await scanSkillsDir(join(workdir, "nonexistent")); - expect(result).toEqual([]); - }); + it("scans .md files and parses metadata", async () => { + const skillsDir = join(workdir, ".skills"); + await mkdir(skillsDir); + await writeFile(join(skillsDir, "valid.md"), "Summary\n---\nBody", "utf8"); + await writeFile(join(skillsDir, "malformed.md"), "No separator\nBody", "utf8"); + await writeFile(join(skillsDir, "other.txt"), "Not a skill", "utf8"); + + const result = await scanSkillsDir(skillsDir); + + expect(result).toHaveLength(2); + const valid = result.find((e) => e.name === "valid"); + expect(valid?.summary).toBe("Summary"); + const malformed = result.find((e) => e.name === "malformed"); + expect(malformed?.summary).toBeUndefined(); + }); + + it("returns empty array for nonexistent directory", async () => { + const result = await scanSkillsDir(join(workdir, "nonexistent")); + expect(result).toEqual([]); + }); }); describe("tools filter", () => { - it("rewrites load_skill description with the current catalog (cwd-aware)", async () => { - const homeSkillsDir = join(homeDir, ".skills"); - await mkdir(homeSkillsDir); - await writeFile(join(homeSkillsDir, "global.md"), "Global summary\n---\nBody", "utf8"); - - const cwdSkillsDir = join(workdir, ".skills"); - await mkdir(cwdSkillsDir); - await writeFile(join(cwdSkillsDir, "local.md"), "Local summary\n---\nBody", "utf8"); - - const filter = makeSkillsToolFilter({ homeDir, workdir }); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const asm: ToolAssembly = { - tools: [tool], - cwd: workdir, - conversationId: "test-conv", - }; - - const result = await filter(asm); - const loadSkill = result.tools.find( - (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", - ); - expect(loadSkill).toBeDefined(); - expect(loadSkill?.description).toContain("global"); - expect(loadSkill?.description).toContain("Global summary"); - expect(loadSkill?.description).toContain("local"); - expect(loadSkill?.description).toContain("Local summary"); - }); - - it("updates the name parameter enum with available skills", async () => { - const cwdSkillsDir = join(workdir, ".skills"); - await mkdir(cwdSkillsDir); - await writeFile(join(cwdSkillsDir, "alpha.md"), "Alpha\n---\nBody", "utf8"); - await writeFile(join(cwdSkillsDir, "beta.md"), "Beta\n---\nBody", "utf8"); - - const filter = makeSkillsToolFilter({ homeDir, workdir }); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const asm: ToolAssembly = { - tools: [tool], - cwd: workdir, - conversationId: "test-conv", - }; - - const result = await filter(asm); - const loadSkill = result.tools.find( - (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", - ); - expect(loadSkill?.parameters.properties?.name?.enum).toEqual(["alpha", "beta"]); - }); - - it("handles empty skill directories gracefully", async () => { - const filter = makeSkillsToolFilter({ homeDir, workdir }); - - const tool = createLoadSkillTool({ homeDir, workdir }); - const asm: ToolAssembly = { - tools: [tool], - cwd: workdir, - conversationId: "test-conv", - }; - - const result = await filter(asm); - const loadSkill = result.tools.find( - (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", - ); - expect(loadSkill?.description).toContain("No skills are currently available"); - }); - - it("passes through non-load_skill tools unchanged", async () => { - const filter = makeSkillsToolFilter({ homeDir, workdir }); - - const otherTool = { - name: "other_tool", - description: "Some other tool", - parameters: { type: "object" as const }, - execute: async () => ({ content: "ok" }), - }; - const asm: ToolAssembly = { - tools: [otherTool], - cwd: workdir, - conversationId: "test-conv", - }; - - const result = await filter(asm); - expect(result.tools[0]?.name).toBe("other_tool"); - expect(result.tools[0]?.description).toBe("Some other tool"); - }); + it("rewrites load_skill description with the current catalog (cwd-aware)", async () => { + const homeSkillsDir = join(homeDir, ".skills"); + await mkdir(homeSkillsDir); + await writeFile(join(homeSkillsDir, "global.md"), "Global summary\n---\nBody", "utf8"); + + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "local.md"), "Local summary\n---\nBody", "utf8"); + + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill).toBeDefined(); + expect(loadSkill?.description).toContain("global"); + expect(loadSkill?.description).toContain("Global summary"); + expect(loadSkill?.description).toContain("local"); + expect(loadSkill?.description).toContain("Local summary"); + }); + + it("updates the name parameter enum with available skills", async () => { + const cwdSkillsDir = join(workdir, ".skills"); + await mkdir(cwdSkillsDir); + await writeFile(join(cwdSkillsDir, "alpha.md"), "Alpha\n---\nBody", "utf8"); + await writeFile(join(cwdSkillsDir, "beta.md"), "Beta\n---\nBody", "utf8"); + + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill?.parameters.properties?.name?.enum).toEqual(["alpha", "beta"]); + }); + + it("handles empty skill directories gracefully", async () => { + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const tool = createLoadSkillTool({ homeDir, workdir }); + const asm: ToolAssembly = { + tools: [tool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + const loadSkill = result.tools.find( + (t: import("@dispatch/kernel").ToolContract) => t.name === "load_skill", + ); + expect(loadSkill?.description).toContain("No skills are currently available"); + }); + + it("passes through non-load_skill tools unchanged", async () => { + const filter = makeSkillsToolFilter({ homeDir, workdir }); + + const otherTool = { + name: "other_tool", + description: "Some other tool", + parameters: { type: "object" as const }, + execute: async () => ({ content: "ok" }), + }; + const asm: ToolAssembly = { + tools: [otherTool], + cwd: workdir, + conversationId: "test-conv", + }; + + const result = await filter(asm); + expect(result.tools[0]?.name).toBe("other_tool"); + expect(result.tools[0]?.description).toBe("Some other tool"); + }); }); diff --git a/packages/skills/src/tools-filter.ts b/packages/skills/src/tools-filter.ts index 058f971..3f25e95 100644 --- a/packages/skills/src/tools-filter.ts +++ b/packages/skills/src/tools-filter.ts @@ -9,55 +9,55 @@ import { mergeCatalog, renderDescription } from "./pure.js"; * and name parameter enum with the current skill catalog. */ export function makeSkillsToolFilter(deps: SkillsDeps) { - const { homeDir, workdir } = deps; - - return async (asm: ToolAssembly): Promise => { - const effectiveBase = asm.cwd ? resolve(asm.cwd) : resolve(workdir); - const cwdSkillsDir = join(effectiveBase, ".skills"); - const homeSkillsDir = join(resolve(homeDir), ".skills"); - - let homeEntries: readonly import("./pure.js").SkillEntry[]; - let cwdEntries: readonly import("./pure.js").SkillEntry[]; - try { - [homeEntries, cwdEntries] = await Promise.all([ - scanSkillsDir(homeSkillsDir), - scanSkillsDir(cwdSkillsDir), - ]); - } catch { - return asm; - } - - const catalog = mergeCatalog(homeEntries, cwdEntries); - const names = catalog.map((e) => e.name); - const description = renderDescription(catalog); - - return { - ...asm, - tools: asm.tools.map((t: ToolContract) => { - if (t.name !== "load_skill") return t; - - const nameProp = t.parameters.properties?.name; - if (!nameProp) { - return { ...t, description }; - } - - const updatedNameProp: JsonSchemaProperty = - names.length > 0 ? { ...nameProp, enum: names } : { ...nameProp }; - - const updatedProperties: Record = { - ...t.parameters.properties, - name: updatedNameProp, - }; - - return { - ...t, - description, - parameters: { - ...t.parameters, - properties: updatedProperties, - }, - }; - }), - }; - }; + const { homeDir, workdir } = deps; + + return async (asm: ToolAssembly): Promise => { + const effectiveBase = asm.cwd ? resolve(asm.cwd) : resolve(workdir); + const cwdSkillsDir = join(effectiveBase, ".skills"); + const homeSkillsDir = join(resolve(homeDir), ".skills"); + + let homeEntries: readonly import("./pure.js").SkillEntry[]; + let cwdEntries: readonly import("./pure.js").SkillEntry[]; + try { + [homeEntries, cwdEntries] = await Promise.all([ + scanSkillsDir(homeSkillsDir), + scanSkillsDir(cwdSkillsDir), + ]); + } catch { + return asm; + } + + const catalog = mergeCatalog(homeEntries, cwdEntries); + const names = catalog.map((e) => e.name); + const description = renderDescription(catalog); + + return { + ...asm, + tools: asm.tools.map((t: ToolContract) => { + if (t.name !== "load_skill") return t; + + const nameProp = t.parameters.properties?.name; + if (!nameProp) { + return { ...t, description }; + } + + const updatedNameProp: JsonSchemaProperty = + names.length > 0 ? { ...nameProp, enum: names } : { ...nameProp }; + + const updatedProperties: Record = { + ...t.parameters.properties, + name: updatedNameProp, + }; + + return { + ...t, + description, + parameters: { + ...t.parameters, + properties: updatedProperties, + }, + }; + }), + }; + }; } diff --git a/packages/skills/tsconfig.json b/packages/skills/tsconfig.json index 2ae3233..0a366d6 100644 --- a/packages/skills/tsconfig.json +++ b/packages/skills/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../session-orchestrator" }] } diff --git a/packages/ssh/package.json b/packages/ssh/package.json index 8f0d025..67dc78e 100644 --- a/packages/ssh/package.json +++ b/packages/ssh/package.json @@ -1,20 +1,20 @@ { - "name": "@dispatch/ssh", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/exec-backend": "workspace:*", - "@dispatch/kernel": "workspace:*", - "@dispatch/transport-contract": "workspace:*", - "@dispatch/transport-http": "workspace:*", - "@dispatch/wire": "workspace:*", - "ssh-config": "^5.1.0", - "ssh2": "^1.17.0" - }, - "devDependencies": { - "@types/ssh2": "^1.15.5" - } + "name": "@dispatch/ssh", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/exec-backend": "workspace:*", + "@dispatch/kernel": "workspace:*", + "@dispatch/transport-contract": "workspace:*", + "@dispatch/transport-http": "workspace:*", + "@dispatch/wire": "workspace:*", + "ssh-config": "^5.1.0", + "ssh2": "^1.17.0" + }, + "devDependencies": { + "@types/ssh2": "^1.15.5" + } } diff --git a/packages/ssh/src/backend.ts b/packages/ssh/src/backend.ts index 6531b8f..3e7f536 100644 --- a/packages/ssh/src/backend.ts +++ b/packages/ssh/src/backend.ts @@ -14,11 +14,11 @@ */ import type { - DirEntry, - ExecBackend, - ExecResult, - SpawnParams, - StatResult, + DirEntry, + ExecBackend, + ExecResult, + SpawnParams, + StatResult, } from "@dispatch/exec-backend"; import type { Client, ClientChannel } from "ssh2"; import { mapSshError } from "./errors.js"; @@ -36,77 +36,77 @@ export type AcquireConnection = (alias: string) => Promise; * from `~/.ssh/config` at connect time, so the backend carries no stale params. */ export function createSshExecBackend(alias: string, acquire: AcquireConnection): ExecBackend { - const getConn = (): Promise => acquire(alias); - - return { - async spawn(params: SpawnParams): Promise { - const conn = await getConn(); - const client = await conn.getClient(); - // ssh2 exec has no cwd option → prefix `cd "" && `. - // Shell-quote the cwd so a path with metachars can't break out (plan §7.6). - const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`; - - return runExec(client, wrapped, params); - }, - - async readFile(path: string): Promise { - const conn = await getConn(); - const sftp = await conn.getSftp(); - return new Promise((resolve, reject) => { - sftp.readFile(path, "utf8", (err, data) => { - if (err !== null && err !== undefined) reject(mapSshError(err, `readFile ${path}`)); - else resolve(data.toString("utf8")); - }); - }); - }, - - async writeFile(path: string, content: string): Promise { - const conn = await getConn(); - const sftp = await conn.getSftp(); - return new Promise((resolve, reject) => { - sftp.writeFile(path, content, "utf8", (err) => { - if (err !== null && err !== undefined) reject(mapSshError(err, `writeFile ${path}`)); - else resolve(); - }); - }); - }, - - async stat(path: string): Promise { - const conn = await getConn(); - const sftp = await conn.getSftp(); - return new Promise((resolve, reject) => { - sftp.stat(path, (err, stats) => { - if (err !== null && err !== undefined) reject(mapSshError(err, `stat ${path}`)); - else resolve({ isFile: stats.isFile(), isDirectory: stats.isDirectory() }); - }); - }); - }, - - async readdir(path: string): Promise { - const conn = await getConn(); - const sftp = await conn.getSftp(); - return new Promise((resolve, reject) => { - sftp.readdir(path, (err, list) => { - if (err !== null && err !== undefined) reject(mapSshError(err, `readdir ${path}`)); - else - resolve( - list.map((e): DirEntry => ({ name: e.filename, isDirectory: e.attrs.isDirectory() })), - ); - }); - }); - }, - - async exists(path: string): Promise { - const conn = await getConn(); - const sftp = await conn.getSftp(); - // ssh2's `sftp.exists` invokes the callback with a boolean that is TRUE - // when the path exists and FALSE when missing (verified empirically). - // Never throws — a missing path resolves `false`. - return new Promise((resolve) => { - sftp.exists(path, (exists: boolean) => resolve(exists)); - }); - }, - }; + const getConn = (): Promise => acquire(alias); + + return { + async spawn(params: SpawnParams): Promise { + const conn = await getConn(); + const client = await conn.getClient(); + // ssh2 exec has no cwd option → prefix `cd "" && `. + // Shell-quote the cwd so a path with metachars can't break out (plan §7.6). + const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`; + + return runExec(client, wrapped, params); + }, + + async readFile(path: string): Promise { + const conn = await getConn(); + const sftp = await conn.getSftp(); + return new Promise((resolve, reject) => { + sftp.readFile(path, "utf8", (err, data) => { + if (err !== null && err !== undefined) reject(mapSshError(err, `readFile ${path}`)); + else resolve(data.toString("utf8")); + }); + }); + }, + + async writeFile(path: string, content: string): Promise { + const conn = await getConn(); + const sftp = await conn.getSftp(); + return new Promise((resolve, reject) => { + sftp.writeFile(path, content, "utf8", (err) => { + if (err !== null && err !== undefined) reject(mapSshError(err, `writeFile ${path}`)); + else resolve(); + }); + }); + }, + + async stat(path: string): Promise { + const conn = await getConn(); + const sftp = await conn.getSftp(); + return new Promise((resolve, reject) => { + sftp.stat(path, (err, stats) => { + if (err !== null && err !== undefined) reject(mapSshError(err, `stat ${path}`)); + else resolve({ isFile: stats.isFile(), isDirectory: stats.isDirectory() }); + }); + }); + }, + + async readdir(path: string): Promise { + const conn = await getConn(); + const sftp = await conn.getSftp(); + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err !== null && err !== undefined) reject(mapSshError(err, `readdir ${path}`)); + else + resolve( + list.map((e): DirEntry => ({ name: e.filename, isDirectory: e.attrs.isDirectory() })), + ); + }); + }); + }, + + async exists(path: string): Promise { + const conn = await getConn(); + const sftp = await conn.getSftp(); + // ssh2's `sftp.exists` invokes the callback with a boolean that is TRUE + // when the path exists and FALSE when missing (verified empirically). + // Never throws — a missing path resolves `false`. + return new Promise((resolve) => { + sftp.exists(path, (exists: boolean) => resolve(exists)); + }); + }, + }; } // ─── spawn core ───────────────────────────────────────────────────────────── @@ -117,75 +117,75 @@ export function createSshExecBackend(alias: string, acquire: AcquireConnection): * cleanup semantics so the tool sees the same `ExecResult` shape (plan §4.3/§8). */ function runExec(client: Client, command: string, params: SpawnParams): Promise { - return new Promise((resolve) => { - let settled = false; - let timedOut = false; - let timer: ReturnType | undefined; - let exitCode: number | null = null; - - const settle = (result: ExecResult): void => { - if (settled) return; - settled = true; - if (timer !== undefined) clearTimeout(timer); - params.signal.removeEventListener("abort", onAbort); - client.removeListener("error", onClientError); - resolve(result); - }; - - const onAbort = (): void => { - if (settled) return; - try { - stream?.end(); - } catch { - // best-effort — the remote channel may already be gone - } - settle({ exitCode: null, timedOut: false, aborted: true }); - }; - - // If the client errors mid-exec, surface as a non-zero exit (the turn is - // NOT aborted — the model sees a normal tool error and can retry; §8). - const onClientError = (): void => { - if (!settled) settle({ exitCode: 1, timedOut: false, aborted: false }); - }; - client.on("error", onClientError); - - let stream: ClientChannel | undefined; - - client.exec(command, { pty: false }, (err, channel) => { - if (err !== null && err !== undefined) { - // Spawn error → non-zero exit, like localSpawn's error path. - settle({ exitCode: 1, timedOut: false, aborted: false }); - return; - } - stream = channel; - - // stdout: ssh2 channel IS its stdout stream (this.stdin = this.stdout = this). - channel.on("data", (data: Buffer) => { - params.onOutput(data.toString(), "stdout"); - }); - channel.stderr.on("data", (data: Buffer) => { - params.onOutput(data.toString(), "stderr"); - }); - channel.on("exit", (code: number | null) => { - exitCode = code; - }); - channel.on("close", () => { - settle({ exitCode, timedOut, aborted: false }); - }); - - params.signal.addEventListener("abort", onAbort, { once: true }); - timer = setTimeout(() => { - if (settled) return; - timedOut = true; - try { - channel.end(); - } catch { - // best-effort - } - settle({ exitCode: null, timedOut: true, aborted: false }); - }, params.timeout); - }); - }); + return new Promise((resolve) => { + let settled = false; + let timedOut = false; + let timer: ReturnType | undefined; + let exitCode: number | null = null; + + const settle = (result: ExecResult): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + params.signal.removeEventListener("abort", onAbort); + client.removeListener("error", onClientError); + resolve(result); + }; + + const onAbort = (): void => { + if (settled) return; + try { + stream?.end(); + } catch { + // best-effort — the remote channel may already be gone + } + settle({ exitCode: null, timedOut: false, aborted: true }); + }; + + // If the client errors mid-exec, surface as a non-zero exit (the turn is + // NOT aborted — the model sees a normal tool error and can retry; §8). + const onClientError = (): void => { + if (!settled) settle({ exitCode: 1, timedOut: false, aborted: false }); + }; + client.on("error", onClientError); + + let stream: ClientChannel | undefined; + + client.exec(command, { pty: false }, (err, channel) => { + if (err !== null && err !== undefined) { + // Spawn error → non-zero exit, like localSpawn's error path. + settle({ exitCode: 1, timedOut: false, aborted: false }); + return; + } + stream = channel; + + // stdout: ssh2 channel IS its stdout stream (this.stdin = this.stdout = this). + channel.on("data", (data: Buffer) => { + params.onOutput(data.toString(), "stdout"); + }); + channel.stderr.on("data", (data: Buffer) => { + params.onOutput(data.toString(), "stderr"); + }); + channel.on("exit", (code: number | null) => { + exitCode = code; + }); + channel.on("close", () => { + settle({ exitCode, timedOut, aborted: false }); + }); + + params.signal.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => { + if (settled) return; + timedOut = true; + try { + channel.end(); + } catch { + // best-effort + } + settle({ exitCode: null, timedOut: true, aborted: false }); + }, params.timeout); + }); + }); } // ─── shell quoting ───────────────────────────────────────────────────────── @@ -196,5 +196,5 @@ function runExec(client: Client, command: string, params: SpawnParams): Promise< * value and any embedded single-quote is escaped (`'\''`). */ export function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; + return `'${value.replace(/'/g, "'\\''")}'`; } diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 04ccdc5..a54f8d6 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from "vitest"; import { - isRejected, - knownHostToken, - parseKnownHosts, - resolveComputer, - resolveComputers, - type SshConfigResolveEnv, + isRejected, + knownHostToken, + parseKnownHosts, + resolveComputer, + resolveComputers, + type SshConfigResolveEnv, } from "./config.js"; const env = (overrides: Partial = {}): SshConfigResolveEnv => ({ - configText: "", - knownHostsText: "", - defaultUser: "fallback-user", - homeDir: "/home/test", - ...overrides, + configText: "", + knownHostsText: "", + defaultUser: "fallback-user", + homeDir: "/home/test", + ...overrides, }); const FIXTURE = ` @@ -41,376 +41,376 @@ Host github.com `; describe("resolveComputers", () => { - it("returns one Computer per named (non-wildcard) Host alias, sorted", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - expect(computers.map((c) => c.alias)).toEqual(["barehost", "github.com", "myserver", "web"]); - }); - - it("skips wildcard-only Host patterns (* and ?)", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - // The bare `*` host is a pattern, not a computer — excluded. - expect(computers.find((c) => c.alias === "*")).toBeUndefined(); - }); - - it("skips wildcard aliases within a multi-alias Host line (*.example.com)", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - expect(computers.find((c) => c.alias === "*.example.com")).toBeUndefined(); - // but the named alias on the SAME line (web) is included. - expect(computers.find((c) => c.alias === "web")).toBeDefined(); - }); - - it("resolves HostName/Port/User/IdentityFile from the config (first-match-wins)", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - const my = computers.find((c) => c.alias === "myserver"); - expect(my).toEqual({ - alias: "myserver", - hostName: "10.0.0.5", - port: 2222, - user: "deploy", - identityFile: "/home/test/.ssh/deploy_key", - knownHost: false, - }); - }); - - it("falls back HostName → alias when no HostName is set", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - const bare = computers.find((c) => c.alias === "barehost"); - expect(bare?.hostName).toBe("barehost"); - expect(bare?.port).toBe(22); - expect(bare?.user).toBe("fallback-user"); - expect(bare?.identityFile).toBeNull(); - }); - - it("expands ~ in IdentityFile to homeDir", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - const gh = computers.find((c) => c.alias === "github.com"); - expect(gh?.identityFile).toBe("/home/test/.ssh/github_key"); - }); - - it("resolves a Host block whose first alias is a wildcard but later alias is named", () => { - const computers = resolveComputers(env({ configText: FIXTURE })); - const web = computers.find((c) => c.alias === "web"); - expect(web?.hostName).toBe("web.internal"); - expect(web?.user).toBe("webuser"); - }); - - it("de-dups aliases listed in multiple Host lines (first wins)", () => { - const dup = ` + it("returns one Computer per named (non-wildcard) Host alias, sorted", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + expect(computers.map((c) => c.alias)).toEqual(["barehost", "github.com", "myserver", "web"]); + }); + + it("skips wildcard-only Host patterns (* and ?)", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + // The bare `*` host is a pattern, not a computer — excluded. + expect(computers.find((c) => c.alias === "*")).toBeUndefined(); + }); + + it("skips wildcard aliases within a multi-alias Host line (*.example.com)", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + expect(computers.find((c) => c.alias === "*.example.com")).toBeUndefined(); + // but the named alias on the SAME line (web) is included. + expect(computers.find((c) => c.alias === "web")).toBeDefined(); + }); + + it("resolves HostName/Port/User/IdentityFile from the config (first-match-wins)", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + const my = computers.find((c) => c.alias === "myserver"); + expect(my).toEqual({ + alias: "myserver", + hostName: "10.0.0.5", + port: 2222, + user: "deploy", + identityFile: "/home/test/.ssh/deploy_key", + knownHost: false, + }); + }); + + it("falls back HostName → alias when no HostName is set", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + const bare = computers.find((c) => c.alias === "barehost"); + expect(bare?.hostName).toBe("barehost"); + expect(bare?.port).toBe(22); + expect(bare?.user).toBe("fallback-user"); + expect(bare?.identityFile).toBeNull(); + }); + + it("expands ~ in IdentityFile to homeDir", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + const gh = computers.find((c) => c.alias === "github.com"); + expect(gh?.identityFile).toBe("/home/test/.ssh/github_key"); + }); + + it("resolves a Host block whose first alias is a wildcard but later alias is named", () => { + const computers = resolveComputers(env({ configText: FIXTURE })); + const web = computers.find((c) => c.alias === "web"); + expect(web?.hostName).toBe("web.internal"); + expect(web?.user).toBe("webuser"); + }); + + it("de-dups aliases listed in multiple Host lines (first wins)", () => { + const dup = ` Host dup HostName first.example Host dup HostName second.example `; - const computers = resolveComputers(env({ configText: dup })); - expect(computers).toHaveLength(1); - expect(computers[0]?.hostName).toBe("first.example"); - }); - - it("knownHost=true when the resolved HostName:port token is in known_hosts", () => { - // myserver is port 2222 → token is [10.0.0.5]:2222; web is port 22 → bare host. - const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\nweb.internal ssh-ed25519 BBB\n"; - const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known })); - expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true); - // default port 22 → token is just the hostName (no bracket). - expect(computers.find((c) => c.alias === "web")?.knownHost).toBe(true); - expect(computers.find((c) => c.alias === "barehost")?.knownHost).toBe(false); - }); - - it("knownHost keys a non-default port as [host]:port", () => { - const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\n"; - const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known })); - expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true); - }); + const computers = resolveComputers(env({ configText: dup })); + expect(computers).toHaveLength(1); + expect(computers[0]?.hostName).toBe("first.example"); + }); + + it("knownHost=true when the resolved HostName:port token is in known_hosts", () => { + // myserver is port 2222 → token is [10.0.0.5]:2222; web is port 22 → bare host. + const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\nweb.internal ssh-ed25519 BBB\n"; + const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known })); + expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true); + // default port 22 → token is just the hostName (no bracket). + expect(computers.find((c) => c.alias === "web")?.knownHost).toBe(true); + expect(computers.find((c) => c.alias === "barehost")?.knownHost).toBe(false); + }); + + it("knownHost keys a non-default port as [host]:port", () => { + const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\n"; + const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known })); + expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true); + }); }); describe("resolveComputer (single alias)", () => { - it("resolves a named alias", () => { - const c = resolveComputer("myserver", env({ configText: FIXTURE })); - expect(c?.hostName).toBe("10.0.0.5"); - expect(c?.port).toBe(2222); - }); - - it("returns null for an unknown alias", () => { - expect(resolveComputer("nope", env({ configText: FIXTURE }))).toBeNull(); - }); - - it("returns null for a wildcard alias (not a selectable computer)", () => { - expect(resolveComputer("*.example.com", env({ configText: FIXTURE }))).toBeNull(); - }); - - it("applies top-level wildcard defaults to a named host (first-match-wins)", () => { - const cfg = ` + it("resolves a named alias", () => { + const c = resolveComputer("myserver", env({ configText: FIXTURE })); + expect(c?.hostName).toBe("10.0.0.5"); + expect(c?.port).toBe(2222); + }); + + it("returns null for an unknown alias", () => { + expect(resolveComputer("nope", env({ configText: FIXTURE }))).toBeNull(); + }); + + it("returns null for a wildcard alias (not a selectable computer)", () => { + expect(resolveComputer("*.example.com", env({ configText: FIXTURE }))).toBeNull(); + }); + + it("applies top-level wildcard defaults to a named host (first-match-wins)", () => { + const cfg = ` Host * ServerAliveInterval 60 User stardefault Host named HostName named.example `; - const c = resolveComputer("named", env({ configText: cfg })); - // User inherited from the `Host *` block via first-match-wins. - expect(c?.user).toBe("stardefault"); - expect(c?.hostName).toBe("named.example"); - }); + const c = resolveComputer("named", env({ configText: cfg })); + // User inherited from the `Host *` block via first-match-wins. + expect(c?.user).toBe("stardefault"); + expect(c?.hostName).toBe("named.example"); + }); }); describe("knownHostToken", () => { - it("returns the bare host for the default port (22)", () => { - expect(knownHostToken("host.example", 22)).toBe("host.example"); - }); + it("returns the bare host for the default port (22)", () => { + expect(knownHostToken("host.example", 22)).toBe("host.example"); + }); - it("returns [host]:port for a non-default port", () => { - expect(knownHostToken("host.example", 2222)).toBe("[host.example]:2222"); - }); + it("returns [host]:port for a non-default port", () => { + expect(knownHostToken("host.example", 2222)).toBe("[host.example]:2222"); + }); }); // ─── known_hosts discovery ───────────────────────────────────────────────── const KNOWN_HOSTS_FIXTURE = [ - "# comment line", - "arch-razer ssh-ed25519 AAAA1", - "xenifse1 ssh-ed25519 AAAA2", - "xenifse1 ssh-rsa AAAA3", - "xenifse1 ecdsa-sha2-nistp256 AAAA4", - "[xenifse1]:2222 ssh-ed25519 AAAA5", - "github.com ssh-ed25519 AAAA6", - "|1|+W4/jC7U8rJwiEC2m0KvG2A7uAA=|rWwV8p5J1FfR3k= ssh-ed25519 AAAA7", - "localhost ssh-ed25519 AAAA8", - "[localhost]:2222 ssh-ed25519 AAAA9", + "# comment line", + "arch-razer ssh-ed25519 AAAA1", + "xenifse1 ssh-ed25519 AAAA2", + "xenifse1 ssh-rsa AAAA3", + "xenifse1 ecdsa-sha2-nistp256 AAAA4", + "[xenifse1]:2222 ssh-ed25519 AAAA5", + "github.com ssh-ed25519 AAAA6", + "|1|+W4/jC7U8rJwiEC2m0KvG2A7uAA=|rWwV8p5J1FfR3k= ssh-ed25519 AAAA7", + "localhost ssh-ed25519 AAAA8", + "[localhost]:2222 ssh-ed25519 AAAA9", ].join("\n"); describe("parseKnownHosts", () => { - it("extracts hostnames with default port 22 from bare entries", () => { - const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); - const arch = entries.find((e) => e.hostname === "arch-razer"); - expect(arch).toEqual({ hostname: "arch-razer", port: 22 }); - }); - - it("extracts hostname + port from [host]:port notation", () => { - const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); - // xenifse1 has entries on port 22 (first) and port 2222 — first wins. - const xen = entries.find((e) => e.hostname === "xenifse1"); - expect(xen?.port).toBe(22); - // localhost appears as port 22 (first) and [localhost]:2222 — first wins. - const local = entries.find((e) => e.hostname === "localhost"); - expect(local?.port).toBe(22); - }); - - it("deduplicates by hostname (first port wins)", () => { - const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); - const xenCount = entries.filter((e) => e.hostname === "xenifse1").length; - expect(xenCount).toBe(1); - // Multiple key types (ed25519, rsa, ecdsa) for the same host:port = 1 entry. - expect(entries).toHaveLength(4); // arch-razer, xenifse1, github.com, localhost - }); - - it("skips hashed entries (|1|...)", () => { - const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); - expect(entries.find((e) => e.hostname.startsWith("|"))).toBeUndefined(); - }); - - it("skips comment lines and empty lines", () => { - const entries = parseKnownHosts("\n# comment\n\n \nhost1 ssh-ed25519 AAA\n"); - expect(entries).toHaveLength(1); - expect(entries[0]?.hostname).toBe("host1"); - }); - - it("returns empty for empty text", () => { - expect(parseKnownHosts("")).toEqual([]); - expect(parseKnownHosts(" \n\n ")).toEqual([]); - }); - - it("handles comma-separated host markers", () => { - const entries = parseKnownHosts("hostA,hostB ssh-ed25519 AAA\n"); - expect(entries.map((e) => e.hostname)).toEqual(["hostA", "hostB"]); - }); - - it("preserves non-default port when the host only has [host]:port entries", () => { - const entries = parseKnownHosts("[specialhost]:9999 ssh-ed25519 AAA\n"); - expect(entries[0]).toEqual({ hostname: "specialhost", port: 9999 }); - }); + it("extracts hostnames with default port 22 from bare entries", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + const arch = entries.find((e) => e.hostname === "arch-razer"); + expect(arch).toEqual({ hostname: "arch-razer", port: 22 }); + }); + + it("extracts hostname + port from [host]:port notation", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + // xenifse1 has entries on port 22 (first) and port 2222 — first wins. + const xen = entries.find((e) => e.hostname === "xenifse1"); + expect(xen?.port).toBe(22); + // localhost appears as port 22 (first) and [localhost]:2222 — first wins. + const local = entries.find((e) => e.hostname === "localhost"); + expect(local?.port).toBe(22); + }); + + it("deduplicates by hostname (first port wins)", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + const xenCount = entries.filter((e) => e.hostname === "xenifse1").length; + expect(xenCount).toBe(1); + // Multiple key types (ed25519, rsa, ecdsa) for the same host:port = 1 entry. + expect(entries).toHaveLength(4); // arch-razer, xenifse1, github.com, localhost + }); + + it("skips hashed entries (|1|...)", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + expect(entries.find((e) => e.hostname.startsWith("|"))).toBeUndefined(); + }); + + it("skips comment lines and empty lines", () => { + const entries = parseKnownHosts("\n# comment\n\n \nhost1 ssh-ed25519 AAA\n"); + expect(entries).toHaveLength(1); + expect(entries[0]?.hostname).toBe("host1"); + }); + + it("returns empty for empty text", () => { + expect(parseKnownHosts("")).toEqual([]); + expect(parseKnownHosts(" \n\n ")).toEqual([]); + }); + + it("handles comma-separated host markers", () => { + const entries = parseKnownHosts("hostA,hostB ssh-ed25519 AAA\n"); + expect(entries.map((e) => e.hostname)).toEqual(["hostA", "hostB"]); + }); + + it("preserves non-default port when the host only has [host]:port entries", () => { + const entries = parseKnownHosts("[specialhost]:9999 ssh-ed25519 AAA\n"); + expect(entries[0]).toEqual({ hostname: "specialhost", port: 9999 }); + }); }); // ─── known_hosts discovery merged into resolveComputers ─────────────────── describe("resolveComputers with known_hosts discovery", () => { - it("discovers computers from known_hosts when no ~/.ssh/config exists", () => { - const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); - const aliases = computers.map((c) => c.alias); - expect(aliases).toContain("arch-razer"); - expect(aliases).toContain("xenifse1"); - expect(aliases).toContain("github.com"); - expect(aliases).toContain("localhost"); - }); - - it("defaulted params for known_hosts-discovered computers", () => { - const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); - const arch = computers.find((c) => c.alias === "arch-razer"); - expect(arch).toEqual({ - alias: "arch-razer", - hostName: "arch-razer", - port: 22, - user: "fallback-user", - identityFile: null, - knownHost: true, - }); - }); - - it("config entries take precedence over known_hosts (no duplication)", () => { - const cfg = ` + it("discovers computers from known_hosts when no ~/.ssh/config exists", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + const aliases = computers.map((c) => c.alias); + expect(aliases).toContain("arch-razer"); + expect(aliases).toContain("xenifse1"); + expect(aliases).toContain("github.com"); + expect(aliases).toContain("localhost"); + }); + + it("defaulted params for known_hosts-discovered computers", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + const arch = computers.find((c) => c.alias === "arch-razer"); + expect(arch).toEqual({ + alias: "arch-razer", + hostName: "arch-razer", + port: 22, + user: "fallback-user", + identityFile: null, + knownHost: true, + }); + }); + + it("config entries take precedence over known_hosts (no duplication)", () => { + const cfg = ` Host arch-razer HostName 192.168.1.100 User root Port 2222 `; - const computers = resolveComputers( - env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), - ); - const arch = computers.filter((c) => c.alias === "arch-razer"); - expect(arch).toHaveLength(1); - // Config params win, not the known_hosts defaults. - expect(arch[0]?.hostName).toBe("192.168.1.100"); - expect(arch[0]?.user).toBe("root"); - expect(arch[0]?.port).toBe(2222); - }); - - it("merges config + known_hosts entries (union, sorted)", () => { - const cfg = ` + const computers = resolveComputers( + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + const arch = computers.filter((c) => c.alias === "arch-razer"); + expect(arch).toHaveLength(1); + // Config params win, not the known_hosts defaults. + expect(arch[0]?.hostName).toBe("192.168.1.100"); + expect(arch[0]?.user).toBe("root"); + expect(arch[0]?.port).toBe(2222); + }); + + it("merges config + known_hosts entries (union, sorted)", () => { + const cfg = ` Host server-a HostName 10.0.0.1 `; - const computers = resolveComputers( - env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), - ); - const aliases = computers.map((c) => c.alias); - // config entry + known_hosts entries, all unique, sorted. - expect(aliases).toEqual(["arch-razer", "github.com", "localhost", "server-a", "xenifse1"]); - }); + const computers = resolveComputers( + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + const aliases = computers.map((c) => c.alias); + // config entry + known_hosts entries, all unique, sorted. + expect(aliases).toEqual(["arch-razer", "github.com", "localhost", "server-a", "xenifse1"]); + }); }); // ─── reject-list filtering ──────────────────────────────────────────────── describe("resolveComputers with rejectPatterns", () => { - it("filters out exact-match rejected hostnames", () => { - const computers = resolveComputers( - env({ - knownHostsText: KNOWN_HOSTS_FIXTURE, - rejectPatterns: ["github.com"], - }), - ); - expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); - expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); - }); - - it("filters out glob patterns (* matches any sequence)", () => { - const computers = resolveComputers( - env({ - knownHostsText: KNOWN_HOSTS_FIXTURE, - rejectPatterns: ["*.com"], - }), - ); - expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); - expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); - }); - - it("filters multiple patterns", () => { - const computers = resolveComputers( - env({ - knownHostsText: KNOWN_HOSTS_FIXTURE, - rejectPatterns: ["github.com", "localhost"], - }), - ); - const aliases = computers.map((c) => c.alias); - expect(aliases).toEqual(["arch-razer", "xenifse1"]); - }); - - it("does NOT filter resolveComputer (single alias lookup ignores reject list)", () => { - // resolveComputer is for specific lookups (status, test, connect) — - // the reject list is a discovery/catalog filter, not access control. - const c = resolveComputer( - "github.com", - env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: ["github.com"] }), - ); - expect(c).not.toBeNull(); - expect(c?.alias).toBe("github.com"); - }); - - it("empty rejectPatterns does not filter anything", () => { - const computers = resolveComputers( - env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: [] }), - ); - expect(computers).toHaveLength(4); - }); - - it("absent rejectPatterns does not filter anything", () => { - const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); - expect(computers).toHaveLength(4); - }); + it("filters out exact-match rejected hostnames", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["github.com"], + }), + ); + expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); + expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); + }); + + it("filters out glob patterns (* matches any sequence)", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["*.com"], + }), + ); + expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); + expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); + }); + + it("filters multiple patterns", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["github.com", "localhost"], + }), + ); + const aliases = computers.map((c) => c.alias); + expect(aliases).toEqual(["arch-razer", "xenifse1"]); + }); + + it("does NOT filter resolveComputer (single alias lookup ignores reject list)", () => { + // resolveComputer is for specific lookups (status, test, connect) — + // the reject list is a discovery/catalog filter, not access control. + const c = resolveComputer( + "github.com", + env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: ["github.com"] }), + ); + expect(c).not.toBeNull(); + expect(c?.alias).toBe("github.com"); + }); + + it("empty rejectPatterns does not filter anything", () => { + const computers = resolveComputers( + env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: [] }), + ); + expect(computers).toHaveLength(4); + }); + + it("absent rejectPatterns does not filter anything", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(computers).toHaveLength(4); + }); }); // ─── resolveComputer known_hosts fallback ───────────────────────────────── describe("resolveComputer with known_hosts fallback", () => { - it("resolves a host from known_hosts when not in config", () => { - const c = resolveComputer("arch-razer", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); - expect(c).toEqual({ - alias: "arch-razer", - hostName: "arch-razer", - port: 22, - user: "fallback-user", - identityFile: null, - knownHost: true, - }); - }); - - it("returns null for a host in neither config nor known_hosts", () => { - const c = resolveComputer("nonexistent", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); - expect(c).toBeNull(); - }); - - it("config takes precedence over known_hosts for resolveComputer", () => { - const cfg = ` + it("resolves a host from known_hosts when not in config", () => { + const c = resolveComputer("arch-razer", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(c).toEqual({ + alias: "arch-razer", + hostName: "arch-razer", + port: 22, + user: "fallback-user", + identityFile: null, + knownHost: true, + }); + }); + + it("returns null for a host in neither config nor known_hosts", () => { + const c = resolveComputer("nonexistent", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(c).toBeNull(); + }); + + it("config takes precedence over known_hosts for resolveComputer", () => { + const cfg = ` Host arch-razer HostName 192.168.1.100 User root `; - const c = resolveComputer( - "arch-razer", - env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), - ); - expect(c?.hostName).toBe("192.168.1.100"); - expect(c?.user).toBe("root"); - }); + const c = resolveComputer( + "arch-razer", + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + expect(c?.hostName).toBe("192.168.1.100"); + expect(c?.user).toBe("root"); + }); }); // ─── isRejected glob matching ────────────────────────────────────────────── describe("isRejected", () => { - it("matches exact hostname", () => { - expect(isRejected("github.com", ["github.com"])).toBe(true); - expect(isRejected("arch-razer", ["github.com"])).toBe(false); - }); - - it("matches * glob (any sequence)", () => { - expect(isRejected("foo.ts.net", ["*.ts.net"])).toBe(true); - // * matches zero chars, but the remaining ".ts.net" (with literal dot) - // still doesn't match "ts.net" — the dot is required. - expect(isRejected("ts.net", ["*.ts.net"])).toBe(false); - expect(isRejected("foo.other.net", ["*.ts.net"])).toBe(false); - }); - - it("matches ? glob (single char)", () => { - expect(isRejected("host1", ["host?"])).toBe(true); - expect(isRejected("host12", ["host?"])).toBe(false); // ? = exactly one char - }); - - it("is case-insensitive", () => { - expect(isRejected("GitHub.Com", ["github.com"])).toBe(true); - expect(isRejected("FOO.TS.NET", ["*.ts.net"])).toBe(true); - }); - - it("returns false for absent or empty patterns", () => { - expect(isRejected("anything")).toBe(false); - expect(isRejected("anything", [])).toBe(false); - expect(isRejected("anything", undefined)).toBe(false); - }); + it("matches exact hostname", () => { + expect(isRejected("github.com", ["github.com"])).toBe(true); + expect(isRejected("arch-razer", ["github.com"])).toBe(false); + }); + + it("matches * glob (any sequence)", () => { + expect(isRejected("foo.ts.net", ["*.ts.net"])).toBe(true); + // * matches zero chars, but the remaining ".ts.net" (with literal dot) + // still doesn't match "ts.net" — the dot is required. + expect(isRejected("ts.net", ["*.ts.net"])).toBe(false); + expect(isRejected("foo.other.net", ["*.ts.net"])).toBe(false); + }); + + it("matches ? glob (single char)", () => { + expect(isRejected("host1", ["host?"])).toBe(true); + expect(isRejected("host12", ["host?"])).toBe(false); // ? = exactly one char + }); + + it("is case-insensitive", () => { + expect(isRejected("GitHub.Com", ["github.com"])).toBe(true); + expect(isRejected("FOO.TS.NET", ["*.ts.net"])).toBe(true); + }); + + it("returns false for absent or empty patterns", () => { + expect(isRejected("anything")).toBe(false); + expect(isRejected("anything", [])).toBe(false); + expect(isRejected("anything", undefined)).toBe(false); + }); }); diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 7c4daa3..b01f860 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -34,20 +34,20 @@ import { isKnownHost } from "./hostkey.js"; /** Injected environment for the pure resolver (no ambient process access). */ export interface SshConfigResolveEnv { - /** The raw `~/.ssh/config` text (may be empty — no file). */ - readonly configText: string; - /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */ - readonly knownHostsText: string; - /** Fallback user when the config sets none (the current OS user). */ - readonly defaultUser: string; - /** Home dir, for resolving `~` in `IdentityFile` (already-expanded by caller). */ - readonly homeDir: string; - /** - * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the - * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent - * or empty → no filtering. - */ - readonly rejectPatterns?: readonly string[]; + /** The raw `~/.ssh/config` text (may be empty — no file). */ + readonly configText: string; + /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */ + readonly knownHostsText: string; + /** Fallback user when the config sets none (the current OS user). */ + readonly defaultUser: string; + /** Home dir, for resolving `~` in `IdentityFile` (already-expanded by caller). */ + readonly homeDir: string; + /** + * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the + * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent + * or empty → no filtering. + */ + readonly rejectPatterns?: readonly string[]; } /** @@ -70,50 +70,50 @@ export interface SshConfigResolveEnv { * Pure: `SshConfigResolveEnv` → `readonly Computer[]`. */ export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] { - const config = SSHConfig.parse(env.configText); - const computers: Computer[] = []; - - // Source 1: ~/.ssh/config — full-param aliases. - for (const line of config) { - // Only `Host` sections define aliases; `Match`/standalone directives aren't - // selectable computers. - if (!isHostSection(line)) continue; - const aliases = readAliasValues(line); - for (const alias of aliases) { - if (isWildcardAlias(alias)) continue; // patterns, not targets - const computer = resolveOne(config, alias, env); - if (computer !== null) computers.push(computer); - } - } - - const configAliases = new Set(computers.map((c) => c.alias)); - - // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config. - for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) { - if (configAliases.has(hostname)) continue; // config takes precedence - computers.push({ - alias: hostname, - hostName: hostname, - port, - user: env.defaultUser, - identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.) - knownHost: true, // it's in known_hosts by definition - }); - } - - // De-dup by alias (a host may be listed in multiple `Host` lines or appear - // in both config + known_hosts; first wins), then sort for stable FE ordering. - const seen = new Set(); - const unique = computers.filter((c) => { - if (seen.has(c.alias)) return false; - seen.add(c.alias); - return true; - }); - - // Filter out rejected hostnames (glob patterns from dispatch.toml). - const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns)); - filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); - return filtered; + const config = SSHConfig.parse(env.configText); + const computers: Computer[] = []; + + // Source 1: ~/.ssh/config — full-param aliases. + for (const line of config) { + // Only `Host` sections define aliases; `Match`/standalone directives aren't + // selectable computers. + if (!isHostSection(line)) continue; + const aliases = readAliasValues(line); + for (const alias of aliases) { + if (isWildcardAlias(alias)) continue; // patterns, not targets + const computer = resolveOne(config, alias, env); + if (computer !== null) computers.push(computer); + } + } + + const configAliases = new Set(computers.map((c) => c.alias)); + + // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config. + for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) { + if (configAliases.has(hostname)) continue; // config takes precedence + computers.push({ + alias: hostname, + hostName: hostname, + port, + user: env.defaultUser, + identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.) + knownHost: true, // it's in known_hosts by definition + }); + } + + // De-dup by alias (a host may be listed in multiple `Host` lines or appear + // in both config + known_hosts; first wins), then sort for stable FE ordering. + const seen = new Set(); + const unique = computers.filter((c) => { + if (seen.has(c.alias)) return false; + seen.add(c.alias); + return true; + }); + + // Filter out rejected hostnames (glob patterns from dispatch.toml). + const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns)); + filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); + return filtered; } /** @@ -125,103 +125,103 @@ export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] * Pure. `compute()` applies OpenSSH first-match-wins + wildcards. */ export function resolveComputer(alias: string, env: SshConfigResolveEnv): Computer | null { - // Source 1: ~/.ssh/config. - const config = SSHConfig.parse(env.configText); - if (aliasExistsAsNamedHost(config, alias)) { - return resolveOne(config, alias, env); - } - - // Source 2: ~/.ssh/known_hosts (defaulted params). - const knownHosts = parseKnownHosts(env.knownHostsText); - const entry = knownHosts.find((h) => h.hostname === alias); - if (entry !== undefined) { - return { - alias: entry.hostname, - hostName: entry.hostname, - port: entry.port, - user: env.defaultUser, - identityFile: null, - knownHost: true, - }; - } - - return null; + // Source 1: ~/.ssh/config. + const config = SSHConfig.parse(env.configText); + if (aliasExistsAsNamedHost(config, alias)) { + return resolveOne(config, alias, env); + } + + // Source 2: ~/.ssh/known_hosts (defaulted params). + const knownHosts = parseKnownHosts(env.knownHostsText); + const entry = knownHosts.find((h) => h.hostname === alias); + if (entry !== undefined) { + return { + alias: entry.hostname, + hostName: entry.hostname, + port: entry.port, + user: env.defaultUser, + identityFile: null, + knownHost: true, + }; + } + + return null; } /** Resolve one alias using a parsed config. Pure. */ function resolveOne(config: SSHConfig, alias: string, env: SshConfigResolveEnv): Computer | null { - const computed = config.compute(alias); - const hostName = stringValue(computed.HostName) ?? alias; // falls back to alias - const port = numberValue(computed.Port) ?? 22; - const user = stringValue(computed.User) ?? env.defaultUser; - const identityFile = identityFileValue(computed.IdentityFile, env); + const computed = config.compute(alias); + const hostName = stringValue(computed.HostName) ?? alias; // falls back to alias + const port = numberValue(computed.Port) ?? 22; + const user = stringValue(computed.User) ?? env.defaultUser; + const identityFile = identityFileValue(computed.IdentityFile, env); - // `knownHost` is keyed by the HostName (the actual connect target) — that is - // what ssh2 connects to and what OpenSSH records in known_hosts. - const knownHost = isKnownHost(env.knownHostsText, knownHostToken(hostName, port)); + // `knownHost` is keyed by the HostName (the actual connect target) — that is + // what ssh2 connects to and what OpenSSH records in known_hosts. + const knownHost = isKnownHost(env.knownHostsText, knownHostToken(hostName, port)); - return { alias, hostName, port, user, identityFile, knownHost }; + return { alias, hostName, port, user, identityFile, knownHost }; } // ─── ssh-config line helpers ────────────────────────────────────────────── function isHostSection(line: SSHConfig[number]): line is Section { - return "param" in line && (line as Directive).param.toLowerCase() === "host"; + return "param" in line && (line as Directive).param.toLowerCase() === "host"; } /** The alias values declared on a `Host` line (space-separated, may be quoted). */ function readAliasValues(section: Section): string[] { - const value = section.value; - if (typeof value === "string") return value.split(/\s+/).filter((s) => s.length > 0); - // Quoted/structured value: array of { val } objects. - if (Array.isArray(value)) { - return value.map((v) => (typeof v === "string" ? v : v.val)).filter((s) => s.length > 0); - } - return []; + const value = section.value; + if (typeof value === "string") return value.split(/\s+/).filter((s) => s.length > 0); + // Quoted/structured value: array of { val } objects. + if (Array.isArray(value)) { + return value.map((v) => (typeof v === "string" ? v : v.val)).filter((s) => s.length > 0); + } + return []; } /** A `Host` alias is a selectable computer only if it contains no wildcard chars. */ function isWildcardAlias(alias: string): boolean { - return alias.includes("*") || alias.includes("?"); + return alias.includes("*") || alias.includes("?"); } function aliasExistsAsNamedHost(config: SSHConfig, alias: string): boolean { - for (const line of config) { - if (!isHostSection(line)) continue; - const aliases = readAliasValues(line); - if (aliases.includes(alias) && !aliases.some(isWildcardAlias)) return true; - } - return false; + for (const line of config) { + if (!isHostSection(line)) continue; + const aliases = readAliasValues(line); + if (aliases.includes(alias) && !aliases.some(isWildcardAlias)) return true; + } + return false; } // ─── value coercion (ssh-config returns string | string[]) ──────────────── function stringValue(v: string | string[] | undefined): string | undefined { - if (v === undefined) return undefined; - return Array.isArray(v) ? v[0] : v; + if (v === undefined) return undefined; + return Array.isArray(v) ? v[0] : v; } function numberValue(v: string | string[] | undefined): number | undefined { - const s = stringValue(v); - if (s === undefined) return undefined; - const n = Number.parseInt(s, 10); - return Number.isNaN(n) ? undefined : n; + const s = stringValue(v); + if (s === undefined) return undefined; + const n = Number.parseInt(s, 10); + return Number.isNaN(n) ? undefined : n; } function identityFileValue( - v: string | string[] | undefined, - env: SshConfigResolveEnv, + v: string | string[] | undefined, + env: SshConfigResolveEnv, ): string | null { - const raw = stringValue(v); - if (raw === undefined) return null; // caller falls back to default probing - return expandPath(raw, env.homeDir); + const raw = stringValue(v); + if (raw === undefined) return null; // caller falls back to default probing + return expandPath(raw, env.homeDir); } /** Expand a leading `~` to the home dir. (Other $VARs left to the shell.) */ function expandPath(p: string, homeDir: string): string { - if (p === "~") return homeDir; - if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`; - return p; + if (p === "~") return homeDir; + if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`; + return p; } /** @@ -230,25 +230,25 @@ function expandPath(p: string, homeDir: string): string { * `host`. Used both for the `knownHost` view and by the pool's host-verifier. */ export function knownHostToken(hostName: string, port: number): string { - if (port === 22) return hostName; - return `[${hostName}]:${port}`; + if (port === 22) return hostName; + return `[${hostName}]:${port}`; } // ─── known_hosts discovery ───────────────────────────────────────────────── /** Find the index of the first space or tab, or -1 if none. */ function findSpace(line: string): number { - for (let i = 0; i < line.length; i++) { - const ch = line.charCodeAt(i); - if (ch === 32 || ch === 9) return i; // space or tab - } - return -1; + for (let i = 0; i < line.length; i++) { + const ch = line.charCodeAt(i); + if (ch === 32 || ch === 9) return i; // space or tab + } + return -1; } /** A hostname + port extracted from a `~/.ssh/known_hosts` line. */ export interface KnownHostEntry { - readonly hostname: string; - readonly port: number; + readonly hostname: string; + readonly port: number; } /** @@ -267,47 +267,47 @@ export interface KnownHostEntry { * Pure: `knownHostsText` → `readonly KnownHostEntry[]`. */ export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry[] { - const entries: KnownHostEntry[] = []; - const seen = new Set(); - - for (const raw of knownHostsText.split("\n")) { - const line = raw.trim(); - if (line === "" || line.startsWith("#")) continue; - - // First whitespace-delimited field is the host markers (comma-list). - const firstSpace = findSpace(line); - const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); - - for (const marker of firstField.split(",")) { - const trimmed = marker.trim(); - if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed - - let hostname: string; - let port = 22; - - if (trimmed.startsWith("[")) { - // [hostname]:port or [hostname] - const bracketEnd = trimmed.indexOf("]"); - if (bracketEnd === -1) continue; // malformed - hostname = trimmed.slice(1, bracketEnd); - const afterBracket = trimmed.slice(bracketEnd + 1); - if (afterBracket.startsWith(":")) { - const n = Number.parseInt(afterBracket.slice(1), 10); - if (Number.isFinite(n) && n > 0) port = n; - } - } else { - hostname = trimmed; - } - - // Dedup by hostname — first port wins (a host with entries on - // multiple ports gets one computer; use config for a specific port). - if (seen.has(hostname)) continue; - seen.add(hostname); - entries.push({ hostname, port }); - } - } - - return entries; + const entries: KnownHostEntry[] = []; + const seen = new Set(); + + for (const raw of knownHostsText.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + + // First whitespace-delimited field is the host markers (comma-list). + const firstSpace = findSpace(line); + const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); + + for (const marker of firstField.split(",")) { + const trimmed = marker.trim(); + if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed + + let hostname: string; + let port = 22; + + if (trimmed.startsWith("[")) { + // [hostname]:port or [hostname] + const bracketEnd = trimmed.indexOf("]"); + if (bracketEnd === -1) continue; // malformed + hostname = trimmed.slice(1, bracketEnd); + const afterBracket = trimmed.slice(bracketEnd + 1); + if (afterBracket.startsWith(":")) { + const n = Number.parseInt(afterBracket.slice(1), 10); + if (Number.isFinite(n) && n > 0) port = n; + } + } else { + hostname = trimmed; + } + + // Dedup by hostname — first port wins (a host with entries on + // multiple ports gets one computer; use config for a specific port). + if (seen.has(hostname)) continue; + seen.add(hostname); + entries.push({ hostname, port }); + } + } + + return entries; } // ─── reject-list glob matching ───────────────────────────────────────────── @@ -320,8 +320,8 @@ export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry * Pure: `alias` + `patterns` → `boolean`. */ export function isRejected(alias: string, patterns?: readonly string[]): boolean { - if (patterns === undefined || patterns.length === 0) return false; - return patterns.some((p) => globMatch(p, alias)); + if (patterns === undefined || patterns.length === 0) return false; + return patterns.some((p) => globMatch(p, alias)); } /** @@ -330,35 +330,35 @@ export function isRejected(alias: string, patterns?: readonly string[]): boolean * characters match literally. */ function globMatch(pattern: string, input: string): boolean { - const p = pattern.toLowerCase(); - const s = input.toLowerCase(); - return globMatchImpl(p, 0, s, 0); + const p = pattern.toLowerCase(); + const s = input.toLowerCase(); + return globMatchImpl(p, 0, s, 0); } function globMatchImpl(p: string, pi: number, s: string, si: number): boolean { - while (pi < p.length) { - const pc = p[pi]; - if (pc === "*") { - // Skip consecutive * (they're equivalent to one). - while (pi + 1 < p.length && p[pi + 1] === "*") pi++; - // If * is the last char, match everything remaining. - if (pi + 1 === p.length) return true; - // Try to match the rest of the pattern at every position in s. - for (let i = si; i <= s.length; i++) { - if (globMatchImpl(p, pi + 1, s, i)) return true; - } - return false; - } - if (pc === "?") { - if (si >= s.length) return false; - pi++; - si++; - continue; - } - // Literal char. - if (si >= s.length || p[pi] !== s[si]) return false; - pi++; - si++; - } - return si === s.length; + while (pi < p.length) { + const pc = p[pi]; + if (pc === "*") { + // Skip consecutive * (they're equivalent to one). + while (pi + 1 < p.length && p[pi + 1] === "*") pi++; + // If * is the last char, match everything remaining. + if (pi + 1 === p.length) return true; + // Try to match the rest of the pattern at every position in s. + for (let i = si; i <= s.length; i++) { + if (globMatchImpl(p, pi + 1, s, i)) return true; + } + return false; + } + if (pc === "?") { + if (si >= s.length) return false; + pi++; + si++; + continue; + } + // Literal char. + if (si >= s.length || p[pi] !== s[si]) return false; + pi++; + si++; + } + return si === s.length; } diff --git a/packages/ssh/src/errors.test.ts b/packages/ssh/src/errors.test.ts index 234fa49..189b704 100644 --- a/packages/ssh/src/errors.test.ts +++ b/packages/ssh/src/errors.test.ts @@ -2,89 +2,89 @@ import { describe, expect, it } from "vitest"; import { type FsError, fsError, mapSshError, sftpStatusToErrno } from "./errors.js"; describe("sftpStatusToErrno", () => { - it("maps SSH_FX_NO_SUCH_FILE (3) → ENOENT", () => { - expect(sftpStatusToErrno(3)).toBe("ENOENT"); - }); + it("maps SSH_FX_NO_SUCH_FILE (3) → ENOENT", () => { + expect(sftpStatusToErrno(3)).toBe("ENOENT"); + }); - it("maps SSH_FX_PERMISSION_DENIED (4) → EACCES", () => { - expect(sftpStatusToErrno(4)).toBe("EACCES"); - }); + it("maps SSH_FX_PERMISSION_DENIED (4) → EACCES", () => { + expect(sftpStatusToErrno(4)).toBe("EACCES"); + }); - it("maps SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST", () => { - expect(sftpStatusToErrno(11)).toBe("EEXIST"); - }); + it("maps SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST", () => { + expect(sftpStatusToErrno(11)).toBe("EEXIST"); + }); - it("maps SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR", () => { - expect(sftpStatusToErrno(20)).toBe("ENOTDIR"); - }); + it("maps SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR", () => { + expect(sftpStatusToErrno(20)).toBe("ENOTDIR"); + }); - it("returns undefined for codes with no errno analog", () => { - expect(sftpStatusToErrno(1)).toBeUndefined(); // SSH_FX_EOF - expect(sftpStatusToErrno(999)).toBeUndefined(); - }); + it("returns undefined for codes with no errno analog", () => { + expect(sftpStatusToErrno(1)).toBeUndefined(); // SSH_FX_EOF + expect(sftpStatusToErrno(999)).toBeUndefined(); + }); }); describe("fsError", () => { - it("builds an Error carrying a .code string", () => { - const err: FsError = fsError("ENOENT", "no such file: /x"); - expect(err).toBeInstanceOf(Error); - expect(err.code).toBe("ENOENT"); - expect(err.message).toBe("no such file: /x"); - }); + it("builds an Error carrying a .code string", () => { + const err: FsError = fsError("ENOENT", "no such file: /x"); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("ENOENT"); + expect(err.message).toBe("no such file: /x"); + }); }); describe("mapSshError", () => { - it("maps a numeric SFTP status code on .code → ENOENT", () => { - const err = mapSshError(Object.assign(new Error("fail"), { code: 3 }), "readFile /x"); - expect(err.code).toBe("ENOENT"); - expect(err.message).toContain("readFile /x"); - expect(err.message).toContain("fail"); - }); + it("maps a numeric SFTP status code on .code → ENOENT", () => { + const err = mapSshError(Object.assign(new Error("fail"), { code: 3 }), "readFile /x"); + expect(err.code).toBe("ENOENT"); + expect(err.message).toContain("readFile /x"); + expect(err.message).toContain("fail"); + }); - it("maps an SFTP_* string code → ENOENT", () => { - const err = mapSshError( - Object.assign(new Error("nope"), { code: "SFTP_STATUS_NO_SUCH_FILE" }), - "stat /y", - ); - expect(err.code).toBe("ENOENT"); - }); + it("maps an SFTP_* string code → ENOENT", () => { + const err = mapSshError( + Object.assign(new Error("nope"), { code: "SFTP_STATUS_NO_SUCH_FILE" }), + "stat /y", + ); + expect(err.code).toBe("ENOENT"); + }); - it("maps an SFTP permission-denied string → EACCES", () => { - const err = mapSshError( - Object.assign(new Error("denied"), { code: "SFTP_STATUS_PERMISSION_DENIED" }), - "readFile /y", - ); - expect(err.code).toBe("EACCES"); - }); + it("maps an SFTP permission-denied string → EACCES", () => { + const err = mapSshError( + Object.assign(new Error("denied"), { code: "SFTP_STATUS_PERMISSION_DENIED" }), + "readFile /y", + ); + expect(err.code).toBe("EACCES"); + }); - it("falls back to message-text sniffing when .code is absent (No such file)", () => { - const err = mapSshError(new Error("No such file or directory"), "readFile /z"); - expect(err.code).toBe("ENOENT"); - }); + it("falls back to message-text sniffing when .code is absent (No such file)", () => { + const err = mapSshError(new Error("No such file or directory"), "readFile /z"); + expect(err.code).toBe("ENOENT"); + }); - it("falls back to message-text sniffing for permission denied", () => { - const err = mapSshError(new Error("Permission denied"), "writeFile /z"); - expect(err.code).toBe("EACCES"); - }); + it("falls back to message-text sniffing for permission denied", () => { + const err = mapSshError(new Error("Permission denied"), "writeFile /z"); + expect(err.code).toBe("EACCES"); + }); - it("surfaces HOST KEY CHANGED as EHOSTUNREACH", () => { - const err = mapSshError(new Error("HOST KEY CHANGED for localhost"), "connect"); - expect(err.code).toBe("EHOSTUNREACH"); - }); + it("surfaces HOST KEY CHANGED as EHOSTUNREACH", () => { + const err = mapSshError(new Error("HOST KEY CHANGED for localhost"), "connect"); + expect(err.code).toBe("EHOSTUNREACH"); + }); - it("defaults unrecognized errors to EIO", () => { - const err = mapSshError(new Error("something weird happened"), "readdir /a"); - expect(err.code).toBe("EIO"); - }); + it("defaults unrecognized errors to EIO", () => { + const err = mapSshError(new Error("something weird happened"), "readdir /a"); + expect(err.code).toBe("EIO"); + }); - it("never throws — maps a non-Error value", () => { - const err = mapSshError("just a string", "readdir /a"); - expect(err.code).toBe("EIO"); - expect(err.message).toContain("just a string"); - }); + it("never throws — maps a non-Error value", () => { + const err = mapSshError("just a string", "readdir /a"); + expect(err.code).toBe("EIO"); + expect(err.message).toContain("just a string"); + }); - it("includes the context prefix in the message", () => { - const err = mapSshError(new Error("boom"), "writeFile /path/file"); - expect(err.message).toContain("writeFile /path/file"); - }); + it("includes the context prefix in the message", () => { + const err = mapSshError(new Error("boom"), "writeFile /path/file"); + expect(err.message).toContain("writeFile /path/file"); + }); }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index fab9d32..4fea51e 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -14,14 +14,14 @@ /** A node:fs-style error carrying a `.code` errno string. */ export interface FsError extends Error { - readonly code: string; + readonly code: string; } /** Build a node:fs-style error with a `.code`. Pure. */ export function fsError(code: string, message: string): FsError { - const err = new Error(message) as FsError; - (err as { code: string }).code = code; - return err; + const err = new Error(message) as FsError; + (err as { code: string }).code = code; + return err; } /** @@ -32,15 +32,15 @@ export function fsError(code: string, message: string): FsError { * @see https://datatracker.ietf.org/doc/html/rfc4254#section-9.1 */ export function sftpStatusToErrno(status: number): string | undefined { - // SSH_FX_NO_SUCH_FILE (3) → ENOENT — the common case (missing path). - if (status === 3) return "ENOENT"; - // SSH_FX_PERMISSION_DENIED (4) → EACCES — read/parse this path. - if (status === 4) return "EACCES"; - // SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST. - if (status === 11) return "EEXIST"; - // SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR. - if (status === 20) return "ENOTDIR"; - return undefined; + // SSH_FX_NO_SUCH_FILE (3) → ENOENT — the common case (missing path). + if (status === 3) return "ENOENT"; + // SSH_FX_PERMISSION_DENIED (4) → EACCES — read/parse this path. + if (status === 4) return "EACCES"; + // SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST. + if (status === 11) return "EEXIST"; + // SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR. + if (status === 20) return "ENOTDIR"; + return undefined; } /** @@ -56,38 +56,38 @@ export function sftpStatusToErrno(status: number): string | undefined { * Pure: takes the thrown value, returns an `FsError`. Never throws. */ export function mapSshError(err: unknown, context: string): FsError { - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error ? err.message : String(err); - // ssh2 SFTP errors often carry a `.code` that is an SSH_FXP_* string. - const code = (err as { code?: unknown } | null)?.code; - if (typeof code === "string") { - const mapped = sshCodeStringToErrno(code); - if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`); - // ssh2 also surfaces raw numeric SFTP status on `.code`. - } - if (typeof code === "number") { - const mapped = sftpStatusToErrno(code); - if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`); - } + // ssh2 SFTP errors often carry a `.code` that is an SSH_FXP_* string. + const code = (err as { code?: unknown } | null)?.code; + if (typeof code === "string") { + const mapped = sshCodeStringToErrno(code); + if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`); + // ssh2 also surfaces raw numeric SFTP status on `.code`. + } + if (typeof code === "number") { + const mapped = sftpStatusToErrno(code); + if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`); + } - // Some ssh2 errors embed the SFTP status code as `.desc`/message text; sniff - // the human-readable text for the common markers as a last resort. - if (message.includes("No such file") || message.includes("ENOENT")) { - return fsError("ENOENT", `${context}: ${message}`); - } - if (message.includes("Permission denied") || message.includes("EACCES")) { - return fsError("EACCES", `${context}: ${message}`); - } - if (message.includes("not a directory") || message.includes("ENOTDIR")) { - return fsError("ENOTDIR", `${context}: ${message}`); - } + // Some ssh2 errors embed the SFTP status code as `.desc`/message text; sniff + // the human-readable text for the common markers as a last resort. + if (message.includes("No such file") || message.includes("ENOENT")) { + return fsError("ENOENT", `${context}: ${message}`); + } + if (message.includes("Permission denied") || message.includes("EACCES")) { + return fsError("EACCES", `${context}: ${message}`); + } + if (message.includes("not a directory") || message.includes("ENOTDIR")) { + return fsError("ENOTDIR", `${context}: ${message}`); + } - // Host-key / connect failures are surfaced as ECONNREFUSED-ish so the tool's - // generic error path still renders them clearly. Default: generic I/O error. - if (message.includes("HOST KEY CHANGED") || message.includes("host key")) { - return fsError("EHOSTUNREACH", `${context}: ${message}`); - } - return fsError("EIO", `${context}: ${message}`); + // Host-key / connect failures are surfaced as ECONNREFUSED-ish so the tool's + // generic error path still renders them clearly. Default: generic I/O error. + if (message.includes("HOST KEY CHANGED") || message.includes("host key")) { + return fsError("EHOSTUNREACH", `${context}: ${message}`); + } + return fsError("EIO", `${context}: ${message}`); } /** @@ -97,15 +97,15 @@ export function mapSshError(err: unknown, context: string): FsError { * Returns `undefined` when no analog. Pure. */ function sshCodeStringToErrno(code: string): string | undefined { - const c = code.toUpperCase(); - if (c.includes("NO_SUCH_FILE")) return "ENOENT"; - if (c.includes("PERMISSION_DENIED")) return "EACCES"; - if ( - c.includes("FILE_ALREADY_EXISTS") || - (c.includes("FAILURE") === false && c.includes("EXIST")) - ) { - return "EEXIST"; - } - if (c.includes("NOT_A_DIRECTORY")) return "ENOTDIR"; - return undefined; + const c = code.toUpperCase(); + if (c.includes("NO_SUCH_FILE")) return "ENOENT"; + if (c.includes("PERMISSION_DENIED")) return "EACCES"; + if ( + c.includes("FILE_ALREADY_EXISTS") || + (c.includes("FAILURE") === false && c.includes("EXIST")) + ) { + return "EEXIST"; + } + if (c.includes("NOT_A_DIRECTORY")) return "ENOTDIR"; + return undefined; } diff --git a/packages/ssh/src/extension.ts b/packages/ssh/src/extension.ts index 3294908..c098ba0 100644 --- a/packages/ssh/src/extension.ts +++ b/packages/ssh/src/extension.ts @@ -22,24 +22,24 @@ import type { Extension, HostAPI, Logger, Manifest } from "@dispatch/kernel"; import { computerServiceHandle } from "@dispatch/transport-http/dist/seam.js"; import { Client } from "ssh2"; import { - resolveComputer as resolveComputerFromConfig, - type SshConfigResolveEnv, + resolveComputer as resolveComputerFromConfig, + type SshConfigResolveEnv, } from "./config.js"; import { createSshService, type SshServiceDeps } from "./service.js"; export const manifest: Manifest = { - id: "ssh", - name: "SSH Remote Execution", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - // exec-backend owns the resolver; ssh provides the remote factory it looks up - // at runtime (lazy, post-activation). Declaring dependsOn keeps the DAG honest - // even though the lookup itself is deferred to tool-execute time. - dependsOn: ["exec-backend"], - capabilities: { fs: true, network: true }, - contributes: { services: ["ssh", "exec-backend/remote-factory"] }, + id: "ssh", + name: "SSH Remote Execution", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + // exec-backend owns the resolver; ssh provides the remote factory it looks up + // at runtime (lazy, post-activation). Declaring dependsOn keeps the DAG honest + // even though the lookup itself is deferred to tool-execute time. + dependsOn: ["exec-backend"], + capabilities: { fs: true, network: true }, + contributes: { services: ["ssh", "exec-backend/remote-factory"] }, }; /** @@ -48,35 +48,35 @@ export const manifest: Manifest = { * against a live sshd (the integration test — no `@dispatch/*` mocking). */ export function makeSshExtension(deps: SshServiceDeps): Extension { - const store: { close: (() => Promise) | null } = { close: null }; - - return { - manifest, - activate(host: HostAPI) { - const { service, pool, remoteFactory } = createSshService(deps); - store.close = () => pool.closeAll(); - - host.provideService(remoteExecBackendFactoryHandle, remoteFactory); - host.provideService(computerServiceHandle, service); - - host.logger.info("ssh extension activated"); - }, - async deactivate() { - await store.close?.(); - store.close = null; - }, - }; + const store: { close: (() => Promise) | null } = { close: null }; + + return { + manifest, + activate(host: HostAPI) { + const { service, pool, remoteFactory } = createSshService(deps); + store.close = () => pool.closeAll(); + + host.provideService(remoteExecBackendFactoryHandle, remoteFactory); + host.provideService(computerServiceHandle, service); + + host.logger.info("ssh extension activated"); + }, + async deactivate() { + await store.close?.(); + store.close = null; + }, + }; } // ─── real node:fs + ssh2 adapters (production wiring) ───────────────────── /** Path candidates for `dispatch.toml` (global + project-local). */ function dispatchTomlPaths(): readonly string[] { - const paths = [ - join(homedir(), ".config", "dispatch", "dispatch.toml"), // global - join(process.cwd(), "dispatch.toml"), // project-local - ]; - return paths; + const paths = [ + join(homedir(), ".config", "dispatch", "dispatch.toml"), // global + join(process.cwd(), "dispatch.toml"), // project-local + ]; + return paths; } /** @@ -85,30 +85,30 @@ function dispatchTomlPaths(): readonly string[] { * Uses `Bun.TOML.parse` (Bun's built-in TOML parser — zero deps). */ async function readRejectPatternsImpl(): Promise { - const patterns: string[] = []; - const seen = new Set(); - - for (const path of dispatchTomlPaths()) { - try { - const text = await readFile(path, "utf8"); - const parsed = Bun.TOML.parse(text) as { - ssh?: { reject?: readonly string[] }; - }; - const list = parsed.ssh?.reject; - if (list !== undefined) { - for (const p of list) { - if (typeof p === "string" && !seen.has(p)) { - seen.add(p); - patterns.push(p); - } - } - } - } catch { - // File missing or parse error → skip silently. - } - } - - return patterns; + const patterns: string[] = []; + const seen = new Set(); + + for (const path of dispatchTomlPaths()) { + try { + const text = await readFile(path, "utf8"); + const parsed = Bun.TOML.parse(text) as { + ssh?: { reject?: readonly string[] }; + }; + const list = parsed.ssh?.reject; + if (list !== undefined) { + for (const p of list) { + if (typeof p === "string" && !seen.has(p)) { + seen.add(p); + patterns.push(p); + } + } + } + } catch { + // File missing or parse error → skip silently. + } + } + + return patterns; } /** @@ -118,67 +118,67 @@ async function readRejectPatternsImpl(): Promise { * resolved fresh from `~/.ssh/config` on each acquire (decision #4). */ export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps { - const sshDir = join(homedir(), ".ssh"); - const configPath = join(sshDir, "config"); - const knownHostsPath = join(sshDir, "known_hosts"); - - const readConfigText = async (): Promise => readFile(configPath, "utf8"); - const readFileText = async (path: string): Promise => readFile(path, "utf8"); - const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root"; - - /** Read the reject list fresh from `dispatch.toml` on each call. */ - const readRejectPatterns = async (): Promise => readRejectPatternsImpl(); - - /** - * Build the resolve env (config + known_hosts + reject patterns) — shared by - * the service methods and the pool's resolveComputer dep. - */ - async function readEnv(): Promise { - const [configText, knownHostsText, rejectPatterns] = await Promise.all([ - readConfigText().catch(async () => ""), - readFileText(knownHostsPath).catch(async () => ""), - readRejectPatterns(), - ]); - const base: SshConfigResolveEnv = { - configText, - knownHostsText, - defaultUser, - homeDir: homedir(), - }; - return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base; - } - - return { - logger: hostLogger, - homeDir: homedir(), - defaultUser, - knownHostsPath, - readConfigText, - readFileText, - readRejectPatterns, - pathExists: async (path: string) => - access(path) - .then(() => true) - .catch(() => false), - appendKnownHosts: async (path: string, line: string) => - appendFile(path, `${line}\n`, { encoding: "utf8" }), - newClient: () => new Client(), - // Resolve a computer alias → `Computer` by reading the live config + - // known_hosts. Reads fresh on each call (a Host block or known_hosts - // entry added between turns is picked up). Does NOT apply the reject - // list — the pool needs to connect even to hosts hidden from the catalog. - resolveComputer: async (alias: string) => { - const env = await readEnv(); - return resolveComputerFromConfig(alias, env); - }, - }; + const sshDir = join(homedir(), ".ssh"); + const configPath = join(sshDir, "config"); + const knownHostsPath = join(sshDir, "known_hosts"); + + const readConfigText = async (): Promise => readFile(configPath, "utf8"); + const readFileText = async (path: string): Promise => readFile(path, "utf8"); + const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root"; + + /** Read the reject list fresh from `dispatch.toml` on each call. */ + const readRejectPatterns = async (): Promise => readRejectPatternsImpl(); + + /** + * Build the resolve env (config + known_hosts + reject patterns) — shared by + * the service methods and the pool's resolveComputer dep. + */ + async function readEnv(): Promise { + const [configText, knownHostsText, rejectPatterns] = await Promise.all([ + readConfigText().catch(async () => ""), + readFileText(knownHostsPath).catch(async () => ""), + readRejectPatterns(), + ]); + const base: SshConfigResolveEnv = { + configText, + knownHostsText, + defaultUser, + homeDir: homedir(), + }; + return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base; + } + + return { + logger: hostLogger, + homeDir: homedir(), + defaultUser, + knownHostsPath, + readConfigText, + readFileText, + readRejectPatterns, + pathExists: async (path: string) => + access(path) + .then(() => true) + .catch(() => false), + appendKnownHosts: async (path: string, line: string) => + appendFile(path, `${line}\n`, { encoding: "utf8" }), + newClient: () => new Client(), + // Resolve a computer alias → `Computer` by reading the live config + + // known_hosts. Reads fresh on each call (a Host block or known_hosts + // entry added between turns is picked up). Does NOT apply the reject + // list — the pool needs to connect even to hosts hidden from the catalog. + resolveComputer: async (alias: string) => { + const env = await readEnv(); + return resolveComputerFromConfig(alias, env); + }, + }; } /** Production extension: real `node:fs` + real `ssh2`. */ export const extension: Extension = { - manifest, - activate(host: HostAPI) { - const deps = createSshServiceDeps(host.logger); - makeSshExtension(deps).activate(host); - }, + manifest, + activate(host: HostAPI) { + const deps = createSshServiceDeps(host.logger); + makeSshExtension(deps).activate(host); + }, }; diff --git a/packages/ssh/src/hostkey.test.ts b/packages/ssh/src/hostkey.test.ts index 1975777..1870de0 100644 --- a/packages/ssh/src/hostkey.test.ts +++ b/packages/ssh/src/hostkey.test.ts @@ -2,104 +2,104 @@ import { describe, expect, it } from "vitest"; import { decideHostKey, type HostKeyFingerprint, isKnownHost } from "./hostkey.js"; const fp = (token: string, key = "AAA"): HostKeyFingerprint => ({ - knownHostToken: token, - keyBase64: key, - keyType: "ssh-ed25519", + knownHostToken: token, + keyBase64: key, + keyType: "ssh-ed25519", }); describe("decideHostKey — present + match → accept, no append", () => { - it("accepts when the pinned key matches exactly", () => { - const known = "myhost ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "AAA")); - expect(d.accept).toBe(true); - expect(d.append).toBeUndefined(); - expect(d.reason).toContain("matches"); - }); + it("accepts when the pinned key matches exactly", () => { + const known = "myhost ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "AAA")); + expect(d.accept).toBe(true); + expect(d.append).toBeUndefined(); + expect(d.reason).toContain("matches"); + }); - it("matches ignoring leading/trailing whitespace differences", () => { - const known = "myhost ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "AAA")); - expect(d.accept).toBe(true); - expect(d.append).toBeUndefined(); - }); + it("matches ignoring leading/trailing whitespace differences", () => { + const known = "myhost ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "AAA")); + expect(d.accept).toBe(true); + expect(d.append).toBeUndefined(); + }); - it("matches a comma-host token list containing the alias", () => { - const known = "hostA,myhost,hostB ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "AAA")); - expect(d.accept).toBe(true); - }); + it("matches a comma-host token list containing the alias", () => { + const known = "hostA,myhost,hostB ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "AAA")); + expect(d.accept).toBe(true); + }); }); describe("decideHostKey — present + mismatch → REJECT, no append", () => { - it("rejects loudly when the pinned key differs", () => { - const known = "myhost ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "BBB")); - expect(d.accept).toBe(false); - expect(d.append).toBeUndefined(); // never pin a mismatched key - expect(d.reason).toContain("HOST KEY CHANGED"); - expect(d.reason).toContain("myhost"); - }); + it("rejects loudly when the pinned key differs", () => { + const known = "myhost ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "BBB")); + expect(d.accept).toBe(false); + expect(d.append).toBeUndefined(); // never pin a mismatched key + expect(d.reason).toContain("HOST KEY CHANGED"); + expect(d.reason).toContain("myhost"); + }); - it("does not pin on mismatch (the user must clear the stale line)", () => { - const known = "myhost ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "DIFFERENT")); - expect(d.append).toBeUndefined(); - }); + it("does not pin on mismatch (the user must clear the stale line)", () => { + const known = "myhost ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "DIFFERENT")); + expect(d.append).toBeUndefined(); + }); }); describe("decideHostKey — absent (first connect) → accept + pin", () => { - it("accepts and produces the pin line to append", () => { - const d = decideHostKey("", fp("newhost", "AAA")); - expect(d.accept).toBe(true); - expect(d.append).toBe("newhost ssh-ed25519 AAA"); - expect(d.reason).toContain("first connect"); - expect(d.reason).toContain("newhost"); - }); + it("accepts and produces the pin line to append", () => { + const d = decideHostKey("", fp("newhost", "AAA")); + expect(d.accept).toBe(true); + expect(d.append).toBe("newhost ssh-ed25519 AAA"); + expect(d.reason).toContain("first connect"); + expect(d.reason).toContain("newhost"); + }); - it("ignores comment + empty lines when searching", () => { - const known = "# a comment\n\n \notherhost ssh-ed25519 ZZZ\n"; - const d = decideHostKey(known, fp("newhost", "AAA")); - expect(d.accept).toBe(true); - expect(d.append).toBe("newhost ssh-ed25519 AAA"); - }); + it("ignores comment + empty lines when searching", () => { + const known = "# a comment\n\n \notherhost ssh-ed25519 ZZZ\n"; + const d = decideHostKey(known, fp("newhost", "AAA")); + expect(d.accept).toBe(true); + expect(d.append).toBe("newhost ssh-ed25519 AAA"); + }); - it("pins a bracketed token for a non-default port", () => { - const d = decideHostKey("", fp("[localhost]:2222", "AAA")); - expect(d.accept).toBe(true); - expect(d.append).toBe("[localhost]:2222 ssh-ed25519 AAA"); - }); + it("pins a bracketed token for a non-default port", () => { + const d = decideHostKey("", fp("[localhost]:2222", "AAA")); + expect(d.accept).toBe(true); + expect(d.append).toBe("[localhost]:2222 ssh-ed25519 AAA"); + }); }); describe("decideHostKey — first field must match the token", () => { - it("does not match a host that appears only as a substring of another token", () => { - const known = "myhost-extra ssh-ed25519 AAA\n"; - const d = decideHostKey(known, fp("myhost", "AAA")); - // "myhost" is not an exact first-field (nor comma element) → absent → pin. - expect(d.accept).toBe(true); - expect(d.append).toBe("myhost ssh-ed25519 AAA"); - }); + it("does not match a host that appears only as a substring of another token", () => { + const known = "myhost-extra ssh-ed25519 AAA\n"; + const d = decideHostKey(known, fp("myhost", "AAA")); + // "myhost" is not an exact first-field (nor comma element) → absent → pin. + expect(d.accept).toBe(true); + expect(d.append).toBe("myhost ssh-ed25519 AAA"); + }); }); describe("isKnownHost", () => { - it("returns true when the token is a known_hosts first field", () => { - expect(isKnownHost("a.example ssh-ed25519 AAA\n", "a.example")).toBe(true); - }); + it("returns true when the token is a known_hosts first field", () => { + expect(isKnownHost("a.example ssh-ed25519 AAA\n", "a.example")).toBe(true); + }); - it("returns true for a comma-list token", () => { - expect(isKnownHost("a,b,c ssh-ed25519 AAA\n", "b")).toBe(true); - }); + it("returns true for a comma-list token", () => { + expect(isKnownHost("a,b,c ssh-ed25519 AAA\n", "b")).toBe(true); + }); - it("returns false when the token is absent", () => { - expect(isKnownHost("a.example ssh-ed25519 AAA\n", "b.example")).toBe(false); - }); + it("returns false when the token is absent", () => { + expect(isKnownHost("a.example ssh-ed25519 AAA\n", "b.example")).toBe(false); + }); - it("returns false for an empty known_hosts", () => { - expect(isKnownHost("", "anything")).toBe(false); - }); + it("returns false for an empty known_hosts", () => { + expect(isKnownHost("", "anything")).toBe(false); + }); - it("ignores comments and blanks", () => { - const known = "# comment\n\nfoo ssh-ed25519 AAA\n"; - expect(isKnownHost(known, "foo")).toBe(true); - expect(isKnownHost(known, "bar")).toBe(false); - }); + it("ignores comments and blanks", () => { + const known = "# comment\n\nfoo ssh-ed25519 AAA\n"; + expect(isKnownHost(known, "foo")).toBe(true); + expect(isKnownHost(known, "bar")).toBe(false); + }); }); diff --git a/packages/ssh/src/hostkey.ts b/packages/ssh/src/hostkey.ts index 626b060..f1c8a07 100644 --- a/packages/ssh/src/hostkey.ts +++ b/packages/ssh/src/hostkey.ts @@ -17,16 +17,16 @@ /** Outcome of a host-key check. The shell acts on `accept` + `append`. */ export interface HostKeyDecision { - /** Accept the connection (true) or reject it loudly (false). */ - readonly accept: boolean; - /** - * When the host is unseen (first connect), the line to append to - * `known_hosts` to pin the key. `undefined` when the host is already known - * (no write needed) or when rejecting (do not pin a mismatched key). - */ - readonly append: string | undefined; - /** Human-readable reason for logging/the rejection error. */ - readonly reason: string; + /** Accept the connection (true) or reject it loudly (false). */ + readonly accept: boolean; + /** + * When the host is unseen (first connect), the line to append to + * `known_hosts` to pin the key. `undefined` when the host is already known + * (no write needed) or when rejecting (do not pin a mismatched key). + */ + readonly append: string | undefined; + /** Human-readable reason for logging/the rejection error. */ + readonly reason: string; } /** @@ -40,12 +40,12 @@ export interface HostKeyDecision { * shared trust store). */ export interface HostKeyFingerprint { - /** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */ - readonly knownHostToken: string; - /** The base64-encoded public key (the 2nd field of a known_hosts line). */ - readonly keyBase64: string; - /** Key type label, e.g. `ssh-ed25519` (the 1st field). */ - readonly keyType: string; + /** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */ + readonly knownHostToken: string; + /** The base64-encoded public key (the 2nd field of a known_hosts line). */ + readonly keyBase64: string; + /** Key type label, e.g. `ssh-ed25519` (the 1st field). */ + readonly keyType: string; } /** @@ -66,76 +66,76 @@ export interface HostKeyFingerprint { * Pure: `knownHostsText` + `fingerprint` → `HostKeyDecision`. */ export function decideHostKey( - knownHostsText: string, - fingerprint: HostKeyFingerprint, + knownHostsText: string, + fingerprint: HostKeyFingerprint, ): HostKeyDecision { - const { knownHostToken, keyBase64, keyType } = fingerprint; - const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`; + const { knownHostToken, keyBase64, keyType } = fingerprint; + const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`; - // A line matches THIS host when its first field is the knownHostToken. - const existing = findHostLine(knownHostsText, knownHostToken); + // A line matches THIS host when its first field is the knownHostToken. + const existing = findHostLine(knownHostsText, knownHostToken); - if (existing === undefined) { - // Absent → first connect → accept + pin (the accept-new analog). - return { - accept: true, - append: expectedLine, - reason: `first connect to "${knownHostToken}": pinning host key`, - }; - } + if (existing === undefined) { + // Absent → first connect → accept + pin (the accept-new analog). + return { + accept: true, + append: expectedLine, + reason: `first connect to "${knownHostToken}": pinning host key`, + }; + } - // Present → compare the key material (fields 2+3). Ignore leading/trailing - // whitespace differences (OpenSSH tolerates these). - const normalizedExisting = normalizeLine(existing); - if (normalizedExisting === normalizeLine(expectedLine)) { - return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` }; - } + // Present → compare the key material (fields 2+3). Ignore leading/trailing + // whitespace differences (OpenSSH tolerates these). + const normalizedExisting = normalizeLine(existing); + if (normalizedExisting === normalizeLine(expectedLine)) { + return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` }; + } - // Present but DIFFERENT → reject loudly. Do NOT pin (the key changed → - // possible MITM; the user must clear the stale line manually). - return { - accept: false, - append: undefined, - reason: - `HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` + - `(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`, - }; + // Present but DIFFERENT → reject loudly. Do NOT pin (the key changed → + // possible MITM; the user must clear the stale line manually). + return { + accept: false, + append: undefined, + reason: + `HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` + + `(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`, + }; } /** Find the first known_hosts line whose first field is `token`. Pure. */ function findHostLine(text: string, token: string): string | undefined { - for (const raw of text.split("\n")) { - const line = raw.trim(); - if (line === "" || line.startsWith("#")) continue; - // First whitespace-delimited field is the host token (possibly comma-list). - const firstSpace = findFirstSpace(line); - const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); - // A token may be a comma-separated host list; accept if any element matches. - if ( - firstField - .split(",") - .map((h) => h.trim()) - .includes(token) - ) { - return line; - } - } - return undefined; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + // First whitespace-delimited field is the host token (possibly comma-list). + const firstSpace = findFirstSpace(line); + const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); + // A token may be a comma-separated host list; accept if any element matches. + if ( + firstField + .split(",") + .map((h) => h.trim()) + .includes(token) + ) { + return line; + } + } + return undefined; } /** Normalize a known_hosts line for key-material comparison (host-independent). */ function normalizeLine(line: string): string { - const parts = line.split(/\s+/).filter((p) => p.length > 0); - // Drop the first field (host token); compare key-type + base64 key. - return parts.slice(1).join(" "); + const parts = line.split(/\s+/).filter((p) => p.length > 0); + // Drop the first field (host token); compare key-type + base64 key. + return parts.slice(1).join(" "); } function findFirstSpace(line: string): number { - for (let i = 0; i < line.length; i++) { - const ch = line.charCodeAt(i); - if (ch === 32 || ch === 9) return i; // space or tab - } - return -1; + for (let i = 0; i < line.length; i++) { + const ch = line.charCodeAt(i); + if (ch === 32 || ch === 9) return i; // space or tab + } + return -1; } /** @@ -144,5 +144,5 @@ function findFirstSpace(line: string): number { * first-field matching as `decideHostKey`. */ export function isKnownHost(knownHostsText: string, token: string): boolean { - return findHostLine(knownHostsText, token) !== undefined; + return findHostLine(knownHostsText, token) !== undefined; } diff --git a/packages/ssh/src/index.ts b/packages/ssh/src/index.ts index 2d4fb2a..f4f7bba 100644 --- a/packages/ssh/src/index.ts +++ b/packages/ssh/src/index.ts @@ -1,24 +1,24 @@ export { type AcquireConnection, createSshExecBackend, shellQuote } from "./backend.js"; export { - knownHostToken, - resolveComputer, - resolveComputers, - type SshConfigResolveEnv, + knownHostToken, + resolveComputer, + resolveComputers, + type SshConfigResolveEnv, } from "./config.js"; export { type FsError, fsError, mapSshError, sftpStatusToErrno } from "./errors.js"; export { createSshServiceDeps, extension, makeSshExtension, manifest } from "./extension.js"; export { - decideHostKey, - type HostKeyDecision, - type HostKeyFingerprint, - isKnownHost, + decideHostKey, + type HostKeyDecision, + type HostKeyFingerprint, + isKnownHost, } from "./hostkey.js"; export { - createSshConnectionPool, - type SshConnection, - type SshConnectionPool, - type SshConnectionState, - type SshPoolDeps, - type SshPoolStatusEntry, + createSshConnectionPool, + type SshConnection, + type SshConnectionPool, + type SshConnectionState, + type SshPoolDeps, + type SshPoolStatusEntry, } from "./pool.js"; export { createSshService, type SshServiceDeps } from "./service.js"; diff --git a/packages/ssh/src/integration.test.ts b/packages/ssh/src/integration.test.ts index 7b05be2..bfbecd8 100644 --- a/packages/ssh/src/integration.test.ts +++ b/packages/ssh/src/integration.test.ts @@ -29,8 +29,8 @@ const testEnv = HOST === undefined ? null : { host: HOST, port: PORT, user: USER // Build a real config env fixture pointing at the test sshd. function configText(): string { - if (testEnv === null) return ""; - return `Host testremote\n HostName ${testEnv.host}\n Port ${testEnv.port}\n User ${testEnv.user}\n`; + if (testEnv === null) return ""; + return `Host testremote\n HostName ${testEnv.host}\n Port ${testEnv.port}\n User ${testEnv.user}\n`; } const sshDir = join(homedir(), ".ssh"); @@ -41,144 +41,144 @@ const sshDir = join(homedir(), ".ssh"); * returns itself so the type is complete (the integration test logs nothing). */ function noopLogger(): Logger { - const log: Logger = { - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined, - child: () => log, - span: (name: string) => ({ - id: name, - log, - setAttributes: () => undefined, - addLink: () => undefined, - child: (n: string) => - ({ - id: n, - log, - setAttributes: () => undefined, - addLink: () => undefined, - child: () => ({ id: n, log }) as never, - end: () => undefined, - }) as never, - end: () => undefined, - }), - }; - return log; + const log: Logger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + child: () => log, + span: (name: string) => ({ + id: name, + log, + setAttributes: () => undefined, + addLink: () => undefined, + child: (n: string) => + ({ + id: n, + log, + setAttributes: () => undefined, + addLink: () => undefined, + child: () => ({ id: n, log }) as never, + end: () => undefined, + }) as never, + end: () => undefined, + }), + }; + return log; } function realDeps() { - return { - logger: noopLogger(), - homeDir: homedir(), - knownHostsPath: join(sshDir, "known_hosts"), - readFileText: (p: string) => readFile(p, "utf8"), - appendKnownHosts: async () => undefined, // don't mutate the real known_hosts in a test - pathExists: (p: string) => - access(p) - .then(() => true) - .catch(() => false), - newClient: () => new Client(), - resolveComputer: async (alias: string) => - resolveComputer(alias, { - configText: configText(), - knownHostsText: "", - defaultUser: USER, - homeDir: homedir(), - }), - }; + return { + logger: noopLogger(), + homeDir: homedir(), + knownHostsPath: join(sshDir, "known_hosts"), + readFileText: (p: string) => readFile(p, "utf8"), + appendKnownHosts: async () => undefined, // don't mutate the real known_hosts in a test + pathExists: (p: string) => + access(p) + .then(() => true) + .catch(() => false), + newClient: () => new Client(), + resolveComputer: async (alias: string) => + resolveComputer(alias, { + configText: configText(), + knownHostsText: "", + defaultUser: USER, + homeDir: homedir(), + }), + }; } describe.skipIf(testEnv === null)("SshExecBackend against a real sshd", () => { - let pool: ReturnType; - let tmpRemoteDir: string; - - beforeEach(async () => { - pool = createSshConnectionPool(realDeps()); - // Create a remote temp dir to run cwd-scoped commands in. - tmpRemoteDir = await mkdtemp(join(tmpdir(), "ssh-int-")); - }); - - afterEach(async () => { - await pool.closeAll(); - // best-effort cleanup of the remote temp dir. - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - try { - await backend.spawn({ - command: `rm -rf ${tmpRemoteDir}`, - cwd: "/", - signal: new AbortController().signal, - timeout: 5000, - onOutput: () => undefined, - }); - } catch { - // ignore - } - }); - - it("connects + execs a command, returning stdout + exit code", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - let stdout = ""; - const res = await backend.spawn({ - command: "echo integration_ok; exit 7", - cwd: tmpRemoteDir, - signal: new AbortController().signal, - timeout: 10000, - onOutput: (data, stream) => { - if (stream === "stdout") stdout += data; - }, - }); - expect(stdout.trim()).toBe("integration_ok"); - expect(res.exitCode).toBe(7); - expect(res.timedOut).toBe(false); - expect(res.aborted).toBe(false); - }); - - it("writes a file over SFTP then reads it back", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - const path = join(tmpRemoteDir, "sftp-probe.txt"); - await backend.writeFile(path, "hello-sftp"); - const content = await backend.readFile(path); - expect(content).toBe("hello-sftp"); - }); - - it("stat reports isFile/isDirectory correctly", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - const path = join(tmpRemoteDir, "stat-probe.txt").replace(/\\/g, "/"); - await backend.writeFile(path, "x"); - const s = await backend.stat(path); - expect(s.isFile).toBe(true); - expect(s.isDirectory).toBe(false); - // A directory stat reports the inverse. - const dirStat = await backend.stat(tmpRemoteDir); - expect(dirStat.isDirectory).toBe(true); - expect(dirStat.isFile).toBe(false); - }); - - it("readdir lists entries with isDirectory flags", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - await backend.writeFile(join(tmpRemoteDir, "a.txt").replace(/\\/g, "/"), "a"); - await mkdir(join(tmpRemoteDir, "subdir").replace(/\\/g, "/")).catch(() => undefined); - const entries = await backend.readdir(tmpRemoteDir); - const names = entries.map((e) => e.name); - expect(names).toContain("a.txt"); - expect(entries.find((e) => e.name === "a.txt")?.isDirectory).toBe(false); - }); - - it("readFile on a missing path throws an ENOENT .code error", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - await expect( - backend.readFile(join(tmpRemoteDir, "nope.txt").replace(/\\/g, "/")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("exists returns false for a missing path and true for an existing one", async () => { - const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); - const path = join(tmpRemoteDir, "exists-probe.txt").replace(/\\/g, "/"); - await backend.writeFile(path, "x"); - expect(await backend.exists(path)).toBe(true); - expect(await backend.exists(join(tmpRemoteDir, "missing.txt").replace(/\\/g, "/"))).toBe(false); - }); + let pool: ReturnType; + let tmpRemoteDir: string; + + beforeEach(async () => { + pool = createSshConnectionPool(realDeps()); + // Create a remote temp dir to run cwd-scoped commands in. + tmpRemoteDir = await mkdtemp(join(tmpdir(), "ssh-int-")); + }); + + afterEach(async () => { + await pool.closeAll(); + // best-effort cleanup of the remote temp dir. + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + try { + await backend.spawn({ + command: `rm -rf ${tmpRemoteDir}`, + cwd: "/", + signal: new AbortController().signal, + timeout: 5000, + onOutput: () => undefined, + }); + } catch { + // ignore + } + }); + + it("connects + execs a command, returning stdout + exit code", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + let stdout = ""; + const res = await backend.spawn({ + command: "echo integration_ok; exit 7", + cwd: tmpRemoteDir, + signal: new AbortController().signal, + timeout: 10000, + onOutput: (data, stream) => { + if (stream === "stdout") stdout += data; + }, + }); + expect(stdout.trim()).toBe("integration_ok"); + expect(res.exitCode).toBe(7); + expect(res.timedOut).toBe(false); + expect(res.aborted).toBe(false); + }); + + it("writes a file over SFTP then reads it back", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + const path = join(tmpRemoteDir, "sftp-probe.txt"); + await backend.writeFile(path, "hello-sftp"); + const content = await backend.readFile(path); + expect(content).toBe("hello-sftp"); + }); + + it("stat reports isFile/isDirectory correctly", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + const path = join(tmpRemoteDir, "stat-probe.txt").replace(/\\/g, "/"); + await backend.writeFile(path, "x"); + const s = await backend.stat(path); + expect(s.isFile).toBe(true); + expect(s.isDirectory).toBe(false); + // A directory stat reports the inverse. + const dirStat = await backend.stat(tmpRemoteDir); + expect(dirStat.isDirectory).toBe(true); + expect(dirStat.isFile).toBe(false); + }); + + it("readdir lists entries with isDirectory flags", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + await backend.writeFile(join(tmpRemoteDir, "a.txt").replace(/\\/g, "/"), "a"); + await mkdir(join(tmpRemoteDir, "subdir").replace(/\\/g, "/")).catch(() => undefined); + const entries = await backend.readdir(tmpRemoteDir); + const names = entries.map((e) => e.name); + expect(names).toContain("a.txt"); + expect(entries.find((e) => e.name === "a.txt")?.isDirectory).toBe(false); + }); + + it("readFile on a missing path throws an ENOENT .code error", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + await expect( + backend.readFile(join(tmpRemoteDir, "nope.txt").replace(/\\/g, "/")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("exists returns false for a missing path and true for an existing one", async () => { + const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a)); + const path = join(tmpRemoteDir, "exists-probe.txt").replace(/\\/g, "/"); + await backend.writeFile(path, "x"); + expect(await backend.exists(path)).toBe(true); + expect(await backend.exists(join(tmpRemoteDir, "missing.txt").replace(/\\/g, "/"))).toBe(false); + }); }); diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts index 5b380d8..9acae0e 100644 --- a/packages/ssh/src/pool.ts +++ b/packages/ssh/src/pool.ts @@ -37,12 +37,12 @@ export type SshConnectionState = "disconnected" | "connecting" | "connected" | " * per computer — the transparency + perf win over spawning `ssh` per call). */ export interface SshConnection { - readonly getClient: () => Promise; - readonly getSftp: () => Promise; - readonly close: () => Promise; - readonly state: SshConnectionState; - /** Last error message when `state === "error"`; `undefined` otherwise. */ - readonly error: string | undefined; + readonly getClient: () => Promise; + readonly getSftp: () => Promise; + readonly close: () => Promise; + readonly state: SshConnectionState; + /** Last error message when `state === "error"`; `undefined` otherwise. */ + readonly error: string | undefined; } /** @@ -50,44 +50,44 @@ export interface SshConnection { * sshd (or fixture files) — never at a `@dispatch/*` mock. */ export interface SshPoolDeps { - readonly logger: Logger; - /** Read a file as utf8 text (key files, known_hosts, ssh config). */ - readonly readFileText: (path: string) => Promise; - /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */ - readonly appendKnownHosts: (path: string, line: string) => Promise; - /** Check a path exists (for default-identity-file probing). */ - readonly pathExists: (path: string) => Promise; - /** Factory for a fresh ssh2 Client (the real edge). */ - readonly newClient: () => Client; - /** Resolve a computer alias → its `Computer` (connection params). */ - readonly resolveComputer: (alias: string) => Promise; - /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */ - readonly knownHostsPath: string; - /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */ - readonly homeDir: string; + readonly logger: Logger; + /** Read a file as utf8 text (key files, known_hosts, ssh config). */ + readonly readFileText: (path: string) => Promise; + /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */ + readonly appendKnownHosts: (path: string, line: string) => Promise; + /** Check a path exists (for default-identity-file probing). */ + readonly pathExists: (path: string) => Promise; + /** Factory for a fresh ssh2 Client (the real edge). */ + readonly newClient: () => Client; + /** Resolve a computer alias → its `Computer` (connection params). */ + readonly resolveComputer: (alias: string) => Promise; + /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */ + readonly knownHostsPath: string; + /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */ + readonly homeDir: string; } export interface SshConnectionPool { - readonly acquire: (computerId: string) => Promise; - readonly drop: (computerId: string) => Promise; - readonly closeAll: () => Promise; - readonly status: () => readonly SshPoolStatusEntry[]; + readonly acquire: (computerId: string) => Promise; + readonly drop: (computerId: string) => Promise; + readonly closeAll: () => Promise; + readonly status: () => readonly SshPoolStatusEntry[]; } export interface SshPoolStatusEntry { - readonly computerId: string; - readonly state: SshConnectionState; - readonly error?: string; + readonly computerId: string; + readonly state: SshConnectionState; + readonly error?: string; } interface PooledEntry { - readonly alias: string; - conn: SshConnection; - /** Wall-clock of the last `acquire`/use — for idle reaping. */ - lastUsedAt: number; - /** Pending connect (so concurrent first-acquires share one connect). */ - readonly pending: Promise | null; - reaper: ReturnType | null; + readonly alias: string; + conn: SshConnection; + /** Wall-clock of the last `acquire`/use — for idle reaping. */ + lastUsedAt: number; + /** Pending connect (so concurrent first-acquires share one connect). */ + readonly pending: Promise | null; + reaper: ReturnType | null; } /** @@ -96,127 +96,127 @@ interface PooledEntry { * `ComputerService` status/test routes. */ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { - const entries = new Map(); - - async function buildConnection(alias: string): Promise { - const computer = await deps.resolveComputer(alias); - if (computer === null) { - throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`); - } - - const state: { value: SshConnectionState; error: string | undefined } = { - value: "disconnected", - error: undefined, - }; - const client = deps.newClient(); - let sftp: import("ssh2").SFTPWrapper | null = null; - let connectPromise: Promise | null = null; - - const touch = (): void => { - const e = entries.get(alias); - if (e !== undefined) e.lastUsedAt = Date.now(); - }; - - const connect = (): Promise => { - if (state.value === "connected") return Promise.resolve(); - if (connectPromise !== null) return connectPromise; // share one connect - state.value = "connecting"; - connectPromise = doConnect(client, computer, deps, state) - .then(() => { - state.value = "connected"; - state.error = undefined; - // Stale pins → re-evaluate on each connect via hostVerifier already. - }) - .catch((err: unknown) => { - state.value = "error"; - state.error = err instanceof Error ? err.message : String(err); - connectPromise = null; // allow retry on next acquire - throw err; - }); - return connectPromise; - }; - - const conn: SshConnection = { - get state() { - return state.value; - }, - get error() { - return state.error; - }, - async getClient() { - await connect(); - touch(); - return client; - }, - async getSftp() { - await connect(); - if (sftp === null) { - sftp = await openSftp(client); - } - touch(); - return sftp; - }, - async close() { - try { - sftp?.end(); - } catch { - // best-effort - } - try { - client.end(); - } catch { - // best-effort - } - sftp = null; - state.value = "disconnected"; - }, - }; - return conn; - } - - return { - async acquire(computerId: string): Promise { - let entry = entries.get(computerId); - if (entry === undefined) { - const conn = await buildConnection(computerId); - entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null }; - entries.set(computerId, entry); - startReaper(entries, computerId, deps); - } - // Eagerly verify connectivity (reconnect if the peer died/reaped). - await entry.conn.getClient().then( - () => undefined, - () => { - // getClient throws on a dead connection — drop + retry once. - }, - ); - entry.lastUsedAt = Date.now(); - return entry.conn; - }, - - async drop(computerId: string): Promise { - const entry = entries.get(computerId); - if (entry === undefined) return; - stopReaper(entry); - await entry.conn.close(); - entries.delete(computerId); - }, - - async closeAll(): Promise { - const all = [...entries.values()]; - for (const entry of all) stopReaper(entry); - await Promise.all(all.map((e) => e.conn.close())); - entries.clear(); - }, - - status(): readonly SshPoolStatusEntry[] { - return [...entries.values()].map((e) => ({ - computerId: e.alias, - state: e.conn.state, - ...(e.conn.error !== undefined ? { error: e.conn.error } : {}), - })); - }, - }; + const entries = new Map(); + + async function buildConnection(alias: string): Promise { + const computer = await deps.resolveComputer(alias); + if (computer === null) { + throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`); + } + + const state: { value: SshConnectionState; error: string | undefined } = { + value: "disconnected", + error: undefined, + }; + const client = deps.newClient(); + let sftp: import("ssh2").SFTPWrapper | null = null; + let connectPromise: Promise | null = null; + + const touch = (): void => { + const e = entries.get(alias); + if (e !== undefined) e.lastUsedAt = Date.now(); + }; + + const connect = (): Promise => { + if (state.value === "connected") return Promise.resolve(); + if (connectPromise !== null) return connectPromise; // share one connect + state.value = "connecting"; + connectPromise = doConnect(client, computer, deps, state) + .then(() => { + state.value = "connected"; + state.error = undefined; + // Stale pins → re-evaluate on each connect via hostVerifier already. + }) + .catch((err: unknown) => { + state.value = "error"; + state.error = err instanceof Error ? err.message : String(err); + connectPromise = null; // allow retry on next acquire + throw err; + }); + return connectPromise; + }; + + const conn: SshConnection = { + get state() { + return state.value; + }, + get error() { + return state.error; + }, + async getClient() { + await connect(); + touch(); + return client; + }, + async getSftp() { + await connect(); + if (sftp === null) { + sftp = await openSftp(client); + } + touch(); + return sftp; + }, + async close() { + try { + sftp?.end(); + } catch { + // best-effort + } + try { + client.end(); + } catch { + // best-effort + } + sftp = null; + state.value = "disconnected"; + }, + }; + return conn; + } + + return { + async acquire(computerId: string): Promise { + let entry = entries.get(computerId); + if (entry === undefined) { + const conn = await buildConnection(computerId); + entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null }; + entries.set(computerId, entry); + startReaper(entries, computerId, deps); + } + // Eagerly verify connectivity (reconnect if the peer died/reaped). + await entry.conn.getClient().then( + () => undefined, + () => { + // getClient throws on a dead connection — drop + retry once. + }, + ); + entry.lastUsedAt = Date.now(); + return entry.conn; + }, + + async drop(computerId: string): Promise { + const entry = entries.get(computerId); + if (entry === undefined) return; + stopReaper(entry); + await entry.conn.close(); + entries.delete(computerId); + }, + + async closeAll(): Promise { + const all = [...entries.values()]; + for (const entry of all) stopReaper(entry); + await Promise.all(all.map((e) => e.conn.close())); + entries.clear(); + }, + + status(): readonly SshPoolStatusEntry[] { + return [...entries.values()].map((e) => ({ + computerId: e.alias, + state: e.conn.state, + ...(e.conn.error !== undefined ? { error: e.conn.error } : {}), + })); + }, + }; } // ─── connect: auth + host-key ────────────────────────────────────────────── @@ -227,127 +227,127 @@ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { * or connect timeout (never silently connects — plan §4.4/§8). */ async function doConnect( - client: Client, - computer: Computer, - deps: SshPoolDeps, - state: { value: SshConnectionState; error: string | undefined }, + client: Client, + computer: Computer, + deps: SshPoolDeps, + state: { value: SshConnectionState; error: string | undefined }, ): Promise { - const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps); - if (passphraseError !== null) throw new Error(passphraseError); - - // Read known_hosts once for the host-key decision (present/absent + verify). - let knownHostsText = ""; - try { - knownHostsText = await deps.readFileText(deps.knownHostsPath); - } catch { - // Missing known_hosts → treat as empty (first connect pins the first line). - knownHostsText = ""; - } - const token = knownHostToken(computer.hostName, computer.port); - const decisionArmed = { decided: false }; - - await new Promise((resolve, reject) => { - const onReady = (): void => { - cleanup(); - resolve(); - }; - const onError = (err: Error): void => { - cleanup(); - reject(err); - }; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`)); - }, CONNECT_TIMEOUT_MS); - - function cleanup(): void { - clearTimeout(timer); - client.removeListener("ready", onReady); - client.removeListener("error", onError); - } - - client.on("ready", onReady); - client.on("error", onError); - - const connectConfig: ConnectConfig = { - host: computer.hostName, - port: computer.port, - username: computer.user, - privateKey, - keepaliveInterval: KEEPALIVE_INTERVAL, - keepaliveCountMax: KEEPALIVE_COUNT_MAX, - readyTimeout: CONNECT_TIMEOUT_MS, - // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces - // the key passed to `hostVerifier` with a hash digest, which would break - // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd - // field is the base64 of the raw public-key blob). We compare the raw - // blob directly, exactly as OpenSSH records it (decision #2 — the file - // is the shared trust store, so the comparison must be byte-identical). - hostVerifier: (key: Buffer | string): boolean => { - if (decisionArmed.decided) return true; // already accepted this handshake - const fingerprint = toFingerprint(token, key); - const decision = decideHostKey(knownHostsText, fingerprint); - decisionArmed.decided = true; - if (!decision.accept) { - state.error = decision.reason; - // Reject the handshake; the emitted 'error' → onError (reject). - process.nextTick(() => client.emit("error", new Error(decision.reason))); - return false; - } - // Accept. Pin on first connect (append is async + best-effort — - // the connection proceeds; a failed append only means the next - // connect re-pins). - if (decision.append !== undefined) { - void deps - .appendKnownHosts(deps.knownHostsPath, decision.append) - .then(() => { - deps.logger.info("pinned host key", { alias: computer.alias, token }); - }) - .catch((e: unknown) => { - deps.logger.warn("failed to pin host key", { - alias: computer.alias, - error: e instanceof Error ? e.message : String(e), - }); - }); - } - return true; - }, - }; - - client.connect(connectConfig); - }); + const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps); + if (passphraseError !== null) throw new Error(passphraseError); + + // Read known_hosts once for the host-key decision (present/absent + verify). + let knownHostsText = ""; + try { + knownHostsText = await deps.readFileText(deps.knownHostsPath); + } catch { + // Missing known_hosts → treat as empty (first connect pins the first line). + knownHostsText = ""; + } + const token = knownHostToken(computer.hostName, computer.port); + const decisionArmed = { decided: false }; + + await new Promise((resolve, reject) => { + const onReady = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`)); + }, CONNECT_TIMEOUT_MS); + + function cleanup(): void { + clearTimeout(timer); + client.removeListener("ready", onReady); + client.removeListener("error", onError); + } + + client.on("ready", onReady); + client.on("error", onError); + + const connectConfig: ConnectConfig = { + host: computer.hostName, + port: computer.port, + username: computer.user, + privateKey, + keepaliveInterval: KEEPALIVE_INTERVAL, + keepaliveCountMax: KEEPALIVE_COUNT_MAX, + readyTimeout: CONNECT_TIMEOUT_MS, + // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces + // the key passed to `hostVerifier` with a hash digest, which would break + // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd + // field is the base64 of the raw public-key blob). We compare the raw + // blob directly, exactly as OpenSSH records it (decision #2 — the file + // is the shared trust store, so the comparison must be byte-identical). + hostVerifier: (key: Buffer | string): boolean => { + if (decisionArmed.decided) return true; // already accepted this handshake + const fingerprint = toFingerprint(token, key); + const decision = decideHostKey(knownHostsText, fingerprint); + decisionArmed.decided = true; + if (!decision.accept) { + state.error = decision.reason; + // Reject the handshake; the emitted 'error' → onError (reject). + process.nextTick(() => client.emit("error", new Error(decision.reason))); + return false; + } + // Accept. Pin on first connect (append is async + best-effort — + // the connection proceeds; a failed append only means the next + // connect re-pins). + if (decision.append !== undefined) { + void deps + .appendKnownHosts(deps.knownHostsPath, decision.append) + .then(() => { + deps.logger.info("pinned host key", { alias: computer.alias, token }); + }) + .catch((e: unknown) => { + deps.logger.warn("failed to pin host key", { + alias: computer.alias, + error: e instanceof Error ? e.message : String(e), + }); + }); + } + return true; + }, + }; + + client.connect(connectConfig); + }); } /** Resolve the private key bytes for a computer (key-only auth, decision #3). */ async function resolvePrivateKey( - computer: Computer, - deps: SshPoolDeps, + computer: Computer, + deps: SshPoolDeps, ): Promise<{ privateKey: Buffer; passphraseError: string | null }> { - const candidates = await identityCandidates(computer, deps); - for (const path of candidates) { - try { - const text = await deps.readFileText(path); - if (looksEncrypted(text)) { - // MVP: no passphrase prompt (roadmap). Fail with a clear error. - return { - privateKey: Buffer.from(text), - passphraseError: - `SSH key "${path}" is encrypted — passphrase prompting is not ` + - `supported in the MVP (use an unencrypted key for computer ` + - `"${computer.alias}", or set IdentityFile to an unencrypted key).`, - }; - } - return { privateKey: Buffer.from(text), passphraseError: null }; - } catch { - // missing/unreadable → try the next candidate - } - } - return { - privateKey: Buffer.alloc(0), - passphraseError: - `no readable SSH key for computer "${computer.alias}" ` + - `(checked: ${candidates.join(", ")})`, - }; + const candidates = await identityCandidates(computer, deps); + for (const path of candidates) { + try { + const text = await deps.readFileText(path); + if (looksEncrypted(text)) { + // MVP: no passphrase prompt (roadmap). Fail with a clear error. + return { + privateKey: Buffer.from(text), + passphraseError: + `SSH key "${path}" is encrypted — passphrase prompting is not ` + + `supported in the MVP (use an unencrypted key for computer ` + + `"${computer.alias}", or set IdentityFile to an unencrypted key).`, + }; + } + return { privateKey: Buffer.from(text), passphraseError: null }; + } catch { + // missing/unreadable → try the next candidate + } + } + return { + privateKey: Buffer.alloc(0), + passphraseError: + `no readable SSH key for computer "${computer.alias}" ` + + `(checked: ${candidates.join(", ")})`, + }; } /** @@ -356,39 +356,39 @@ async function resolvePrivateKey( * `~/.ssh/id_rsa`, first-existing-wins — matches OpenSSH's own probing). */ async function identityCandidates(computer: Computer, deps: SshPoolDeps): Promise { - const candidates: string[] = []; - if (computer.identityFile !== null) candidates.push(computer.identityFile); - for (const name of DEFAULT_IDENTITY_FILES) { - candidates.push(`${deps.homeDir}/.ssh/${name}`); - } - // De-dup + filter to existing, preserving order. - const existing: string[] = []; - const seen = new Set(); - for (const c of candidates) { - if (seen.has(c)) continue; - seen.add(c); - if (await deps.pathExists(c)) existing.push(c); - } - if (existing.length > 0) return existing; - // Fall back to the raw candidate list (so resolvePrivateKey reports it). - return [...new Set(candidates)]; + const candidates: string[] = []; + if (computer.identityFile !== null) candidates.push(computer.identityFile); + for (const name of DEFAULT_IDENTITY_FILES) { + candidates.push(`${deps.homeDir}/.ssh/${name}`); + } + // De-dup + filter to existing, preserving order. + const existing: string[] = []; + const seen = new Set(); + for (const c of candidates) { + if (seen.has(c)) continue; + seen.add(c); + if (await deps.pathExists(c)) existing.push(c); + } + if (existing.length > 0) return existing; + // Fall back to the raw candidate list (so resolvePrivateKey reports it). + return [...new Set(candidates)]; } const DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_rsa"]; /** OpenSSH encrypts keys with a `ENCRYPTED` header — detect it (no passphrase MVP). */ function looksEncrypted(keyText: string): boolean { - return keyText.includes("ENCRYPTED"); + return keyText.includes("ENCRYPTED"); } /** Open an SFTP session on a connected client (promisified). */ function openSftp(client: Client): Promise { - return new Promise((resolve, reject) => { - client.sftp((err, sftp) => { - if (err !== null && err !== undefined) reject(err); - else resolve(sftp); - }); - }); + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err !== null && err !== undefined) reject(err); + else resolve(sftp); + }); + }); } // ─── host-key fingerprint → pure decision input ──────────────────────────── @@ -403,12 +403,12 @@ function openSftp(client: Client): Promise { * itself writes — the file is the shared trust store (decision #2). */ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint { - const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key; - return { - knownHostToken: token, - keyBase64: buf.toString("base64"), - keyType: parseKeyType(buf), - }; + const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key; + return { + knownHostToken: token, + keyBase64: buf.toString("base64"), + keyType: parseKeyType(buf), + }; } /** @@ -418,40 +418,40 @@ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint * is the authoritative identity for `decideHostKey`'s comparison. */ function parseKeyType(buf: Buffer): string { - if (buf.length < 4) return "ssh-ed25519"; - const len = buf.readUInt32BE(0); - if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519"; - return buf.subarray(4, 4 + len).toString("ascii"); + if (buf.length < 4) return "ssh-ed25519"; + const len = buf.readUInt32BE(0); + if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519"; + return buf.subarray(4, 4 + len).toString("ascii"); } // ─── idle reaping ─────────────────────────────────────────────────────────── function startReaper( - entries: Map, - computerId: string, - deps: SshPoolDeps, + entries: Map, + computerId: string, + deps: SshPoolDeps, ): void { - const entry = entries.get(computerId); - if (entry === undefined) return; - entry.reaper = setInterval(() => { - const e = entries.get(computerId); - if (e === undefined) return; - const idle = Date.now() - e.lastUsedAt; - if (idle >= IDLE_REAP_MS) { - deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle }); - void e.conn.close().then(() => { - stopReaper(e); - entries.delete(computerId); - }); - } - }, 60_000); + const entry = entries.get(computerId); + if (entry === undefined) return; + entry.reaper = setInterval(() => { + const e = entries.get(computerId); + if (e === undefined) return; + const idle = Date.now() - e.lastUsedAt; + if (idle >= IDLE_REAP_MS) { + deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle }); + void e.conn.close().then(() => { + stopReaper(e); + entries.delete(computerId); + }); + } + }, 60_000); } function stopReaper(entry: PooledEntry): void { - if (entry.reaper !== null) { - clearInterval(entry.reaper); - entry.reaper = null; - } + if (entry.reaper !== null) { + clearInterval(entry.reaper); + entry.reaper = null; + } } /** Ssh2 exec stream type alias (the channel backing spawn). */ diff --git a/packages/ssh/src/service.ts b/packages/ssh/src/service.ts index cebe2ef..c47a81e 100644 --- a/packages/ssh/src/service.ts +++ b/packages/ssh/src/service.ts @@ -30,132 +30,132 @@ import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } fro * same real edges against a real sshd. */ export interface SshServiceDeps extends SshPoolDeps { - readonly logger: Logger; - /** Read `~/.ssh/config` text (the source of truth — decision #4). */ - readonly readConfigText: () => Promise; - /** The current OS user (fallback when the config sets no `User`). */ - readonly defaultUser: string; - /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */ - readonly homeDir: string; - /** - * Optional: alias → usage count (conversations/workspaces referencing it). - * host-bin wires this from conversation-store; absent → every count is 0. - */ - readonly getUsageCounts?: () => Promise>; - /** - * Optional: glob patterns to exclude from the computer catalog (e.g. - * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`. - * Absent → no filtering. - */ - readonly readRejectPatterns?: () => Promise; + readonly logger: Logger; + /** Read `~/.ssh/config` text (the source of truth — decision #4). */ + readonly readConfigText: () => Promise; + /** The current OS user (fallback when the config sets no `User`). */ + readonly defaultUser: string; + /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */ + readonly homeDir: string; + /** + * Optional: alias → usage count (conversations/workspaces referencing it). + * host-bin wires this from conversation-store; absent → every count is 0. + */ + readonly getUsageCounts?: () => Promise>; + /** + * Optional: glob patterns to exclude from the computer catalog (e.g. + * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`. + * Absent → no filtering. + */ + readonly readRejectPatterns?: () => Promise; } /** Build the `ComputerService` + the remote-`ExecBackend` factory. */ export function createSshService(deps: SshServiceDeps): { - readonly service: ComputerService; - readonly pool: SshConnectionPool; - /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */ - readonly remoteFactory: (computerId: string) => ExecBackend; + readonly service: ComputerService; + readonly pool: SshConnectionPool; + /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */ + readonly remoteFactory: (computerId: string) => ExecBackend; } { - const pool = createSshConnectionPool(deps); - - async function readEnv(): Promise { - const [configText, knownHostsText, rejectPatterns] = await Promise.all([ - deps.readConfigText().catch(async () => ""), - deps.readFileText(deps.knownHostsPath).catch(async () => ""), - deps.readRejectPatterns !== undefined - ? deps.readRejectPatterns().catch(async () => [] as readonly string[]) - : Promise.resolve([] as readonly string[]), - ]); - const base: SshConfigResolveEnv = { - configText, - knownHostsText, - defaultUser: deps.defaultUser, - homeDir: deps.homeDir, - }; - return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base; - } - - const service: ComputerService = { - async listComputers(): Promise { - const env = await readEnv(); - const computers = resolveComputers(env); - const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map(); - return computers.map( - (c): ComputerEntry => ({ - ...c, - usageCount: counts.get(c.alias) ?? 0, - }), - ); - }, - - async getComputer(alias: string): Promise { - const env = await readEnv(); - return resolveComputer(alias, env); - }, - - async getStatus(alias: string): Promise { - const env = await readEnv(); - const computer = resolveComputer(alias, env); - if (computer === null) { - return { - alias, - state: "disconnected", - knownHost: false, - }; - } - // Surface the pool's live state for this alias (disconnected if never - // acquired; connecting/connected/error once a connect is attempted). - const entry = pool.status().find((s) => s.computerId === alias); - if (entry === undefined) { - return { alias, state: "disconnected", knownHost: computer.knownHost }; - } - if (entry.error !== undefined) { - return { alias, state: "error", error: entry.error, knownHost: computer.knownHost }; - } - return { alias, state: entry.state, knownHost: computer.knownHost }; - }, - - async test(alias: string): Promise { - const env = await readEnv(); - const computer = resolveComputer(alias, env); - if (computer === null) { - return { alias, ok: false, error: `unknown computer alias "${alias}"` }; - } - // One-shot probe: acquire (connects), run a trivial command, then drop - // the connection so a test never holds a pooled socket open (plan §9.1). - // Wrapped in a timeout safety net so the endpoint ALWAYS responds — - // even if the SSH connect/exec/drop hangs (the probe is non-interactive). - const PROBE_TOTAL_TIMEOUT_MS = 30_000; - try { - const result = await Promise.race([ - runTestProbe(pool, alias, deps.logger), - timeoutAfter( - PROBE_TOTAL_TIMEOUT_MS, - `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`, - ), - ]); - return result; - } catch (err: unknown) { - await pool.drop(alias).catch(() => undefined); - const message = err instanceof Error ? err.message : String(err); - deps.logger.warn("computer test failed", { alias, error: message }); - return { alias, ok: false, error: message }; - } - }, - }; - - /** - * The factory exec-backend consumes: given a computerId (alias), return a - * remote `ExecBackend`. The backend acquires lazily — merely building it - * (in the resolver) opens NO connection; the first method call connects. - * Only the alias is captured; the pool re-resolves connection params from - * `~/.ssh/config` at connect time, so no stale snapshot is held here. - */ - const remoteFactory = (computerId: string): ExecBackend => - createSshExecBackend(computerId, async (alias) => pool.acquire(alias)); - - return { service, pool, remoteFactory }; + const pool = createSshConnectionPool(deps); + + async function readEnv(): Promise { + const [configText, knownHostsText, rejectPatterns] = await Promise.all([ + deps.readConfigText().catch(async () => ""), + deps.readFileText(deps.knownHostsPath).catch(async () => ""), + deps.readRejectPatterns !== undefined + ? deps.readRejectPatterns().catch(async () => [] as readonly string[]) + : Promise.resolve([] as readonly string[]), + ]); + const base: SshConfigResolveEnv = { + configText, + knownHostsText, + defaultUser: deps.defaultUser, + homeDir: deps.homeDir, + }; + return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base; + } + + const service: ComputerService = { + async listComputers(): Promise { + const env = await readEnv(); + const computers = resolveComputers(env); + const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map(); + return computers.map( + (c): ComputerEntry => ({ + ...c, + usageCount: counts.get(c.alias) ?? 0, + }), + ); + }, + + async getComputer(alias: string): Promise { + const env = await readEnv(); + return resolveComputer(alias, env); + }, + + async getStatus(alias: string): Promise { + const env = await readEnv(); + const computer = resolveComputer(alias, env); + if (computer === null) { + return { + alias, + state: "disconnected", + knownHost: false, + }; + } + // Surface the pool's live state for this alias (disconnected if never + // acquired; connecting/connected/error once a connect is attempted). + const entry = pool.status().find((s) => s.computerId === alias); + if (entry === undefined) { + return { alias, state: "disconnected", knownHost: computer.knownHost }; + } + if (entry.error !== undefined) { + return { alias, state: "error", error: entry.error, knownHost: computer.knownHost }; + } + return { alias, state: entry.state, knownHost: computer.knownHost }; + }, + + async test(alias: string): Promise { + const env = await readEnv(); + const computer = resolveComputer(alias, env); + if (computer === null) { + return { alias, ok: false, error: `unknown computer alias "${alias}"` }; + } + // One-shot probe: acquire (connects), run a trivial command, then drop + // the connection so a test never holds a pooled socket open (plan §9.1). + // Wrapped in a timeout safety net so the endpoint ALWAYS responds — + // even if the SSH connect/exec/drop hangs (the probe is non-interactive). + const PROBE_TOTAL_TIMEOUT_MS = 30_000; + try { + const result = await Promise.race([ + runTestProbe(pool, alias, deps.logger), + timeoutAfter( + PROBE_TOTAL_TIMEOUT_MS, + `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`, + ), + ]); + return result; + } catch (err: unknown) { + await pool.drop(alias).catch(() => undefined); + const message = err instanceof Error ? err.message : String(err); + deps.logger.warn("computer test failed", { alias, error: message }); + return { alias, ok: false, error: message }; + } + }, + }; + + /** + * The factory exec-backend consumes: given a computerId (alias), return a + * remote `ExecBackend`. The backend acquires lazily — merely building it + * (in the resolver) opens NO connection; the first method call connects. + * Only the alias is captured; the pool re-resolves connection params from + * `~/.ssh/config` at connect time, so no stale snapshot is held here. + */ + const remoteFactory = (computerId: string): ExecBackend => + createSshExecBackend(computerId, async (alias) => pool.acquire(alias)); + + return { service, pool, remoteFactory }; } /** @@ -163,27 +163,27 @@ export function createSshService(deps: SshServiceDeps): { * be raced against a timeout. Always drops the connection (even on success). */ async function runTestProbe( - pool: SshConnectionPool, - alias: string, - logger: Logger, + pool: SshConnectionPool, + alias: string, + logger: Logger, ): Promise { - const conn = await pool.acquire(alias); - const client = await conn.getClient(); - const ok = await runProbe(client); - if (ok) { - logger.info("computer test ok", { alias }); - } - await pool.drop(alias); - return ok - ? { alias, ok: true } - : { alias, ok: false, error: "remote command returned no exit code" }; + const conn = await pool.acquire(alias); + const client = await conn.getClient(); + const ok = await runProbe(client); + if (ok) { + logger.info("computer test ok", { alias }); + } + await pool.drop(alias); + return ok + ? { alias, ok: true } + : { alias, ok: false, error: "remote command returned no exit code" }; } /** Reject with `message` after `ms`. Used to race against a hanging probe. */ function timeoutAfter(ms: number, message: string): Promise { - return new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error(message)), ms); - }); + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); } /** Probe timeout — the `true` command exits instantly; 15s is generous. */ @@ -198,37 +198,37 @@ const PROBE_TIMEOUT_MS = 15_000; * the `exit` event never fires (e.g. the server requires a pty for exec). */ function runProbe(client: import("ssh2").Client): Promise { - return new Promise((resolve) => { - let settled = false; - const done = (result: boolean): void => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(result); - } - }; - - const timer = setTimeout(() => { - done(false); - }, PROBE_TIMEOUT_MS); - - client.exec("true", { pty: false }, (err, stream) => { - if (err !== null && err !== undefined) { - done(false); - return; - } - // Resolve on `exit` — the command has finished. Don't wait for - // `close` (some servers never emit it for exec channels). - stream.on("exit", (code: number | null) => { - done(code === 0); - }); - // Safety net: if `exit` never fires, `close` might. - stream.on("close", () => { - done(false); - }); - // Drain any output so the stream doesn't deadlock. - stream.on("data", () => {}); - stream.stderr.on("data", () => {}); - }); - }); + return new Promise((resolve) => { + let settled = false; + const done = (result: boolean): void => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(result); + } + }; + + const timer = setTimeout(() => { + done(false); + }, PROBE_TIMEOUT_MS); + + client.exec("true", { pty: false }, (err, stream) => { + if (err !== null && err !== undefined) { + done(false); + return; + } + // Resolve on `exit` — the command has finished. Don't wait for + // `close` (some servers never emit it for exec channels). + stream.on("exit", (code: number | null) => { + done(code === 0); + }); + // Safety net: if `exit` never fires, `close` might. + stream.on("close", () => { + done(false); + }); + // Drain any output so the stream doesn't deadlock. + stream.on("data", () => {}); + stream.stderr.on("data", () => {}); + }); + }); } diff --git a/packages/ssh/tsconfig.json b/packages/ssh/tsconfig.json index 79e6972..741bef7 100644 --- a/packages/ssh/tsconfig.json +++ b/packages/ssh/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../exec-backend" }, - { "path": "../kernel" }, - { "path": "../transport-contract" }, - { "path": "../transport-http" }, - { "path": "../wire" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../exec-backend" }, + { "path": "../kernel" }, + { "path": "../transport-contract" }, + { "path": "../transport-http" }, + { "path": "../wire" } + ] } diff --git a/packages/storage-sqlite/package.json b/packages/storage-sqlite/package.json index 1f60e0a..deb75a2 100644 --- a/packages/storage-sqlite/package.json +++ b/packages/storage-sqlite/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/storage-sqlite", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/storage-sqlite", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/storage-sqlite/src/extension.ts b/packages/storage-sqlite/src/extension.ts index 32297e5..cbf2ec0 100644 --- a/packages/storage-sqlite/src/extension.ts +++ b/packages/storage-sqlite/src/extension.ts @@ -1,17 +1,17 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; export const manifest: Manifest = { - id: "storage-sqlite", - name: "SQLite Storage Backend", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - capabilities: { db: true }, - activation: "eager", + id: "storage-sqlite", + name: "SQLite Storage Backend", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + capabilities: { db: true }, + activation: "eager", }; export const extension: Extension = { - manifest, - // No-op: the SQLite backend is a kernel bootstrap dep injected via HostDeps.storageFactory, not a bus service. - activate: async (_host: HostAPI) => {}, + manifest, + // No-op: the SQLite backend is a kernel bootstrap dep injected via HostDeps.storageFactory, not a bus service. + activate: async (_host: HostAPI) => {}, }; diff --git a/packages/storage-sqlite/src/migrate.ts b/packages/storage-sqlite/src/migrate.ts index bdd32b8..43f418d 100644 --- a/packages/storage-sqlite/src/migrate.ts +++ b/packages/storage-sqlite/src/migrate.ts @@ -1,14 +1,14 @@ export interface Migration { - readonly version: number; - readonly name: string; - readonly up: string; + readonly version: number; + readonly name: string; + readonly up: string; } export function computePending( - appliedVersions: ReadonlySet, - migrations: readonly Migration[], + appliedVersions: ReadonlySet, + migrations: readonly Migration[], ): readonly Migration[] { - return migrations - .filter((m) => !appliedVersions.has(m.version)) - .sort((a, b) => a.version - b.version); + return migrations + .filter((m) => !appliedVersions.has(m.version)) + .sort((a, b) => a.version - b.version); } diff --git a/packages/storage-sqlite/src/storage.test.ts b/packages/storage-sqlite/src/storage.test.ts index fc14498..bfadc67 100644 --- a/packages/storage-sqlite/src/storage.test.ts +++ b/packages/storage-sqlite/src/storage.test.ts @@ -5,214 +5,214 @@ import type { SqliteStorageBackend } from "./storage.js"; import { createSqliteStorage } from "./storage.js"; describe("computePending (pure)", () => { - it("returns all migrations when none applied", () => { - const migrations: Migration[] = [ - { version: 2, name: "b", up: "" }, - { version: 1, name: "a", up: "" }, - ]; - const result = computePending(new Set(), migrations); - expect(result.map((m) => m.version)).toEqual([1, 2]); - }); - - it("skips already-applied versions", () => { - const migrations: Migration[] = [ - { version: 1, name: "a", up: "" }, - { version: 2, name: "b", up: "" }, - { version: 3, name: "c", up: "" }, - ]; - const result = computePending(new Set([1, 3]), migrations); - expect(result.map((m) => m.version)).toEqual([2]); - }); - - it("returns empty when all applied", () => { - const migrations: Migration[] = [{ version: 1, name: "a", up: "" }]; - const result = computePending(new Set([1]), migrations); - expect(result).toEqual([]); - }); - - it("sorts by version ascending", () => { - const migrations: Migration[] = [ - { version: 3, name: "c", up: "" }, - { version: 1, name: "a", up: "" }, - { version: 2, name: "b", up: "" }, - ]; - const result = computePending(new Set(), migrations); - expect(result.map((m) => m.version)).toEqual([1, 2, 3]); - }); + it("returns all migrations when none applied", () => { + const migrations: Migration[] = [ + { version: 2, name: "b", up: "" }, + { version: 1, name: "a", up: "" }, + ]; + const result = computePending(new Set(), migrations); + expect(result.map((m) => m.version)).toEqual([1, 2]); + }); + + it("skips already-applied versions", () => { + const migrations: Migration[] = [ + { version: 1, name: "a", up: "" }, + { version: 2, name: "b", up: "" }, + { version: 3, name: "c", up: "" }, + ]; + const result = computePending(new Set([1, 3]), migrations); + expect(result.map((m) => m.version)).toEqual([2]); + }); + + it("returns empty when all applied", () => { + const migrations: Migration[] = [{ version: 1, name: "a", up: "" }]; + const result = computePending(new Set([1]), migrations); + expect(result).toEqual([]); + }); + + it("sorts by version ascending", () => { + const migrations: Migration[] = [ + { version: 3, name: "c", up: "" }, + { version: 1, name: "a", up: "" }, + { version: 2, name: "b", up: "" }, + ]; + const result = computePending(new Set(), migrations); + expect(result.map((m) => m.version)).toEqual([1, 2, 3]); + }); }); describe("createSqliteStorage", () => { - let backend: SqliteStorageBackend; - - beforeEach(() => { - backend = createSqliteStorage({ path: ":memory:" }); - }); - - afterEach(() => { - backend.close(); - }); - - describe("StorageNamespace get/set/delete/has", () => { - it("returns null for missing key", async () => { - const ns = backend.storage("test"); - expect(await ns.get("missing")).toBeNull(); - }); - - it("roundtrips set then get", async () => { - const ns = backend.storage("test"); - await ns.set("key1", "value1"); - expect(await ns.get("key1")).toBe("value1"); - }); - - it("overwrites existing value", async () => { - const ns = backend.storage("test"); - await ns.set("key1", "v1"); - await ns.set("key1", "v2"); - expect(await ns.get("key1")).toBe("v2"); - }); - - it("has returns false for missing, true for existing", async () => { - const ns = backend.storage("test"); - expect(await ns.has("key1")).toBe(false); - await ns.set("key1", "val"); - expect(await ns.has("key1")).toBe(true); - }); - - it("delete removes the key", async () => { - const ns = backend.storage("test"); - await ns.set("key1", "val"); - await ns.delete("key1"); - expect(await ns.get("key1")).toBeNull(); - expect(await ns.has("key1")).toBe(false); - }); - - it("delete on missing key is a no-op", async () => { - const ns = backend.storage("test"); - await ns.delete("nonexistent"); - }); - }); - - describe("keys", () => { - it("returns all keys in namespace", async () => { - const ns = backend.storage("test"); - await ns.set("a", "1"); - await ns.set("b", "2"); - await ns.set("c", "3"); - const keys = await ns.keys(); - expect(keys.toSorted()).toEqual(["a", "b", "c"]); - }); - - it("filters by prefix", async () => { - const ns = backend.storage("test"); - await ns.set("foo:1", "a"); - await ns.set("foo:2", "b"); - await ns.set("bar:1", "c"); - const keys = await ns.keys("foo"); - expect(keys.toSorted()).toEqual(["foo:1", "foo:2"]); - }); - - it("returns empty for no matches", async () => { - const ns = backend.storage("test"); - await ns.set("a", "1"); - expect(await ns.keys("zzz")).toEqual([]); - }); - }); - - describe("namespace isolation", () => { - it("same key in different namespaces does not collide", async () => { - const ns1 = backend.storage("ns1"); - const ns2 = backend.storage("ns2"); - await ns1.set("key", "value1"); - await ns2.set("key", "value2"); - expect(await ns1.get("key")).toBe("value1"); - expect(await ns2.get("key")).toBe("value2"); - }); - - it("delete in one namespace does not affect another", async () => { - const ns1 = backend.storage("ns1"); - const ns2 = backend.storage("ns2"); - await ns1.set("key", "v1"); - await ns2.set("key", "v2"); - await ns1.delete("key"); - expect(await ns1.has("key")).toBe(false); - expect(await ns2.get("key")).toBe("v2"); - }); - - it("keys are scoped to namespace", async () => { - const ns1 = backend.storage("ns1"); - const ns2 = backend.storage("ns2"); - await ns1.set("a", "1"); - await ns1.set("b", "2"); - await ns2.set("c", "3"); - expect((await ns1.keys()).toSorted()).toEqual(["a", "b"]); - expect(await ns2.keys()).toEqual(["c"]); - }); - }); - - describe("migrate", () => { - it("runs pending migrations in order", async () => { - const migrations: Migration[] = [ - { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, - { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, - ]; - await backend.migrate("ext1", migrations); - - const ns = backend.storage("ext1"); - await ns.set("test", "val"); - expect(await ns.get("test")).toBe("val"); - }); - - it("skips already-applied migrations", async () => { - const migrations: Migration[] = [ - { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, - ]; - await backend.migrate("ext1", migrations); - await backend.migrate("ext1", [ - ...migrations, - { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, - ]); - }); - - it("is idempotent — calling migrate twice with same migrations is safe", async () => { - const migrations: Migration[] = [ - { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, - ]; - await backend.migrate("ext1", migrations); - await backend.migrate("ext1", migrations); - }); - - it("migrations are per-namespace", async () => { - const m1: Migration[] = [ - { version: 1, name: "create_t1", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY);" }, - ]; - const m2: Migration[] = [ - { version: 1, name: "create_t2", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY);" }, - ]; - await backend.migrate("ext1", m1); - await backend.migrate("ext2", m2); - }); - }); + let backend: SqliteStorageBackend; + + beforeEach(() => { + backend = createSqliteStorage({ path: ":memory:" }); + }); + + afterEach(() => { + backend.close(); + }); + + describe("StorageNamespace get/set/delete/has", () => { + it("returns null for missing key", async () => { + const ns = backend.storage("test"); + expect(await ns.get("missing")).toBeNull(); + }); + + it("roundtrips set then get", async () => { + const ns = backend.storage("test"); + await ns.set("key1", "value1"); + expect(await ns.get("key1")).toBe("value1"); + }); + + it("overwrites existing value", async () => { + const ns = backend.storage("test"); + await ns.set("key1", "v1"); + await ns.set("key1", "v2"); + expect(await ns.get("key1")).toBe("v2"); + }); + + it("has returns false for missing, true for existing", async () => { + const ns = backend.storage("test"); + expect(await ns.has("key1")).toBe(false); + await ns.set("key1", "val"); + expect(await ns.has("key1")).toBe(true); + }); + + it("delete removes the key", async () => { + const ns = backend.storage("test"); + await ns.set("key1", "val"); + await ns.delete("key1"); + expect(await ns.get("key1")).toBeNull(); + expect(await ns.has("key1")).toBe(false); + }); + + it("delete on missing key is a no-op", async () => { + const ns = backend.storage("test"); + await ns.delete("nonexistent"); + }); + }); + + describe("keys", () => { + it("returns all keys in namespace", async () => { + const ns = backend.storage("test"); + await ns.set("a", "1"); + await ns.set("b", "2"); + await ns.set("c", "3"); + const keys = await ns.keys(); + expect(keys.toSorted()).toEqual(["a", "b", "c"]); + }); + + it("filters by prefix", async () => { + const ns = backend.storage("test"); + await ns.set("foo:1", "a"); + await ns.set("foo:2", "b"); + await ns.set("bar:1", "c"); + const keys = await ns.keys("foo"); + expect(keys.toSorted()).toEqual(["foo:1", "foo:2"]); + }); + + it("returns empty for no matches", async () => { + const ns = backend.storage("test"); + await ns.set("a", "1"); + expect(await ns.keys("zzz")).toEqual([]); + }); + }); + + describe("namespace isolation", () => { + it("same key in different namespaces does not collide", async () => { + const ns1 = backend.storage("ns1"); + const ns2 = backend.storage("ns2"); + await ns1.set("key", "value1"); + await ns2.set("key", "value2"); + expect(await ns1.get("key")).toBe("value1"); + expect(await ns2.get("key")).toBe("value2"); + }); + + it("delete in one namespace does not affect another", async () => { + const ns1 = backend.storage("ns1"); + const ns2 = backend.storage("ns2"); + await ns1.set("key", "v1"); + await ns2.set("key", "v2"); + await ns1.delete("key"); + expect(await ns1.has("key")).toBe(false); + expect(await ns2.get("key")).toBe("v2"); + }); + + it("keys are scoped to namespace", async () => { + const ns1 = backend.storage("ns1"); + const ns2 = backend.storage("ns2"); + await ns1.set("a", "1"); + await ns1.set("b", "2"); + await ns2.set("c", "3"); + expect((await ns1.keys()).toSorted()).toEqual(["a", "b"]); + expect(await ns2.keys()).toEqual(["c"]); + }); + }); + + describe("migrate", () => { + it("runs pending migrations in order", async () => { + const migrations: Migration[] = [ + { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, + { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, + ]; + await backend.migrate("ext1", migrations); + + const ns = backend.storage("ext1"); + await ns.set("test", "val"); + expect(await ns.get("test")).toBe("val"); + }); + + it("skips already-applied migrations", async () => { + const migrations: Migration[] = [ + { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, + ]; + await backend.migrate("ext1", migrations); + await backend.migrate("ext1", [ + ...migrations, + { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, + ]); + }); + + it("is idempotent — calling migrate twice with same migrations is safe", async () => { + const migrations: Migration[] = [ + { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, + ]; + await backend.migrate("ext1", migrations); + await backend.migrate("ext1", migrations); + }); + + it("migrations are per-namespace", async () => { + const m1: Migration[] = [ + { version: 1, name: "create_t1", up: "CREATE TABLE t1 (id INTEGER PRIMARY KEY);" }, + ]; + const m2: Migration[] = [ + { version: 1, name: "create_t2", up: "CREATE TABLE t2 (id INTEGER PRIMARY KEY);" }, + ]; + await backend.migrate("ext1", m1); + await backend.migrate("ext2", m2); + }); + }); }); describe("createSqliteStorage — persistence across reopen", () => { - it("migrations survive close and reopen", async () => { - const tmpPath = `/tmp/storage-sqlite-test-${Date.now()}.db`; - const migrations: Migration[] = [ - { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, - ]; - - const first = createSqliteStorage({ path: tmpPath }); - await first.migrate("ext1", migrations); - first.close(); - - const second = createSqliteStorage({ path: tmpPath }); - await second.migrate("ext1", [ - ...migrations, - { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, - ]); - second.close(); - - const { unlinkSync } = await import("node:fs"); - unlinkSync(tmpPath); - }); + it("migrations survive close and reopen", async () => { + const tmpPath = `/tmp/storage-sqlite-test-${Date.now()}.db`; + const migrations: Migration[] = [ + { version: 1, name: "create_foo", up: "CREATE TABLE foo (id INTEGER PRIMARY KEY);" }, + ]; + + const first = createSqliteStorage({ path: tmpPath }); + await first.migrate("ext1", migrations); + first.close(); + + const second = createSqliteStorage({ path: tmpPath }); + await second.migrate("ext1", [ + ...migrations, + { version: 2, name: "create_bar", up: "CREATE TABLE bar (id INTEGER PRIMARY KEY);" }, + ]); + second.close(); + + const { unlinkSync } = await import("node:fs"); + unlinkSync(tmpPath); + }); }); diff --git a/packages/storage-sqlite/src/storage.ts b/packages/storage-sqlite/src/storage.ts index 2499664..94a826d 100644 --- a/packages/storage-sqlite/src/storage.ts +++ b/packages/storage-sqlite/src/storage.ts @@ -3,17 +3,17 @@ import type { StorageNamespace } from "@dispatch/kernel"; import { computePending, type Migration } from "./migrate.js"; export interface SqliteStorageBackend { - readonly storage: (namespace: string) => StorageNamespace; - readonly migrate: (namespace: string, migrations: readonly Migration[]) => Promise; - readonly close: () => void; + readonly storage: (namespace: string) => StorageNamespace; + readonly migrate: (namespace: string, migrations: readonly Migration[]) => Promise; + readonly close: () => void; } export type StorageFactory = (opts: { path: string }) => SqliteStorageBackend; export function createSqliteStorage(opts: { path: string }): SqliteStorageBackend { - const db = new Database(opts.path); - db.exec("PRAGMA journal_mode = WAL;"); - db.exec(` + const db = new Database(opts.path); + db.exec("PRAGMA journal_mode = WAL;"); + db.exec(` CREATE TABLE IF NOT EXISTS kv ( namespace TEXT NOT NULL, key TEXT NOT NULL, @@ -21,7 +21,7 @@ export function createSqliteStorage(opts: { path: string }): SqliteStorageBacken PRIMARY KEY (namespace, key) ); `); - db.exec(` + db.exec(` CREATE TABLE IF NOT EXISTS _migrations ( namespace TEXT NOT NULL, version INTEGER NOT NULL, @@ -31,62 +31,62 @@ export function createSqliteStorage(opts: { path: string }): SqliteStorageBacken ); `); - const getStmt = db.prepare("SELECT value FROM kv WHERE namespace = ?1 AND key = ?2"); - const setStmt = db.prepare( - "INSERT OR REPLACE INTO kv (namespace, key, value) VALUES (?1, ?2, ?3)", - ); - const deleteStmt = db.prepare("DELETE FROM kv WHERE namespace = ?1 AND key = ?2"); - const hasStmt = db.prepare("SELECT 1 FROM kv WHERE namespace = ?1 AND key = ?2"); - const keysAllStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1"); - const keysPrefixStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1 AND key LIKE ?2"); - const appliedVersionsStmt = db.prepare("SELECT version FROM _migrations WHERE namespace = ?1"); - const recordMigrationStmt = db.prepare( - "INSERT INTO _migrations (namespace, version, name) VALUES (?1, ?2, ?3)", - ); + const getStmt = db.prepare("SELECT value FROM kv WHERE namespace = ?1 AND key = ?2"); + const setStmt = db.prepare( + "INSERT OR REPLACE INTO kv (namespace, key, value) VALUES (?1, ?2, ?3)", + ); + const deleteStmt = db.prepare("DELETE FROM kv WHERE namespace = ?1 AND key = ?2"); + const hasStmt = db.prepare("SELECT 1 FROM kv WHERE namespace = ?1 AND key = ?2"); + const keysAllStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1"); + const keysPrefixStmt = db.prepare("SELECT key FROM kv WHERE namespace = ?1 AND key LIKE ?2"); + const appliedVersionsStmt = db.prepare("SELECT version FROM _migrations WHERE namespace = ?1"); + const recordMigrationStmt = db.prepare( + "INSERT INTO _migrations (namespace, version, name) VALUES (?1, ?2, ?3)", + ); - function storage(namespace: string): StorageNamespace { - return { - get: async (key: string) => { - const row = getStmt.get(namespace, key) as { value: string } | null; - return row?.value ?? null; - }, - set: async (key: string, value: string) => { - setStmt.run(namespace, key, value); - }, - delete: async (key: string) => { - deleteStmt.run(namespace, key); - }, - has: async (key: string) => { - return hasStmt.get(namespace, key) !== null; - }, - keys: async (prefix?: string) => { - if (prefix !== undefined) { - const rows = keysPrefixStmt.all(namespace, `${prefix}%`) as { key: string }[]; - return rows.map((r) => r.key); - } - const rows = keysAllStmt.all(namespace) as { key: string }[]; - return rows.map((r) => r.key); - }, - }; - } + function storage(namespace: string): StorageNamespace { + return { + get: async (key: string) => { + const row = getStmt.get(namespace, key) as { value: string } | null; + return row?.value ?? null; + }, + set: async (key: string, value: string) => { + setStmt.run(namespace, key, value); + }, + delete: async (key: string) => { + deleteStmt.run(namespace, key); + }, + has: async (key: string) => { + return hasStmt.get(namespace, key) !== null; + }, + keys: async (prefix?: string) => { + if (prefix !== undefined) { + const rows = keysPrefixStmt.all(namespace, `${prefix}%`) as { key: string }[]; + return rows.map((r) => r.key); + } + const rows = keysAllStmt.all(namespace) as { key: string }[]; + return rows.map((r) => r.key); + }, + }; + } - async function migrate(namespace: string, migrations: readonly Migration[]): Promise { - const rows = appliedVersionsStmt.all(namespace) as { version: number }[]; - const applied = new Set(rows.map((r) => r.version)); - const pending = computePending(applied, migrations); + async function migrate(namespace: string, migrations: readonly Migration[]): Promise { + const rows = appliedVersionsStmt.all(namespace) as { version: number }[]; + const applied = new Set(rows.map((r) => r.version)); + const pending = computePending(applied, migrations); - for (const m of pending) { - const runInTransaction = db.transaction(() => { - db.exec(m.up); - recordMigrationStmt.run(namespace, m.version, m.name); - }); - runInTransaction(); - } - } + for (const m of pending) { + const runInTransaction = db.transaction(() => { + db.exec(m.up); + recordMigrationStmt.run(namespace, m.version, m.name); + }); + runInTransaction(); + } + } - function close(): void { - db.close(); - } + function close(): void { + db.close(); + } - return { storage, migrate, close }; + return { storage, migrate, close }; } diff --git a/packages/storage-sqlite/tsconfig.json b/packages/storage-sqlite/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/storage-sqlite/tsconfig.json +++ b/packages/storage-sqlite/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/surface-loaded-extensions/package.json b/packages/surface-loaded-extensions/package.json index 66e2e69..f2eb4b9 100644 --- a/packages/surface-loaded-extensions/package.json +++ b/packages/surface-loaded-extensions/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/surface-loaded-extensions", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/ui-contract": "workspace:*", - "@dispatch/surface-registry": "workspace:*" - } + "name": "@dispatch/surface-loaded-extensions", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*", + "@dispatch/surface-registry": "workspace:*" + } } diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts index 20abdec..7af55b6 100644 --- a/packages/surface-loaded-extensions/src/extension.ts +++ b/packages/surface-loaded-extensions/src/extension.ts @@ -4,41 +4,41 @@ import { surfaceRegistryHandle } from "@dispatch/surface-registry"; import { buildLoadedExtensionsSpec } from "./spec.js"; export const manifest: Manifest = { - id: "surface-loaded-extensions", - name: "Loaded Extensions Surface", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["surface-registry"], - contributes: { services: [] }, + id: "surface-loaded-extensions", + name: "Loaded Extensions Surface", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["surface-registry"], + contributes: { services: [] }, }; export function createLoadedExtensionsExtension(): Extension { - let dispose: (() => void) | undefined; + let dispose: (() => void) | undefined; - return { - manifest, - activate(host: HostAPI) { - const registry = host.getService(surfaceRegistryHandle); + return { + manifest, + activate(host: HostAPI) { + const registry = host.getService(surfaceRegistryHandle); - const provider: SurfaceProvider = { - catalogEntry: { - id: "loaded-extensions", - region: "side", - title: "Loaded Extensions", - scope: "global", - }, - getSpec() { - return buildLoadedExtensionsSpec(host.getExtensions()); - }, - invoke() {}, - }; + const provider: SurfaceProvider = { + catalogEntry: { + id: "loaded-extensions", + region: "side", + title: "Loaded Extensions", + scope: "global", + }, + getSpec() { + return buildLoadedExtensionsSpec(host.getExtensions()); + }, + invoke() {}, + }; - dispose = registry.register(provider); - }, - deactivate() { - dispose?.(); - }, - }; + dispose = registry.register(provider); + }, + deactivate() { + dispose?.(); + }, + }; } diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts index bc31b9e..d20eb8e 100644 --- a/packages/surface-loaded-extensions/src/spec.test.ts +++ b/packages/surface-loaded-extensions/src/spec.test.ts @@ -4,105 +4,105 @@ import { describe, expect, it } from "vitest"; import { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js"; function fakeManifest( - id: string, - name: string, - version: string, - extra: Partial = {}, + id: string, + name: string, + version: string, + extra: Partial = {}, ): Manifest { - return { - id, - name, - version, - apiVersion: "^0.1.0", - trust: "bundled", - ...extra, - }; + return { + id, + name, + version, + apiVersion: "^0.1.0", + trust: "bundled", + ...extra, + }; } function tablePayload(field: unknown): TablePayload { - const custom = field as CustomField; - expect(custom.kind).toBe("custom"); - expect(custom.rendererId).toBe(TABLE_RENDERER_ID); - return custom.payload as TablePayload; + const custom = field as CustomField; + expect(custom.kind).toBe("custom"); + expect(custom.rendererId).toBe(TABLE_RENDERER_ID); + return custom.payload as TablePayload; } describe("buildLoadedExtensionsSpec", () => { - it("returns a count stat of '0' and an empty table for empty manifests", () => { - const spec = buildLoadedExtensionsSpec([]); - - expect(spec.id).toBe("loaded-extensions"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Loaded Extensions"); - expect(spec.fields).toHaveLength(2); - expect(spec.fields[0]).toEqual({ - kind: "stat", - label: "Loaded", - value: "0", - }); - expect(tablePayload(spec.fields[1]).rows).toEqual([]); - }); - - it("returns a count stat plus ONE table field with a row per manifest (CR-1)", () => { - const manifests = [ - fakeManifest("alpha", "Alpha", "1.0.0"), - fakeManifest("beta", "Beta", "2.3.1", { trust: "external", activation: "lazy" }), - fakeManifest("gamma", "Gamma", "0.5.0", { trust: "local" }), - ]; - - const spec = buildLoadedExtensionsSpec(manifests); - - expect(spec.fields).toHaveLength(2); - expect(spec.fields[0]).toEqual({ - kind: "stat", - label: "Loaded", - value: "3", - }); - - const payload = tablePayload(spec.fields[1]); - expect(payload.columns).toEqual(["Name", "Version", "Trust", "Activation"]); - expect(payload.rows).toEqual([ - ["Alpha", "1.0.0", "bundled", "eager"], - ["Beta", "2.3.1", "external", "lazy"], - ["Gamma", "0.5.0", "local", "eager"], - ]); - }); - - it("every row aligns cell-for-cell to the columns", () => { - const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); - const payload = tablePayload(spec.fields[1]); - for (const row of payload.rows) { - expect(row).toHaveLength(payload.columns.length); - } - }); - - it("preserves input order of manifests", () => { - const manifests = [ - fakeManifest("z-last", "Z Last", "1.0.0"), - fakeManifest("a-first", "A First", "2.0.0"), - ]; - - const spec = buildLoadedExtensionsSpec(manifests); - - const payload = tablePayload(spec.fields[1]); - expect(payload.rows.map((r) => r[0])).toEqual(["Z Last", "A First"]); - }); - - it("sets the surface id, region, and title correctly", () => { - const spec = buildLoadedExtensionsSpec([]); - - expect(spec.id).toBe("loaded-extensions"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Loaded Extensions"); - }); - - it("defaults a missing activation to 'eager' (the declared manifest default)", () => { - const spec = buildLoadedExtensionsSpec([fakeManifest("my-ext", "My Extension", "3.2.1")]); - const payload = tablePayload(spec.fields[1]); - expect(payload.rows[0]).toEqual(["My Extension", "3.2.1", "bundled", "eager"]); - }); - - it("the count stat remains a plain stat (graceful-skip clients still see it)", () => { - const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); - expect((spec.fields[0] as StatField).kind).toBe("stat"); - }); + it("returns a count stat of '0' and an empty table for empty manifests", () => { + const spec = buildLoadedExtensionsSpec([]); + + expect(spec.id).toBe("loaded-extensions"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Loaded Extensions"); + expect(spec.fields).toHaveLength(2); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Loaded", + value: "0", + }); + expect(tablePayload(spec.fields[1]).rows).toEqual([]); + }); + + it("returns a count stat plus ONE table field with a row per manifest (CR-1)", () => { + const manifests = [ + fakeManifest("alpha", "Alpha", "1.0.0"), + fakeManifest("beta", "Beta", "2.3.1", { trust: "external", activation: "lazy" }), + fakeManifest("gamma", "Gamma", "0.5.0", { trust: "local" }), + ]; + + const spec = buildLoadedExtensionsSpec(manifests); + + expect(spec.fields).toHaveLength(2); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Loaded", + value: "3", + }); + + const payload = tablePayload(spec.fields[1]); + expect(payload.columns).toEqual(["Name", "Version", "Trust", "Activation"]); + expect(payload.rows).toEqual([ + ["Alpha", "1.0.0", "bundled", "eager"], + ["Beta", "2.3.1", "external", "lazy"], + ["Gamma", "0.5.0", "local", "eager"], + ]); + }); + + it("every row aligns cell-for-cell to the columns", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); + const payload = tablePayload(spec.fields[1]); + for (const row of payload.rows) { + expect(row).toHaveLength(payload.columns.length); + } + }); + + it("preserves input order of manifests", () => { + const manifests = [ + fakeManifest("z-last", "Z Last", "1.0.0"), + fakeManifest("a-first", "A First", "2.0.0"), + ]; + + const spec = buildLoadedExtensionsSpec(manifests); + + const payload = tablePayload(spec.fields[1]); + expect(payload.rows.map((r) => r[0])).toEqual(["Z Last", "A First"]); + }); + + it("sets the surface id, region, and title correctly", () => { + const spec = buildLoadedExtensionsSpec([]); + + expect(spec.id).toBe("loaded-extensions"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Loaded Extensions"); + }); + + it("defaults a missing activation to 'eager' (the declared manifest default)", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("my-ext", "My Extension", "3.2.1")]); + const payload = tablePayload(spec.fields[1]); + expect(payload.rows[0]).toEqual(["My Extension", "3.2.1", "bundled", "eager"]); + }); + + it("the count stat remains a plain stat (graceful-skip clients still see it)", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); + expect((spec.fields[0] as StatField).kind).toBe("stat"); + }); }); diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts index 72e8d41..e488381 100644 --- a/packages/surface-loaded-extensions/src/spec.ts +++ b/packages/surface-loaded-extensions/src/spec.ts @@ -7,8 +7,8 @@ import type { CustomField, StatField, SurfaceSpec } from "@dispatch/ui-contract" * a blind `unknown`. Each row aligns cell-for-cell to `columns`. */ export interface TablePayload { - readonly columns: readonly string[]; - readonly rows: ReadonlyArray>; + readonly columns: readonly string[]; + readonly rows: ReadonlyArray>; } /** The renderer id clients dispatch on for the extensions table. */ @@ -23,29 +23,29 @@ export const TABLE_RENDERER_ID = "table"; * "table" renderer gracefully skips the field and still sees the count. */ export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec { - const count: StatField = { kind: "stat", label: "Loaded", value: String(manifests.length) }; + const count: StatField = { kind: "stat", label: "Loaded", value: String(manifests.length) }; - const payload: TablePayload = { - columns: ["Name", "Version", "Trust", "Activation"], - rows: manifests.map((manifest) => [ - manifest.name, - manifest.version, - manifest.trust, - // Activation is optional in the manifest; "eager" is the declared default. - manifest.activation ?? "eager", - ]), - }; + const payload: TablePayload = { + columns: ["Name", "Version", "Trust", "Activation"], + rows: manifests.map((manifest) => [ + manifest.name, + manifest.version, + manifest.trust, + // Activation is optional in the manifest; "eager" is the declared default. + manifest.activation ?? "eager", + ]), + }; - const table: CustomField = { - kind: "custom", - rendererId: TABLE_RENDERER_ID, - payload, - }; + const table: CustomField = { + kind: "custom", + rendererId: TABLE_RENDERER_ID, + payload, + }; - return { - id: "loaded-extensions", - region: "side", - title: "Loaded Extensions", - fields: [count, table], - }; + return { + id: "loaded-extensions", + region: "side", + title: "Loaded Extensions", + fields: [count, table], + }; } diff --git a/packages/surface-loaded-extensions/tsconfig.json b/packages/surface-loaded-extensions/tsconfig.json index db257d0..89dfc56 100644 --- a/packages/surface-loaded-extensions/tsconfig.json +++ b/packages/surface-loaded-extensions/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../ui-contract" }, - { "path": "../surface-registry" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../ui-contract" }, + { "path": "../surface-registry" } + ] } diff --git a/packages/surface-registry/package.json b/packages/surface-registry/package.json index 16b0c4c..9ae2a04 100644 --- a/packages/surface-registry/package.json +++ b/packages/surface-registry/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/surface-registry", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/ui-contract": "workspace:*" - } + "name": "@dispatch/surface-registry", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } } diff --git a/packages/surface-registry/src/extension.ts b/packages/surface-registry/src/extension.ts index 6d0ce22..2e0c038 100644 --- a/packages/surface-registry/src/extension.ts +++ b/packages/surface-registry/src/extension.ts @@ -3,21 +3,21 @@ import { createSurfaceRegistry } from "./registry.js"; import { surfaceRegistryHandle } from "./service.js"; export const manifest: Manifest = { - id: "surface-registry", - name: "Surface Registry", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - contributes: { services: ["surface-registry/registry"] }, + id: "surface-registry", + name: "Surface Registry", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["surface-registry/registry"] }, }; export function createSurfaceRegistryExtension(): Extension { - return { - manifest, - activate(host) { - const registry = createSurfaceRegistry(); - host.provideService(surfaceRegistryHandle, registry); - }, - }; + return { + manifest, + activate(host) { + const registry = createSurfaceRegistry(); + host.provideService(surfaceRegistryHandle, registry); + }, + }; } diff --git a/packages/surface-registry/src/registry.test.ts b/packages/surface-registry/src/registry.test.ts index c47c979..151e449 100644 --- a/packages/surface-registry/src/registry.test.ts +++ b/packages/surface-registry/src/registry.test.ts @@ -4,119 +4,119 @@ import type { SurfaceProvider } from "./registry.js"; import { createSurfaceRegistry } from "./registry.js"; function fakeProvider(id: string, title?: string): SurfaceProvider { - const catalogEntry: SurfaceCatalogEntry = { - id, - region: "default", - title: title ?? `Surface ${id}`, - }; - return { - catalogEntry, - getSpec(): SurfaceSpec { - return { - id, - region: "default", - title: catalogEntry.title, - fields: [], - }; - }, - invoke() {}, - }; + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: [], + }; + }, + invoke() {}, + }; } describe("createSurfaceRegistry", () => { - describe("register + getCatalog", () => { - it("returns the entry after registration", () => { - const registry = createSurfaceRegistry(); - registry.register(fakeProvider("a", "Surface A")); - - const catalog = registry.getCatalog(); - expect(catalog).toHaveLength(1); - expect(catalog[0]).toEqual({ - id: "a", - region: "default", - title: "Surface A", - }); - }); - - it("returns entries for multiple providers", () => { - const registry = createSurfaceRegistry(); - registry.register(fakeProvider("a")); - registry.register(fakeProvider("b")); - - const catalog = registry.getCatalog(); - expect(catalog).toHaveLength(2); - expect(catalog.map((e) => e.id)).toEqual(["a", "b"]); - }); - }); - - describe("getSurface", () => { - it("returns the provider for a known id", () => { - const registry = createSurfaceRegistry(); - const provider = fakeProvider("x"); - registry.register(provider); - - expect(registry.getSurface("x")).toBe(provider); - }); - - it("returns undefined for an unknown id", () => { - const registry = createSurfaceRegistry(); - expect(registry.getSurface("nonexistent")).toBeUndefined(); - }); - }); - - describe("disposer", () => { - it("removes the provider from catalog and lookup", () => { - const registry = createSurfaceRegistry(); - const dispose = registry.register(fakeProvider("a")); - - expect(registry.getCatalog()).toHaveLength(1); - expect(registry.getSurface("a")).toBeDefined(); - - dispose(); - - expect(registry.getCatalog()).toHaveLength(0); - expect(registry.getSurface("a")).toBeUndefined(); - }); - - it("is idempotent — calling dispose twice is safe", () => { - const registry = createSurfaceRegistry(); - const dispose = registry.register(fakeProvider("a")); - - dispose(); - dispose(); - - expect(registry.getCatalog()).toHaveLength(0); - }); - - it("does not remove a replacement provider with the same id", () => { - const registry = createSurfaceRegistry(); - const first = fakeProvider("a", "First"); - const second = fakeProvider("a", "Second"); - - const disposeFirst = registry.register(first); - registry.register(second); - - disposeFirst(); - - // The second provider should still be registered - expect(registry.getSurface("a")).toBe(second); - expect(registry.getCatalog()).toHaveLength(1); - expect(registry.getCatalog()[0]?.title).toBe("Second"); - }); - }); - - describe("duplicate-id behavior (last-wins)", () => { - it("replaces an existing provider when registering the same id", () => { - const registry = createSurfaceRegistry(); - const first = fakeProvider("a", "First"); - const second = fakeProvider("a", "Second"); - - registry.register(first); - registry.register(second); - - expect(registry.getSurface("a")).toBe(second); - expect(registry.getCatalog()).toHaveLength(1); - expect(registry.getCatalog()[0]?.title).toBe("Second"); - }); - }); + describe("register + getCatalog", () => { + it("returns the entry after registration", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a", "Surface A")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(1); + expect(catalog[0]).toEqual({ + id: "a", + region: "default", + title: "Surface A", + }); + }); + + it("returns entries for multiple providers", () => { + const registry = createSurfaceRegistry(); + registry.register(fakeProvider("a")); + registry.register(fakeProvider("b")); + + const catalog = registry.getCatalog(); + expect(catalog).toHaveLength(2); + expect(catalog.map((e) => e.id)).toEqual(["a", "b"]); + }); + }); + + describe("getSurface", () => { + it("returns the provider for a known id", () => { + const registry = createSurfaceRegistry(); + const provider = fakeProvider("x"); + registry.register(provider); + + expect(registry.getSurface("x")).toBe(provider); + }); + + it("returns undefined for an unknown id", () => { + const registry = createSurfaceRegistry(); + expect(registry.getSurface("nonexistent")).toBeUndefined(); + }); + }); + + describe("disposer", () => { + it("removes the provider from catalog and lookup", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getSurface("a")).toBeDefined(); + + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + expect(registry.getSurface("a")).toBeUndefined(); + }); + + it("is idempotent — calling dispose twice is safe", () => { + const registry = createSurfaceRegistry(); + const dispose = registry.register(fakeProvider("a")); + + dispose(); + dispose(); + + expect(registry.getCatalog()).toHaveLength(0); + }); + + it("does not remove a replacement provider with the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + const disposeFirst = registry.register(first); + registry.register(second); + + disposeFirst(); + + // The second provider should still be registered + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); + + describe("duplicate-id behavior (last-wins)", () => { + it("replaces an existing provider when registering the same id", () => { + const registry = createSurfaceRegistry(); + const first = fakeProvider("a", "First"); + const second = fakeProvider("a", "Second"); + + registry.register(first); + registry.register(second); + + expect(registry.getSurface("a")).toBe(second); + expect(registry.getCatalog()).toHaveLength(1); + expect(registry.getCatalog()[0]?.title).toBe("Second"); + }); + }); }); diff --git a/packages/surface-registry/src/registry.ts b/packages/surface-registry/src/registry.ts index 5780910..840ef5a 100644 --- a/packages/surface-registry/src/registry.ts +++ b/packages/surface-registry/src/registry.ts @@ -6,7 +6,7 @@ import type { SurfaceCatalog, SurfaceCatalogEntry, SurfaceSpec } from "@dispatch * the default/global behaviour. */ export interface SurfaceContext { - readonly conversationId?: string; + readonly conversationId?: string; } /** @@ -14,20 +14,20 @@ export interface SurfaceContext { * Each provider owns one surface identified by its catalog entry id. */ export interface SurfaceProvider { - /** Discovery metadata for the surface catalog. */ - readonly catalogEntry: SurfaceCatalogEntry; + /** Discovery metadata for the surface catalog. */ + readonly catalogEntry: SurfaceCatalogEntry; - /** Build the current surface spec (may be async for dynamic surfaces). */ - getSpec(context?: SurfaceContext): SurfaceSpec | Promise; + /** Build the current surface spec (may be async for dynamic surfaces). */ + getSpec(context?: SurfaceContext): SurfaceSpec | Promise; - /** Run a backend action by id with an optional payload. */ - invoke(actionId: string, payload?: unknown, context?: SurfaceContext): void | Promise; + /** Run a backend action by id with an optional payload. */ + invoke(actionId: string, payload?: unknown, context?: SurfaceContext): void | Promise; - /** - * Optional: subscribe to spec changes. Returns an unsubscribe disposer. - * When the spec changes, the caller should re-fetch via getSpec() and push. - */ - subscribe?(onChange: () => void): () => void; + /** + * Optional: subscribe to spec changes. Returns an unsubscribe disposer. + * When the spec changes, the caller should re-fetch via getSpec() and push. + */ + subscribe?(onChange: () => void): () => void; } /** @@ -35,18 +35,18 @@ export interface SurfaceProvider { * `host.getService(surfaceRegistryHandle)`. */ export interface SurfaceRegistry { - /** - * Register a surface provider. Returns an unregister disposer. - * If a provider with the same id is already registered, the new one - * replaces it (last-wins semantics). - */ - register(provider: SurfaceProvider): () => void; + /** + * Register a surface provider. Returns an unregister disposer. + * If a provider with the same id is already registered, the new one + * replaces it (last-wins semantics). + */ + register(provider: SurfaceProvider): () => void; - /** Return discovery metadata for all currently registered providers. */ - getCatalog(): SurfaceCatalog; + /** Return discovery metadata for all currently registered providers. */ + getCatalog(): SurfaceCatalog; - /** Look up a provider by its surface id. */ - getSurface(id: string): SurfaceProvider | undefined; + /** Look up a provider by its surface id. */ + getSurface(id: string): SurfaceProvider | undefined; } /** @@ -54,36 +54,36 @@ export interface SurfaceRegistry { * the decision logic is a plain Map behind the SurfaceRegistry interface. */ export function createSurfaceRegistry(): SurfaceRegistry { - const providers = new Map(); + const providers = new Map(); - return { - register(provider: SurfaceProvider): () => void { - const id = provider.catalogEntry.id; - providers.set(id, provider); + return { + register(provider: SurfaceProvider): () => void { + const id = provider.catalogEntry.id; + providers.set(id, provider); - let disposed = false; - return () => { - if (!disposed) { - disposed = true; - // Only delete if the current entry is still this provider - // (another register with the same id may have replaced it). - if (providers.get(id) === provider) { - providers.delete(id); - } - } - }; - }, + let disposed = false; + return () => { + if (!disposed) { + disposed = true; + // Only delete if the current entry is still this provider + // (another register with the same id may have replaced it). + if (providers.get(id) === provider) { + providers.delete(id); + } + } + }; + }, - getCatalog(): SurfaceCatalog { - const entries: SurfaceCatalogEntry[] = []; - for (const provider of providers.values()) { - entries.push(provider.catalogEntry); - } - return entries; - }, + getCatalog(): SurfaceCatalog { + const entries: SurfaceCatalogEntry[] = []; + for (const provider of providers.values()) { + entries.push(provider.catalogEntry); + } + return entries; + }, - getSurface(id: string): SurfaceProvider | undefined { - return providers.get(id); - }, - }; + getSurface(id: string): SurfaceProvider | undefined { + return providers.get(id); + }, + }; } diff --git a/packages/surface-registry/tsconfig.json b/packages/surface-registry/tsconfig.json index e430ba9..789e54f 100644 --- a/packages/surface-registry/tsconfig.json +++ b/packages/surface-registry/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }] } diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json index 9414297..2e08e0c 100644 --- a/packages/system-prompt/package.json +++ b/packages/system-prompt/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/system-prompt", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/exec-backend": "workspace:*", - "@dispatch/kernel": "workspace:*", - "@dispatch/transport-contract": "workspace:*" - } + "name": "@dispatch/system-prompt", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/exec-backend": "workspace:*", + "@dispatch/kernel": "workspace:*", + "@dispatch/transport-contract": "workspace:*" + } } diff --git a/packages/system-prompt/src/catalog.test.ts b/packages/system-prompt/src/catalog.test.ts index 406455b..12999b6 100644 --- a/packages/system-prompt/src/catalog.test.ts +++ b/packages/system-prompt/src/catalog.test.ts @@ -2,30 +2,30 @@ import { describe, expect, it } from "vitest"; import { getVariableCatalog } from "./catalog.js"; describe("catalog", () => { - it("lists all fixed variables", () => { - const catalog = getVariableCatalog(); - const keys = catalog.map((v) => `${v.type}:${v.name}`); + it("lists all fixed variables", () => { + const catalog = getVariableCatalog(); + const keys = catalog.map((v) => `${v.type}:${v.name}`); - expect(keys).toContain("system:time"); - expect(keys).toContain("system:date"); - expect(keys).toContain("system:os"); - expect(keys).toContain("system:hostname"); - expect(keys).toContain("prompt:cwd"); - expect(keys).toContain("prompt:model"); - expect(keys).toContain("prompt:conversation_id"); - expect(keys).toContain("git:branch"); - expect(keys).toContain("git:status"); - }); + expect(keys).toContain("system:time"); + expect(keys).toContain("system:date"); + expect(keys).toContain("system:os"); + expect(keys).toContain("system:hostname"); + expect(keys).toContain("prompt:cwd"); + expect(keys).toContain("prompt:model"); + expect(keys).toContain("prompt:conversation_id"); + expect(keys).toContain("git:branch"); + expect(keys).toContain("git:status"); + }); - it("marks the file type as dynamic", () => { - const fileVar = getVariableCatalog().find((v) => v.type === "file"); - expect(fileVar).toBeDefined(); - expect(fileVar?.dynamic).toBe(true); - }); + it("marks the file type as dynamic", () => { + const fileVar = getVariableCatalog().find((v) => v.type === "file"); + expect(fileVar).toBeDefined(); + expect(fileVar?.dynamic).toBe(true); + }); - it("every entry has a description", () => { - for (const v of getVariableCatalog()) { - expect(v.description.length).toBeGreaterThan(0); - } - }); + it("every entry has a description", () => { + for (const v of getVariableCatalog()) { + expect(v.description.length).toBeGreaterThan(0); + } + }); }); diff --git a/packages/system-prompt/src/catalog.ts b/packages/system-prompt/src/catalog.ts index 1c825ab..f4df390 100644 --- a/packages/system-prompt/src/catalog.ts +++ b/packages/system-prompt/src/catalog.ts @@ -8,22 +8,22 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract"; export function getVariableCatalog(): SystemPromptVariable[] { - return [ - { type: "system", name: "time", description: "Current time in ISO 8601 format" }, - { type: "system", name: "date", description: "Current date (YYYY-MM-DD)" }, - { type: "system", name: "os", description: "Operating system platform" }, - { type: "system", name: "hostname", description: "Machine hostname" }, - { type: "prompt", name: "cwd", description: "Conversation working directory" }, - { type: "prompt", name: "model", description: "Current model name" }, - { type: "prompt", name: "conversation_id", description: "Conversation identifier" }, - { type: "prompt", name: "workspace_id", description: "Workspace identifier" }, - { type: "git", name: "branch", description: "Current git branch" }, - { type: "git", name: "status", description: "Short git status" }, - { - type: "file", - name: "", - description: "Contents of a file (relative to cwd, or absolute if it starts with /)", - dynamic: true, - }, - ]; + return [ + { type: "system", name: "time", description: "Current time in ISO 8601 format" }, + { type: "system", name: "date", description: "Current date (YYYY-MM-DD)" }, + { type: "system", name: "os", description: "Operating system platform" }, + { type: "system", name: "hostname", description: "Machine hostname" }, + { type: "prompt", name: "cwd", description: "Conversation working directory" }, + { type: "prompt", name: "model", description: "Current model name" }, + { type: "prompt", name: "conversation_id", description: "Conversation identifier" }, + { type: "prompt", name: "workspace_id", description: "Workspace identifier" }, + { type: "git", name: "branch", description: "Current git branch" }, + { type: "git", name: "status", description: "Short git status" }, + { + type: "file", + name: "", + description: "Contents of a file (relative to cwd, or absolute if it starts with /)", + dynamic: true, + }, + ]; } diff --git a/packages/system-prompt/src/extension.ts b/packages/system-prompt/src/extension.ts index 5cb9125..d281bf8 100644 --- a/packages/system-prompt/src/extension.ts +++ b/packages/system-prompt/src/extension.ts @@ -17,43 +17,43 @@ import { createSystemPromptService } from "./service.js"; import { systemPromptHandle } from "./types.js"; export const manifest: Manifest = { - id: "system-prompt", - name: "System Prompt", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - // exec-backend provides the resolver used to obtain a remote ExecBackend - // when computerId is set. The lookup is lazy (at construct time, not - // activation), but declaring the dep keeps the DAG honest. - dependsOn: ["exec-backend"], - capabilities: { fs: true, spawn: true }, - contributes: { services: ["system-prompt"] }, + id: "system-prompt", + name: "System Prompt", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + // exec-backend provides the resolver used to obtain a remote ExecBackend + // when computerId is set. The lookup is lazy (at construct time, not + // activation), but declaring the dep keeps the DAG honest. + dependsOn: ["exec-backend"], + capabilities: { fs: true, spawn: true }, + contributes: { services: ["system-prompt"] }, }; /** Run a command and capture stdout/stderr (used for git). */ async function realSpawn( - command: readonly string[], - opts: { readonly cwd: string }, + command: readonly string[], + opts: { readonly cwd: string }, ): Promise { - const proc = Bun.spawn([...command], { - cwd: opts.cwd, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - Bun.readableStreamToText(proc.stdout), - Bun.readableStreamToText(proc.stderr), - proc.exited, - ]); - return { stdout, stderr, exitCode }; + const proc = Bun.spawn([...command], { + cwd: opts.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + Bun.readableStreamToText(proc.stdout), + Bun.readableStreamToText(proc.stderr), + proc.exited, + ]); + return { stdout, stderr, exitCode }; } function realFs() { - return { - readText: async (path: string): Promise => Bun.file(path).text(), - exists: async (path: string): Promise => Bun.file(path).exists(), - }; + return { + readText: async (path: string): Promise => Bun.file(path).text(), + exists: async (path: string): Promise => Bun.file(path).exists(), + }; } const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() }; @@ -63,25 +63,25 @@ const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() }; * `null` on any error (the resolver treats null as "unavailable"). */ async function remoteCommand( - backend: ExecBackend, - command: string, - cwd: string, + backend: ExecBackend, + command: string, + cwd: string, ): Promise { - let stdout = ""; - try { - const result = await backend.spawn({ - command, - cwd, - signal: new AbortController().signal, - timeout: 10_000, - onOutput: (data: string, stream: "stdout" | "stderr") => { - if (stream === "stdout") stdout += data; - }, - }); - return result.exitCode === 0 ? stdout.trim() : null; - } catch { - return null; - } + let stdout = ""; + try { + const result = await backend.spawn({ + command, + cwd, + signal: new AbortController().signal, + timeout: 10_000, + onOutput: (data: string, stream: "stdout" | "stderr") => { + if (stream === "stdout") stdout += data; + }, + }); + return result.exitCode === 0 ? stdout.trim() : null; + } catch { + return null; + } } /** @@ -90,68 +90,68 @@ async function remoteCommand( * prompt reflect the REMOTE machine's OS, hostname, and git state. */ function buildRemoteAdapters(backend: ExecBackend, cwd: string): ResolverAdapters { - return { - spawn: async (command, opts) => { - let stdout = ""; - let stderr = ""; - const result = await backend.spawn({ - command: command.join(" "), - cwd: opts.cwd, - signal: new AbortController().signal, - timeout: 10_000, - onOutput: (data: string, stream: "stdout" | "stderr") => { - if (stream === "stdout") stdout += data; - else stderr += data; - }, - }); - return { stdout, stderr, exitCode: result.exitCode }; - }, - fs: { - readText: async (path: string): Promise => backend.readFile(path), - exists: async (path: string): Promise => backend.exists(path), - }, - // Run hostname/uname on the REMOTE machine. These are resolved once per - // construct call (cached by the service's cwd+computerId cache). If the - // remote command fails, fall back to a generic value (the resolver will - // still read /etc/os-release via SFTP for the distro name). - hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote", - platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux", - }; + return { + spawn: async (command, opts) => { + let stdout = ""; + let stderr = ""; + const result = await backend.spawn({ + command: command.join(" "), + cwd: opts.cwd, + signal: new AbortController().signal, + timeout: 10_000, + onOutput: (data: string, stream: "stdout" | "stderr") => { + if (stream === "stdout") stdout += data; + else stderr += data; + }, + }); + return { stdout, stderr, exitCode: result.exitCode }; + }, + fs: { + readText: async (path: string): Promise => backend.readFile(path), + exists: async (path: string): Promise => backend.exists(path), + }, + // Run hostname/uname on the REMOTE machine. These are resolved once per + // construct call (cached by the service's cwd+computerId cache). If the + // remote command fails, fall back to a generic value (the resolver will + // still read /etc/os-release via SFTP for the distro name). + hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote", + platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux", + }; } export function activate(host: HostAPI): void { - const storage = host.storage("system-prompt"); + const storage = host.storage("system-prompt"); - /** - * Resolve remote-backed adapters for a given computerId. Looks up the - * ExecBackendResolver (provided by exec-backend, which delegates to ssh's - * remote factory when computerId is set) and wraps it in ResolverAdapters. - * Falls back to local adapters if the resolver or backend is unavailable. - */ - const resolveRemoteAdapters = async ( - computerId: string, - cwd: string, - ): Promise => { - try { - const resolver = host.getService(execBackendHandle); - const backend = resolver(computerId); - return buildRemoteAdapters(backend, cwd); - } catch { - // exec-backend not loaded or resolver unavailable → local. - return localAdapters; - } - }; + /** + * Resolve remote-backed adapters for a given computerId. Looks up the + * ExecBackendResolver (provided by exec-backend, which delegates to ssh's + * remote factory when computerId is set) and wraps it in ResolverAdapters. + * Falls back to local adapters if the resolver or backend is unavailable. + */ + const resolveRemoteAdapters = async ( + computerId: string, + cwd: string, + ): Promise => { + try { + const resolver = host.getService(execBackendHandle); + const backend = resolver(computerId); + return buildRemoteAdapters(backend, cwd); + } catch { + // exec-backend not loaded or resolver unavailable → local. + return localAdapters; + } + }; - const service = createSystemPromptService({ - storage, - adapters: localAdapters, - resolveRemoteAdapters, - }); - host.provideService(systemPromptHandle, service); - host.logger.info("system-prompt: activated"); + const service = createSystemPromptService({ + storage, + adapters: localAdapters, + resolveRemoteAdapters, + }); + host.provideService(systemPromptHandle, service); + host.logger.info("system-prompt: activated"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts index 71cc434..35fd091 100644 --- a/packages/system-prompt/src/index.ts +++ b/packages/system-prompt/src/index.ts @@ -2,12 +2,12 @@ export { getVariableCatalog } from "./catalog.js"; export { extension, manifest } from "./extension.js"; export { extractVariables, parseTemplate } from "./parser.js"; export type { - GitSpawn, - GitSpawnResult, - ResolveOptions, - ResolverAdapters, - ResolverContext, - ResolverFs, + GitSpawn, + GitSpawnResult, + ResolveOptions, + ResolverAdapters, + ResolverContext, + ResolverFs, } from "./resolver.js"; export { resolveVariables } from "./resolver.js"; export type { SystemPromptServiceDeps } from "./service.js"; diff --git a/packages/system-prompt/src/parser.test.ts b/packages/system-prompt/src/parser.test.ts index 636a50d..3e3cdb6 100644 --- a/packages/system-prompt/src/parser.test.ts +++ b/packages/system-prompt/src/parser.test.ts @@ -2,185 +2,185 @@ import { describe, expect, it } from "vitest"; import { extractVariables, parseTemplate } from "./parser.js"; function vars(entries: ReadonlyArray<[string, string | null]>): Map { - return new Map(entries); + return new Map(entries); } describe("parser", () => { - describe("variable insertion", () => { - it("simple variable insertion", () => { - // 1. [system:time] with value → inserts it - expect(parseTemplate("[system:time]", vars([["system:time", "12:00"]]))).toBe("12:00"); - }); - - it("unknown variable → blank", () => { - // 2. [unknown:foo] not in map → "" - expect(parseTemplate("[unknown:foo]", vars([]))).toBe(""); - }); - - it("null variable → blank", () => { - // 3. [file:missing.md] with value null → "" - expect(parseTemplate("[file:missing.md]", vars([["file:missing.md", null]]))).toBe(""); - }); - - it("inserts value mid-text", () => { - expect(parseTemplate("cwd is [prompt:cwd]!", vars([["prompt:cwd", "/proj"]]))).toBe( - "cwd is /proj!", - ); - }); - - it("non-tag brackets stay literal", () => { - expect(parseTemplate("[not a tag] done", vars([]))).toBe("[not a tag] done"); - }); - - it("unclosed bracket stays literal", () => { - expect(parseTemplate("[file:x", vars([]))).toBe("[file:x"); - }); - }); - - describe("conditionals", () => { - it("if block renders when variable exists", () => { - // 4. [if file:AGENTS.md]YES[endif] with value → "YES" - expect( - parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", "content"]])), - ).toBe("YES"); - }); - - it("if block skipped when variable is null", () => { - // 5. same with null → "" - expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", null]]))).toBe( - "", - ); - }); - - it("if block skipped when variable absent", () => { - expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([]))).toBe(""); - }); - - it("if/else renders fallback when null", () => { - // 6. [if file:X]A[else]B[endif] with null → "B" - expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", null]]))).toBe("B"); - }); - - it("if/else renders then-branch when exists", () => { - expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", "v"]]))).toBe("A"); - }); - - it("negated if renders when variable is null", () => { - // 7. [if !file:X]A[endif] with null → "A" - expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", null]]))).toBe("A"); - }); - - it("negated if skipped when variable exists", () => { - expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", "v"]]))).toBe(""); - }); - - it("negated if renders when variable absent", () => { - expect(parseTemplate("[if !file:X]A[endif]", vars([]))).toBe("A"); - }); - - it("nested if — inner skipped when its var is null", () => { - // 8. [if system:os][if file:X]A[endif][endif] with os=set, file=null → "" - expect( - parseTemplate( - "[if system:os][if file:X]A[endif][endif]", - vars([ - ["system:os", "linux"], - ["file:X", null], - ]), - ), - ).toBe(""); - }); - - it("nested if — inner renders when both exist", () => { - expect( - parseTemplate( - "[if system:os][if file:X]A[endif][endif]", - vars([ - ["system:os", "linux"], - ["file:X", "v"], - ]), - ), - ).toBe("A"); - }); - - it("nested if/else", () => { - expect( - parseTemplate( - "[if system:os]os[if file:X]A[else]B[endif][endif]", - vars([ - ["system:os", "linux"], - ["file:X", null], - ]), - ), - ).toBe("osB"); - }); - - it("unmatched if → literal text", () => { - // 9. [if file:X]text (no endif) → "[if file:X]text" - expect(parseTemplate("[if file:X]text", vars([["file:X", "v"]]))).toBe("[if file:X]text"); - }); - - it("unmatched if with null var still emits literal tag", () => { - expect(parseTemplate("[if file:X]text", vars([["file:X", null]]))).toBe("[if file:X]text"); - }); - - it("stray endif → literal text", () => { - expect(parseTemplate("a[endif]b", vars([]))).toBe("a[endif]b"); - }); - - it("stray else → literal text", () => { - expect(parseTemplate("a[else]b", vars([]))).toBe("a[else]b"); - }); - - it("multi-line content renders correctly", () => { - // 10. if block spanning multiple lines - const template = "[if file:AGENTS.md]line1\nline2\nline3[endif]"; - expect(parseTemplate(template, vars([["file:AGENTS.md", "c"]]))).toBe("line1\nline2\nline3"); - }); - - it("multi-line if/else block", () => { - const template = "[if file:X]\nA\n[else]\nB\n[endif]"; - expect(parseTemplate(template, vars([["file:X", null]]))).toBe("\nB\n"); - }); - - it("default-template-like structure renders", () => { - const template = - "You are a helpful coding assistant.\n\n[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\n\nThe current working directory is [prompt:cwd].\n"; - expect( - parseTemplate( - template, - vars([ - ["file:AGENTS.md", "RULES"], - ["prompt:cwd", "/proj"], - ]), - ), - ).toBe( - "You are a helpful coding assistant.\n\n\nRULES\n\n\nThe current working directory is /proj.\n", - ); - }); - - it("default-template-like structure without AGENTS.md", () => { - const template = "[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\nThe cwd is [prompt:cwd]."; - expect(parseTemplate(template, vars([["prompt:cwd", "/proj"]]))).toBe("\nThe cwd is /proj."); - }); - }); - - describe("extractVariables", () => { - it("collects insertion + condition keys", () => { - const template = "[system:time] [if file:AGENTS.md][file:AGENTS.md][endif] [if !git:branch]"; - expect(extractVariables(template)).toEqual(["system:time", "file:AGENTS.md", "git:branch"]); - }); - - it("deduplicates keys", () => { - expect(extractVariables("[file:X][if file:X][file:X]")).toEqual(["file:X"]); - }); - - it("returns empty for plain text", () => { - expect(extractVariables("no variables here")).toEqual([]); - }); - - it("ignores unmatched tags", () => { - expect(extractVariables("[if file:X]no endif")).toEqual(["file:X"]); - }); - }); + describe("variable insertion", () => { + it("simple variable insertion", () => { + // 1. [system:time] with value → inserts it + expect(parseTemplate("[system:time]", vars([["system:time", "12:00"]]))).toBe("12:00"); + }); + + it("unknown variable → blank", () => { + // 2. [unknown:foo] not in map → "" + expect(parseTemplate("[unknown:foo]", vars([]))).toBe(""); + }); + + it("null variable → blank", () => { + // 3. [file:missing.md] with value null → "" + expect(parseTemplate("[file:missing.md]", vars([["file:missing.md", null]]))).toBe(""); + }); + + it("inserts value mid-text", () => { + expect(parseTemplate("cwd is [prompt:cwd]!", vars([["prompt:cwd", "/proj"]]))).toBe( + "cwd is /proj!", + ); + }); + + it("non-tag brackets stay literal", () => { + expect(parseTemplate("[not a tag] done", vars([]))).toBe("[not a tag] done"); + }); + + it("unclosed bracket stays literal", () => { + expect(parseTemplate("[file:x", vars([]))).toBe("[file:x"); + }); + }); + + describe("conditionals", () => { + it("if block renders when variable exists", () => { + // 4. [if file:AGENTS.md]YES[endif] with value → "YES" + expect( + parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", "content"]])), + ).toBe("YES"); + }); + + it("if block skipped when variable is null", () => { + // 5. same with null → "" + expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", null]]))).toBe( + "", + ); + }); + + it("if block skipped when variable absent", () => { + expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([]))).toBe(""); + }); + + it("if/else renders fallback when null", () => { + // 6. [if file:X]A[else]B[endif] with null → "B" + expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", null]]))).toBe("B"); + }); + + it("if/else renders then-branch when exists", () => { + expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", "v"]]))).toBe("A"); + }); + + it("negated if renders when variable is null", () => { + // 7. [if !file:X]A[endif] with null → "A" + expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", null]]))).toBe("A"); + }); + + it("negated if skipped when variable exists", () => { + expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", "v"]]))).toBe(""); + }); + + it("negated if renders when variable absent", () => { + expect(parseTemplate("[if !file:X]A[endif]", vars([]))).toBe("A"); + }); + + it("nested if — inner skipped when its var is null", () => { + // 8. [if system:os][if file:X]A[endif][endif] with os=set, file=null → "" + expect( + parseTemplate( + "[if system:os][if file:X]A[endif][endif]", + vars([ + ["system:os", "linux"], + ["file:X", null], + ]), + ), + ).toBe(""); + }); + + it("nested if — inner renders when both exist", () => { + expect( + parseTemplate( + "[if system:os][if file:X]A[endif][endif]", + vars([ + ["system:os", "linux"], + ["file:X", "v"], + ]), + ), + ).toBe("A"); + }); + + it("nested if/else", () => { + expect( + parseTemplate( + "[if system:os]os[if file:X]A[else]B[endif][endif]", + vars([ + ["system:os", "linux"], + ["file:X", null], + ]), + ), + ).toBe("osB"); + }); + + it("unmatched if → literal text", () => { + // 9. [if file:X]text (no endif) → "[if file:X]text" + expect(parseTemplate("[if file:X]text", vars([["file:X", "v"]]))).toBe("[if file:X]text"); + }); + + it("unmatched if with null var still emits literal tag", () => { + expect(parseTemplate("[if file:X]text", vars([["file:X", null]]))).toBe("[if file:X]text"); + }); + + it("stray endif → literal text", () => { + expect(parseTemplate("a[endif]b", vars([]))).toBe("a[endif]b"); + }); + + it("stray else → literal text", () => { + expect(parseTemplate("a[else]b", vars([]))).toBe("a[else]b"); + }); + + it("multi-line content renders correctly", () => { + // 10. if block spanning multiple lines + const template = "[if file:AGENTS.md]line1\nline2\nline3[endif]"; + expect(parseTemplate(template, vars([["file:AGENTS.md", "c"]]))).toBe("line1\nline2\nline3"); + }); + + it("multi-line if/else block", () => { + const template = "[if file:X]\nA\n[else]\nB\n[endif]"; + expect(parseTemplate(template, vars([["file:X", null]]))).toBe("\nB\n"); + }); + + it("default-template-like structure renders", () => { + const template = + "You are a helpful coding assistant.\n\n[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\n\nThe current working directory is [prompt:cwd].\n"; + expect( + parseTemplate( + template, + vars([ + ["file:AGENTS.md", "RULES"], + ["prompt:cwd", "/proj"], + ]), + ), + ).toBe( + "You are a helpful coding assistant.\n\n\nRULES\n\n\nThe current working directory is /proj.\n", + ); + }); + + it("default-template-like structure without AGENTS.md", () => { + const template = "[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\nThe cwd is [prompt:cwd]."; + expect(parseTemplate(template, vars([["prompt:cwd", "/proj"]]))).toBe("\nThe cwd is /proj."); + }); + }); + + describe("extractVariables", () => { + it("collects insertion + condition keys", () => { + const template = "[system:time] [if file:AGENTS.md][file:AGENTS.md][endif] [if !git:branch]"; + expect(extractVariables(template)).toEqual(["system:time", "file:AGENTS.md", "git:branch"]); + }); + + it("deduplicates keys", () => { + expect(extractVariables("[file:X][if file:X][file:X]")).toEqual(["file:X"]); + }); + + it("returns empty for plain text", () => { + expect(extractVariables("no variables here")).toEqual([]); + }); + + it("ignores unmatched tags", () => { + expect(extractVariables("[if file:X]no endif")).toEqual(["file:X"]); + }); + }); }); diff --git a/packages/system-prompt/src/parser.ts b/packages/system-prompt/src/parser.ts index 5b39b7f..d01e6a8 100644 --- a/packages/system-prompt/src/parser.ts +++ b/packages/system-prompt/src/parser.ts @@ -19,48 +19,48 @@ // ─── Token model ───────────────────────────────────────────────────────────── interface TextToken { - readonly kind: "text"; - readonly value: string; + readonly kind: "text"; + readonly value: string; } interface VarToken { - readonly kind: "var"; - readonly key: string; - readonly raw: string; + readonly kind: "var"; + readonly key: string; + readonly raw: string; } interface IfToken { - readonly kind: "if"; - readonly key: string; - readonly negated: boolean; - readonly raw: string; + readonly kind: "if"; + readonly key: string; + readonly negated: boolean; + readonly raw: string; } interface ElseToken { - readonly kind: "else"; - readonly raw: string; + readonly kind: "else"; + readonly raw: string; } interface EndifToken { - readonly kind: "endif"; - readonly raw: string; + readonly kind: "endif"; + readonly raw: string; } type Token = TextToken | VarToken | IfToken | ElseToken | EndifToken; // ─── Node model (AST) ──────────────────────────────────────────────────────── interface TextNode { - readonly kind: "text"; - readonly value: string; + readonly kind: "text"; + readonly value: string; } interface VarNode { - readonly kind: "var"; - readonly key: string; + readonly kind: "var"; + readonly key: string; } interface IfNode { - kind: "if"; - key: string; - negated: boolean; - thenBranch: Node[]; - else: Node[] | null; - matched: boolean; - readonly raw: string; + kind: "if"; + key: string; + negated: boolean; + thenBranch: Node[]; + else: Node[] | null; + matched: boolean; + readonly raw: string; } type Node = TextNode | VarNode | IfNode; @@ -71,23 +71,23 @@ type Node = TextNode | VarNode | IfNode; * `null` when it is not a recognized tag (then it stays literal text). */ function classifyTag(content: string): Token | null { - const trimmed = content.trim(); - if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` }; - if (trimmed === "else") return { kind: "else", raw: `[${content}]` }; + const trimmed = content.trim(); + if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` }; + if (trimmed === "else") return { kind: "else", raw: `[${content}]` }; - // `[if type:name]` / `[if !type:name]` - const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed); - if (ifMatch) { - const negated = (ifMatch[1] ?? "") === "!"; - const key = ifMatch[2] ?? ""; - return { kind: "if", key, negated, raw: `[${content}]` }; - } + // `[if type:name]` / `[if !type:name]` + const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed); + if (ifMatch) { + const negated = (ifMatch[1] ?? "") === "!"; + const key = ifMatch[2] ?? ""; + return { kind: "if", key, negated, raw: `[${content}]` }; + } - // `[type:name]` — variable insertion (any `word:rest`) - const varMatch = /^(\w+:.*)$/.exec(trimmed); - if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` }; + // `[type:name]` — variable insertion (any `word:rest`) + const varMatch = /^(\w+:.*)$/.exec(trimmed); + if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` }; - return null; + return null; } /** @@ -96,45 +96,45 @@ function classifyTag(content: string): Token | null { * as literal text. */ function tokenize(template: string): Token[] { - const tokens: Token[] = []; - let buf = ""; - let i = 0; - const n = template.length; + const tokens: Token[] = []; + let buf = ""; + let i = 0; + const n = template.length; - const flush = (): void => { - if (buf.length > 0) { - tokens.push({ kind: "text", value: buf }); - buf = ""; - } - }; + const flush = (): void => { + if (buf.length > 0) { + tokens.push({ kind: "text", value: buf }); + buf = ""; + } + }; - while (i < n) { - const ch = template[i]; - if (ch === undefined) break; - if (ch === "[") { - const close = template.indexOf("]", i + 1); - if (close === -1) { - buf += "["; - i++; - continue; - } - const content = template.slice(i + 1, close); - const tag = classifyTag(content); - if (tag !== null) { - flush(); - tokens.push(tag); - i = close + 1; - continue; - } - buf += "["; - i++; - } else { - buf += ch; - i++; - } - } - flush(); - return tokens; + while (i < n) { + const ch = template[i]; + if (ch === undefined) break; + if (ch === "[") { + const close = template.indexOf("]", i + 1); + if (close === -1) { + buf += "["; + i++; + continue; + } + const content = template.slice(i + 1, close); + const tag = classifyTag(content); + if (tag !== null) { + flush(); + tokens.push(tag); + i = close + 1; + continue; + } + buf += "["; + i++; + } else { + buf += ch; + i++; + } + } + flush(); + return tokens; } // ─── Parser (token stream → AST) ───────────────────────────────────────────── @@ -147,99 +147,99 @@ const EMPTY: readonly Node[] = Object.freeze([]) as readonly Node[]; * stray `else`/`endif` (no open `if`) becomes a literal text node. */ function parse(tokens: readonly Token[]): Node[] { - const root: Node[] = []; - const stack: IfNode[] = []; - let current: Node[] = root; + const root: Node[] = []; + const stack: IfNode[] = []; + let current: Node[] = root; - for (const tok of tokens) { - switch (tok.kind) { - case "text": - current.push({ kind: "text", value: tok.value }); - break; - case "var": - current.push({ kind: "var", key: tok.key }); - break; - case "if": { - const node: IfNode = { - kind: "if", - key: tok.key, - negated: tok.negated, - thenBranch: [], - else: null, - matched: true, - raw: tok.raw, - }; - current.push(node); - stack.push(node); - current = node.thenBranch; - break; - } - case "else": { - const top = stack[stack.length - 1]; - if (top !== undefined && top.else === null) { - top.else = []; - current = top.else; - } else { - // stray else (no open if, or if already has an else) → literal - current.push({ kind: "text", value: tok.raw }); - } - break; - } - case "endif": { - const top = stack.pop(); - if (top === undefined) { - // stray endif → literal - current.push({ kind: "text", value: tok.raw }); - break; - } - const parent = stack[stack.length - 1]; - current = parent === undefined ? root : (parent.else ?? parent.thenBranch); - break; - } - } - } + for (const tok of tokens) { + switch (tok.kind) { + case "text": + current.push({ kind: "text", value: tok.value }); + break; + case "var": + current.push({ kind: "var", key: tok.key }); + break; + case "if": { + const node: IfNode = { + kind: "if", + key: tok.key, + negated: tok.negated, + thenBranch: [], + else: null, + matched: true, + raw: tok.raw, + }; + current.push(node); + stack.push(node); + current = node.thenBranch; + break; + } + case "else": { + const top = stack[stack.length - 1]; + if (top !== undefined && top.else === null) { + top.else = []; + current = top.else; + } else { + // stray else (no open if, or if already has an else) → literal + current.push({ kind: "text", value: tok.raw }); + } + break; + } + case "endif": { + const top = stack.pop(); + if (top === undefined) { + // stray endif → literal + current.push({ kind: "text", value: tok.raw }); + break; + } + const parent = stack[stack.length - 1]; + current = parent === undefined ? root : (parent.else ?? parent.thenBranch); + break; + } + } + } - // Any `if` still on the stack never found its `endif` → unmatched. - for (const node of stack) node.matched = false; - return root; + // Any `if` still on the stack never found its `endif` → unmatched. + for (const node of stack) node.matched = false; + return root; } // ─── Renderer (AST → string) ──────────────────────────────────────────────── function variableExists(key: string, vars: ReadonlyMap): boolean { - return vars.has(key) && vars.get(key) !== null; + return vars.has(key) && vars.get(key) !== null; } function render(nodes: readonly Node[], vars: ReadonlyMap): string { - let out = ""; - for (const node of nodes) { - switch (node.kind) { - case "text": - out += node.value; - break; - case "var": - out += vars.get(node.key) ?? ""; - break; - case "if": { - if (node.matched) { - const exists = variableExists(node.key, vars); - const takeThen = node.negated ? !exists : exists; - const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY); - out += render(branch, vars); - } else { - // Unmatched `if` → the tag is literal text; content still renders. - out += node.raw; - out += render(node.thenBranch, vars); - if (node.else !== null) { - out += "[else]"; - out += render(node.else, vars); - } - } - break; - } - } - } - return out; + let out = ""; + for (const node of nodes) { + switch (node.kind) { + case "text": + out += node.value; + break; + case "var": + out += vars.get(node.key) ?? ""; + break; + case "if": { + if (node.matched) { + const exists = variableExists(node.key, vars); + const takeThen = node.negated ? !exists : exists; + const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY); + out += render(branch, vars); + } else { + // Unmatched `if` → the tag is literal text; content still renders. + out += node.raw; + out += render(node.thenBranch, vars); + if (node.else !== null) { + out += "[else]"; + out += render(node.else, vars); + } + } + break; + } + } + } + return out; } // ─── Public API ────────────────────────────────────────────────────────────── @@ -254,9 +254,9 @@ function render(nodes: readonly Node[], vars: ReadonlyMap * - Unmatched `[if]`/`[endif]` tags pass through as literal text. */ export function parseTemplate(template: string, vars: ReadonlyMap): string { - const tokens = tokenize(template); - const ast = parse(tokens); - return render(ast, vars); + const tokens = tokenize(template); + const ast = parse(tokens); + return render(ast, vars); } /** @@ -266,16 +266,16 @@ export function parseTemplate(template: string, vars: ReadonlyMap(); - const keys: string[] = []; - for (const tok of tokens) { - if (tok.kind === "var" || tok.kind === "if") { - if (!seen.has(tok.key)) { - seen.add(tok.key); - keys.push(tok.key); - } - } - } - return keys; + const tokens = tokenize(template); + const seen = new Set(); + const keys: string[] = []; + for (const tok of tokens) { + if (tok.kind === "var" || tok.kind === "if") { + if (!seen.has(tok.key)) { + seen.add(tok.key); + keys.push(tok.key); + } + } + } + return keys; } diff --git a/packages/system-prompt/src/resolver.test.ts b/packages/system-prompt/src/resolver.test.ts index d55af07..a92e43f 100644 --- a/packages/system-prompt/src/resolver.test.ts +++ b/packages/system-prompt/src/resolver.test.ts @@ -4,287 +4,287 @@ import { resolveVariables } from "./resolver.js"; /** A spawn that returns canned output per command (joined argv → result). */ function fakeSpawn( - table: ReadonlyMap | GitSpawnResult, + table: ReadonlyMap | GitSpawnResult, ): ResolverAdapters["spawn"] { - return async (command) => { - if (table instanceof Map) { - return table.get(command.join(" ")) ?? { stdout: "", stderr: "", exitCode: 128 }; - } - return table; - }; + return async (command) => { + if (table instanceof Map) { + return table.get(command.join(" ")) ?? { stdout: "", stderr: "", exitCode: 128 }; + } + return table; + }; } function fakeFs(files: ReadonlyMap): ResolverFs { - return { - readText: async (path: string) => files.get(path) ?? "", - exists: async (path: string) => files.has(path), - }; + return { + readText: async (path: string) => files.get(path) ?? "", + exists: async (path: string) => files.has(path), + }; } const failSpawn = (): ResolverAdapters["spawn"] => async () => ({ - stdout: "", - stderr: "not a git repo", - exitCode: 128, + stdout: "", + stderr: "not a git repo", + exitCode: 128, }); const fixedNow = new Date("2024-06-15T12:30:00.000Z"); describe("resolver", () => { - describe("system variables", () => { - it("resolves system:* to non-null strings", async () => { - // 11. system:time, system:date, system:os, system:hostname - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - platform: async () => "linux", - hostname: async () => "myhost", - }); - - expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z"); - expect(map.get("system:date")).toBe("2024-06-15"); - expect(map.get("system:os")).toBe("linux"); - expect(map.get("system:hostname")).toBe("myhost"); - }); - - it("prompt:cwd is the cwd, model/conversation_id follow context", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { context: { model: "gpt-4", conversationId: "conv-1" } }, - ); - - expect(map.get("prompt:cwd")).toBe("/proj"); - expect(map.get("prompt:model")).toBe("gpt-4"); - expect(map.get("prompt:conversation_id")).toBe("conv-1"); - }); - - it("prompt:model / prompt:conversation_id are null when absent", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("prompt:model")).toBeNull(); - expect(map.get("prompt:conversation_id")).toBeNull(); - }); - }); - - describe("system:os rich resolution", () => { - it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => { - const files = new Map([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS"); - }); - - it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => { - const files = new Map([ - ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Debian 12"); - }); - - it("appends (WSL) when WSLInterop exists", async () => { - const files = new Map([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], - ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); - }); - - it("detects WSL via 'microsoft' in /proc/version", async () => { - const files = new Map([ - ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], - ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"], - ]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); - }); - - it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => { - const files = new Map([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]); - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(files), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("Linux (WSL)"); - }); - - it("returns plain 'linux' when no os-release and no WSL", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - platform: async () => "linux", - }); - expect(map.get("system:os")).toBe("linux"); - }); - - it("returns platform as-is for non-Linux (darwin)", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - platform: async () => "darwin", - }); - expect(map.get("system:os")).toBe("darwin"); - }); - }); - - describe("file variables", () => { - it("reads a file relative to cwd", async () => { - // 12. file variable reads relative path; missing → null - const files = new Map([["/proj/AGENTS.md", "rules"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:AGENTS.md"] }, - ); - - expect(map.get("file:AGENTS.md")).toBe("rules"); - }); - - it("missing file → null", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { referencedKeys: ["file:missing.md"] }, - ); - - expect(map.get("file:missing.md")).toBeNull(); - }); - - it("absolute path reads from absolute location", async () => { - const files = new Map([["/etc/config", "data"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:/etc/config"] }, - ); - - expect(map.get("file:/etc/config")).toBe("data"); - }); - - it("reads nested relative path", async () => { - const files = new Map([["/proj/src/foo.ts", "export {}"]]); - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(files), - now: () => fixedNow, - }, - { referencedKeys: ["file:src/foo.ts"] }, - ); - - expect(map.get("file:src/foo.ts")).toBe("export {}"); - }); - - it("non-file referenced keys are not added to the map", async () => { - const map = await resolveVariables( - "/proj", - { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }, - { referencedKeys: ["unknown:foo"] }, - ); - - expect(map.has("unknown:foo")).toBe(false); - }); - }); - - describe("git variables", () => { - it("git:branch returns the branch name", async () => { - // 13. git:branch via injected spawn - const table = new Map([ - ["git rev-parse --abbrev-ref HEAD", { stdout: "feature/x\n", stderr: "", exitCode: 0 }], - ["git status --short", { stdout: " M a.ts\n", stderr: "", exitCode: 0 }], - ]); - const map = await resolveVariables("/proj", { - spawn: fakeSpawn(table), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBe("feature/x"); - expect(map.get("git:status")).toBe(" M a.ts"); - }); - - it("non-git cwd → null", async () => { - const map = await resolveVariables("/proj", { - spawn: failSpawn(), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBeNull(); - expect(map.get("git:status")).toBeNull(); - }); - - it("throwing spawn → null", async () => { - const throwingSpawn = async (): Promise => { - throw new Error("git not installed"); - }; - const map = await resolveVariables("/proj", { - spawn: throwingSpawn, - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:branch")).toBeNull(); - expect(map.get("git:status")).toBeNull(); - }); - - it("clean repo → git:status is empty string (existing)", async () => { - const table = new Map([ - ["git rev-parse --abbrev-ref HEAD", { stdout: "main\n", stderr: "", exitCode: 0 }], - ["git status --short", { stdout: "", stderr: "", exitCode: 0 }], - ]); - const map = await resolveVariables("/proj", { - spawn: fakeSpawn(table), - fs: fakeFs(new Map()), - now: () => fixedNow, - }); - - expect(map.get("git:status")).toBe(""); - }); - }); + describe("system variables", () => { + it("resolves system:* to non-null strings", async () => { + // 11. system:time, system:date, system:os, system:hostname + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + platform: async () => "linux", + hostname: async () => "myhost", + }); + + expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z"); + expect(map.get("system:date")).toBe("2024-06-15"); + expect(map.get("system:os")).toBe("linux"); + expect(map.get("system:hostname")).toBe("myhost"); + }); + + it("prompt:cwd is the cwd, model/conversation_id follow context", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { context: { model: "gpt-4", conversationId: "conv-1" } }, + ); + + expect(map.get("prompt:cwd")).toBe("/proj"); + expect(map.get("prompt:model")).toBe("gpt-4"); + expect(map.get("prompt:conversation_id")).toBe("conv-1"); + }); + + it("prompt:model / prompt:conversation_id are null when absent", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("prompt:model")).toBeNull(); + expect(map.get("prompt:conversation_id")).toBeNull(); + }); + }); + + describe("system:os rich resolution", () => { + it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => { + const files = new Map([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS"); + }); + + it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => { + const files = new Map([ + ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Debian 12"); + }); + + it("appends (WSL) when WSLInterop exists", async () => { + const files = new Map([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], + ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); + }); + + it("detects WSL via 'microsoft' in /proc/version", async () => { + const files = new Map([ + ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'], + ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"], + ]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)"); + }); + + it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => { + const files = new Map([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]); + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(files), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("Linux (WSL)"); + }); + + it("returns plain 'linux' when no os-release and no WSL", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + platform: async () => "linux", + }); + expect(map.get("system:os")).toBe("linux"); + }); + + it("returns platform as-is for non-Linux (darwin)", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + platform: async () => "darwin", + }); + expect(map.get("system:os")).toBe("darwin"); + }); + }); + + describe("file variables", () => { + it("reads a file relative to cwd", async () => { + // 12. file variable reads relative path; missing → null + const files = new Map([["/proj/AGENTS.md", "rules"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:AGENTS.md"] }, + ); + + expect(map.get("file:AGENTS.md")).toBe("rules"); + }); + + it("missing file → null", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { referencedKeys: ["file:missing.md"] }, + ); + + expect(map.get("file:missing.md")).toBeNull(); + }); + + it("absolute path reads from absolute location", async () => { + const files = new Map([["/etc/config", "data"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:/etc/config"] }, + ); + + expect(map.get("file:/etc/config")).toBe("data"); + }); + + it("reads nested relative path", async () => { + const files = new Map([["/proj/src/foo.ts", "export {}"]]); + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(files), + now: () => fixedNow, + }, + { referencedKeys: ["file:src/foo.ts"] }, + ); + + expect(map.get("file:src/foo.ts")).toBe("export {}"); + }); + + it("non-file referenced keys are not added to the map", async () => { + const map = await resolveVariables( + "/proj", + { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }, + { referencedKeys: ["unknown:foo"] }, + ); + + expect(map.has("unknown:foo")).toBe(false); + }); + }); + + describe("git variables", () => { + it("git:branch returns the branch name", async () => { + // 13. git:branch via injected spawn + const table = new Map([ + ["git rev-parse --abbrev-ref HEAD", { stdout: "feature/x\n", stderr: "", exitCode: 0 }], + ["git status --short", { stdout: " M a.ts\n", stderr: "", exitCode: 0 }], + ]); + const map = await resolveVariables("/proj", { + spawn: fakeSpawn(table), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBe("feature/x"); + expect(map.get("git:status")).toBe(" M a.ts"); + }); + + it("non-git cwd → null", async () => { + const map = await resolveVariables("/proj", { + spawn: failSpawn(), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBeNull(); + expect(map.get("git:status")).toBeNull(); + }); + + it("throwing spawn → null", async () => { + const throwingSpawn = async (): Promise => { + throw new Error("git not installed"); + }; + const map = await resolveVariables("/proj", { + spawn: throwingSpawn, + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:branch")).toBeNull(); + expect(map.get("git:status")).toBeNull(); + }); + + it("clean repo → git:status is empty string (existing)", async () => { + const table = new Map([ + ["git rev-parse --abbrev-ref HEAD", { stdout: "main\n", stderr: "", exitCode: 0 }], + ["git status --short", { stdout: "", stderr: "", exitCode: 0 }], + ]); + const map = await resolveVariables("/proj", { + spawn: fakeSpawn(table), + fs: fakeFs(new Map()), + now: () => fixedNow, + }); + + expect(map.get("git:status")).toBe(""); + }); + }); }); diff --git a/packages/system-prompt/src/resolver.ts b/packages/system-prompt/src/resolver.ts index e864554..8b397d2 100644 --- a/packages/system-prompt/src/resolver.ts +++ b/packages/system-prompt/src/resolver.ts @@ -17,9 +17,9 @@ import { isAbsolute, resolve as resolvePath } from "node:path"; /** Result of a spawned command (used for git). */ export interface GitSpawnResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; } /** @@ -27,73 +27,73 @@ export interface GitSpawnResult { * resolver (e.g. git not installed, bad cwd). */ export type GitSpawn = ( - command: readonly string[], - opts: { readonly cwd: string }, + command: readonly string[], + opts: { readonly cwd: string }, ) => Promise; /** Filesystem adapter — the read effects the resolver needs. */ export interface ResolverFs { - readonly readText: (path: string) => Promise; - readonly exists: (path: string) => Promise; + readonly readText: (path: string) => Promise; + readonly exists: (path: string) => Promise; } /** Injected effects + optional overridable clocks for deterministic tests. */ export interface ResolverAdapters { - /** Run a command (git) and capture stdout. */ - readonly spawn: GitSpawn; - /** File read effects. */ - readonly fs: ResolverFs; - /** Override the current time (defaults to `new Date()`). */ - readonly now?: () => Date; - /** - * Override `process.platform` (defaults to the real platform). Async so a - * remote adapter can run `uname -s` over SSH. - */ - readonly platform?: () => Promise; - /** - * Override the hostname (defaults to `os.hostname()`). Async so a remote - * adapter can run `hostname` over SSH. - */ - readonly hostname?: () => Promise; + /** Run a command (git) and capture stdout. */ + readonly spawn: GitSpawn; + /** File read effects. */ + readonly fs: ResolverFs; + /** Override the current time (defaults to `new Date()`). */ + readonly now?: () => Date; + /** + * Override `process.platform` (defaults to the real platform). Async so a + * remote adapter can run `uname -s` over SSH. + */ + readonly platform?: () => Promise; + /** + * Override the hostname (defaults to `os.hostname()`). Async so a remote + * adapter can run `hostname` over SSH. + */ + readonly hostname?: () => Promise; } /** Per-construction context forwarded by the session-orchestrator. */ export interface ResolverContext { - readonly model?: string; - readonly conversationId?: string; - readonly workspaceId?: string; + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; } export interface ResolveOptions { - readonly context?: ResolverContext; - /** Variable keys referenced by the template (drives dynamic `file:` reads). */ - readonly referencedKeys?: readonly string[]; + readonly context?: ResolverContext; + /** Variable keys referenced by the template (drives dynamic `file:` reads). */ + readonly referencedKeys?: readonly string[]; } /** Run a git subcommand in `cwd`; return raw stdout on success, else null. */ async function runGit( - args: readonly string[], - cwd: string, - spawn: GitSpawn, + args: readonly string[], + cwd: string, + spawn: GitSpawn, ): Promise { - try { - const res = await spawn(["git", ...args], { cwd }); - if (res.exitCode !== 0) return null; - return res.stdout; - } catch { - return null; - } + try { + const res = await spawn(["git", ...args], { cwd }); + if (res.exitCode !== 0) return null; + return res.stdout; + } catch { + return null; + } } /** Read a file (relative to cwd, or absolute). Missing/error → null. */ async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise { - const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath); - try { - if (!(await fs.exists(abs))) return null; - return await fs.readText(abs); - } catch { - return null; - } + const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath); + try { + if (!(await fs.exists(abs))) return null; + return await fs.readText(abs); + } catch { + return null; + } } /** @@ -109,38 +109,38 @@ async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise< * to the base platform string). The `platform` override is honored for tests. */ async function resolveOs(platform: string, fs: ResolverFs): Promise { - if (platform !== "linux") return platform; - - let distro: string | null = null; - const osRelease = await readFile("/etc/os-release", "/", fs); - if (osRelease !== null) { - const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m); - if (pretty?.[1] !== undefined) { - distro = pretty[1]; - } else { - const name = osRelease.match(/^NAME="(.+)"/m); - const version = osRelease.match(/^VERSION_ID="(.+)"/m); - if (name?.[1] !== undefined) { - distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1]; - } - } - } - - let isWsl = false; - const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs); - if (wslInterop !== null) { - isWsl = true; - } else { - const procVersion = await readFile("/proc/version", "/", fs); - if (procVersion !== null && /microsoft/i.test(procVersion)) { - isWsl = true; - } - } - - if (distro !== null) { - return isWsl ? `${distro} (WSL)` : distro; - } - return isWsl ? "Linux (WSL)" : "linux"; + if (platform !== "linux") return platform; + + let distro: string | null = null; + const osRelease = await readFile("/etc/os-release", "/", fs); + if (osRelease !== null) { + const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m); + if (pretty?.[1] !== undefined) { + distro = pretty[1]; + } else { + const name = osRelease.match(/^NAME="(.+)"/m); + const version = osRelease.match(/^VERSION_ID="(.+)"/m); + if (name?.[1] !== undefined) { + distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1]; + } + } + } + + let isWsl = false; + const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs); + if (wslInterop !== null) { + isWsl = true; + } else { + const procVersion = await readFile("/proc/version", "/", fs); + if (procVersion !== null && /microsoft/i.test(procVersion)) { + isWsl = true; + } + } + + if (distro !== null) { + return isWsl ? `${distro} (WSL)` : distro; + } + return isWsl ? "Linux (WSL)" : "linux"; } /** @@ -151,45 +151,45 @@ async function resolveOs(platform: string, fs: ResolverFs): Promise { * the template). Unknown types are intentionally left out of the map. */ export async function resolveVariables( - cwd: string, - adapters: ResolverAdapters, - options?: ResolveOptions, + cwd: string, + adapters: ResolverAdapters, + options?: ResolveOptions, ): Promise> { - const ctx = options?.context; - const referencedKeys = options?.referencedKeys; - const now = adapters.now?.() ?? new Date(); - const vars = new Map(); - - // ── system:* ──────────────────────────────────────────────────────────── - vars.set("system:time", now.toISOString()); - vars.set("system:date", now.toISOString().slice(0, 10)); - const platform = (await adapters.platform?.()) ?? process.platform; - vars.set("system:os", await resolveOs(platform, adapters.fs)); - vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname()); - - // ── prompt:* ──────────────────────────────────────────────────────────── - vars.set("prompt:cwd", cwd); - vars.set("prompt:model", ctx?.model ?? null); - vars.set("prompt:conversation_id", ctx?.conversationId ?? null); - vars.set("prompt:workspace_id", ctx?.workspaceId ?? null); - - // ── git:* ──────────────────────────────────────────────────────────────── - // branch is a single value — trim fully; status keeps its leading status - // indicators, dropping only the trailing newline (trimEnd). - const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, adapters.spawn); - vars.set("git:branch", branch === null ? null : branch.trim()); - const status = await runGit(["status", "--short"], cwd, adapters.spawn); - vars.set("git:status", status === null ? null : status.trimEnd()); - - // ── file: (dynamic — only those referenced by the template) ──────── - if (referencedKeys !== undefined) { - for (const key of referencedKeys) { - if (key.startsWith("file:")) { - const filePath = key.slice("file:".length); - vars.set(key, await readFile(filePath, cwd, adapters.fs)); - } - } - } - - return vars; + const ctx = options?.context; + const referencedKeys = options?.referencedKeys; + const now = adapters.now?.() ?? new Date(); + const vars = new Map(); + + // ── system:* ──────────────────────────────────────────────────────────── + vars.set("system:time", now.toISOString()); + vars.set("system:date", now.toISOString().slice(0, 10)); + const platform = (await adapters.platform?.()) ?? process.platform; + vars.set("system:os", await resolveOs(platform, adapters.fs)); + vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname()); + + // ── prompt:* ──────────────────────────────────────────────────────────── + vars.set("prompt:cwd", cwd); + vars.set("prompt:model", ctx?.model ?? null); + vars.set("prompt:conversation_id", ctx?.conversationId ?? null); + vars.set("prompt:workspace_id", ctx?.workspaceId ?? null); + + // ── git:* ──────────────────────────────────────────────────────────────── + // branch is a single value — trim fully; status keeps its leading status + // indicators, dropping only the trailing newline (trimEnd). + const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, adapters.spawn); + vars.set("git:branch", branch === null ? null : branch.trim()); + const status = await runGit(["status", "--short"], cwd, adapters.spawn); + vars.set("git:status", status === null ? null : status.trimEnd()); + + // ── file: (dynamic — only those referenced by the template) ──────── + if (referencedKeys !== undefined) { + for (const key of referencedKeys) { + if (key.startsWith("file:")) { + const filePath = key.slice("file:".length); + vars.set(key, await readFile(filePath, cwd, adapters.fs)); + } + } + } + + return vars; } diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 37c1c0d..08e3d33 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -5,223 +5,223 @@ import { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js"; /** In-memory StorageNamespace for tests. */ function memoryStorage(): StorageNamespace { - const store = new Map(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - has: async (key: string) => store.has(key), - keys: async (prefix?: string) => - [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const store = new Map(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + has: async (key: string) => store.has(key), + keys: async (prefix?: string) => + [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } function fakeFs(files: ReadonlyMap): ResolverFs { - return { - readText: async (path: string) => files.get(path) ?? "", - exists: async (path: string) => files.has(path), - }; + return { + readText: async (path: string) => files.get(path) ?? "", + exists: async (path: string) => files.has(path), + }; } const failSpawn = async (): Promise => ({ - stdout: "", - stderr: "", - exitCode: 128, + stdout: "", + stderr: "", + exitCode: 128, }); function adapters(files: ReadonlyMap): ResolverAdapters { - return { - spawn: failSpawn, - fs: fakeFs(files), - now: () => new Date("2024-06-15T12:30:00.000Z"), - platform: () => "linux", - hostname: () => "myhost", - }; + return { + spawn: failSpawn, + fs: fakeFs(files), + now: () => new Date("2024-06-15T12:30:00.000Z"), + platform: () => "linux", + hostname: () => "myhost", + }; } describe("system-prompt service", () => { - it("construct persists and returns the resolved string", async () => { - // 14. construct writes to storage and returns the resolved string. - const storage = memoryStorage(); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); - - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("RULES"); - expect(result).toContain("/proj"); - // persisted under resolved: - expect(await storage.get("resolved:conv-1")).toBe(result); - }); - - it("get returns persisted value after construct", async () => { - // 15. after construct, get returns the same string. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-2")).toBeNull(); - - const result = await service.construct("conv-2", "/proj"); - expect(await service.get("conv-2")).toBe(result); - }); - - it("get returns null before construct", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - expect(await service.get("never-constructed")).toBeNull(); - }); - - it("empty/no template stored → default template → non-empty", async () => { - // 16. no template stored → default template used → resolves to non-empty. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), // no AGENTS.md - }); - - const result = await service.construct("conv-3", "/proj"); - - expect(result.length).toBeGreaterThan(0); - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("/proj"); - // no AGENTS.md file → the [if file:AGENTS.md] block is omitted - expect(result).not.toContain("AGENTS.md"); - }); - - it("stored template is used instead of default", async () => { - const storage = memoryStorage(); - await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-4", "/work"); - expect(result).toBe("cwd=/work os=linux"); - }); - - it("empty stored template → empty string", async () => { - const storage = memoryStorage(); - await storage.set("template", ""); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-5", "/proj"); - expect(result).toBe(""); - expect(await service.get("conv-5")).toBe(""); - }); - - it("construct is independent per conversation", async () => { - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const a = await service.construct("conv-a", "/dir-a"); - const b = await service.construct("conv-b", "/dir-b"); - - expect(a).toBe("/dir-a"); - expect(b).toBe("/dir-b"); - expect(await service.get("conv-a")).toBe("/dir-a"); - expect(await service.get("conv-b")).toBe("/dir-b"); - }); - - it("DEFAULT_TEMPLATE contains the expected structure", () => { - expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); - expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); - }); - - it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { - // 1. never constructed → both fields null. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - const meta = await service.getWithMeta("never-constructed"); - expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); - }); - - it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { - // 2. after construct → prompt + exact cwd passed to construct. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); - const meta = await service.getWithMeta("conv-meta"); - - expect(meta.prompt).toBe(result); - expect(meta.cwd).toBe("/proj"); - }); - - it("get still returns the same value as before (backward compat)", async () => { - // 3. get() behavior is unchanged by the additive getWithMeta. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-bc")).toBeNull(); - - const result = await service.construct("conv-bc", "/proj"); - expect(await service.get("conv-bc")).toBe(result); - }); - - it("construct called twice with different cwds stores the latest cwd", async () => { - // 4. second construct overwrites the cwd (not the first). - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - await service.construct("conv-twice", "/first"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); - - const second = await service.construct("conv-twice", "/second"); - expect(second).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); - }); - - it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { - // 5. getWithMeta reflects the latest construct, not the first. - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const first = await service.construct("conv-second", "/dir-a"); - const firstMeta = await service.getWithMeta("conv-second"); - expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); - - const second = await service.construct("conv-second", "/dir-b"); - const secondMeta = await service.getWithMeta("conv-second"); - expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); - expect(secondMeta.cwd).not.toBe("/dir-a"); - }); + it("construct persists and returns the resolved string", async () => { + // 14. construct writes to storage and returns the resolved string. + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); + + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("RULES"); + expect(result).toContain("/proj"); + // persisted under resolved: + expect(await storage.get("resolved:conv-1")).toBe(result); + }); + + it("get returns persisted value after construct", async () => { + // 15. after construct, get returns the same string. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-2")).toBeNull(); + + const result = await service.construct("conv-2", "/proj"); + expect(await service.get("conv-2")).toBe(result); + }); + + it("get returns null before construct", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + expect(await service.get("never-constructed")).toBeNull(); + }); + + it("empty/no template stored → default template → non-empty", async () => { + // 16. no template stored → default template used → resolves to non-empty. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), // no AGENTS.md + }); + + const result = await service.construct("conv-3", "/proj"); + + expect(result.length).toBeGreaterThan(0); + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("/proj"); + // no AGENTS.md file → the [if file:AGENTS.md] block is omitted + expect(result).not.toContain("AGENTS.md"); + }); + + it("stored template is used instead of default", async () => { + const storage = memoryStorage(); + await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-4", "/work"); + expect(result).toBe("cwd=/work os=linux"); + }); + + it("empty stored template → empty string", async () => { + const storage = memoryStorage(); + await storage.set("template", ""); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-5", "/proj"); + expect(result).toBe(""); + expect(await service.get("conv-5")).toBe(""); + }); + + it("construct is independent per conversation", async () => { + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const a = await service.construct("conv-a", "/dir-a"); + const b = await service.construct("conv-b", "/dir-b"); + + expect(a).toBe("/dir-a"); + expect(b).toBe("/dir-b"); + expect(await service.get("conv-a")).toBe("/dir-a"); + expect(await service.get("conv-b")).toBe("/dir-b"); + }); + + it("DEFAULT_TEMPLATE contains the expected structure", () => { + expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); + expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); + }); + + it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { + // 1. never constructed → both fields null. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const meta = await service.getWithMeta("never-constructed"); + expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); + }); + + it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { + // 2. after construct → prompt + exact cwd passed to construct. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); + const meta = await service.getWithMeta("conv-meta"); + + expect(meta.prompt).toBe(result); + expect(meta.cwd).toBe("/proj"); + }); + + it("get still returns the same value as before (backward compat)", async () => { + // 3. get() behavior is unchanged by the additive getWithMeta. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-bc")).toBeNull(); + + const result = await service.construct("conv-bc", "/proj"); + expect(await service.get("conv-bc")).toBe(result); + }); + + it("construct called twice with different cwds stores the latest cwd", async () => { + // 4. second construct overwrites the cwd (not the first). + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.construct("conv-twice", "/first"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); + + const second = await service.construct("conv-twice", "/second"); + expect(second).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); + }); + + it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { + // 5. getWithMeta reflects the latest construct, not the first. + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const first = await service.construct("conv-second", "/dir-a"); + const firstMeta = await service.getWithMeta("conv-second"); + expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); + + const second = await service.construct("conv-second", "/dir-b"); + const secondMeta = await service.getWithMeta("conv-second"); + expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); + expect(secondMeta.cwd).not.toBe("/dir-a"); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index 8d6ede5..34ecbe7 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -29,20 +29,20 @@ const TEMPLATE_KEY = "template"; const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`; const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`; const resolvedComputerIdKey = (conversationId: string): string => - `resolved-computer:${conversationId}`; + `resolved-computer:${conversationId}`; export interface SystemPromptServiceDeps { - /** Namespaced KV (`host.storage("system-prompt")`). */ - readonly storage: StorageNamespace; - /** Injected effects for variable resolution (local). */ - readonly adapters: ResolverAdapters; - /** - * Optional: build remote-backed adapters for a given computerId. When - * `construct` is called with a `computerId`, this is invoked to obtain - * adapters that read/run commands on the REMOTE machine (via the - * ExecBackend/SSH). Absent → falls back to the local `adapters`. - */ - readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise; + /** Namespaced KV (`host.storage("system-prompt")`). */ + readonly storage: StorageNamespace; + /** Injected effects for variable resolution (local). */ + readonly adapters: ResolverAdapters; + /** + * Optional: build remote-backed adapters for a given computerId. When + * `construct` is called with a `computerId`, this is invoked to obtain + * adapters that read/run commands on the REMOTE machine (via the + * ExecBackend/SSH). Absent → falls back to the local `adapters`. + */ + readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise; } /** @@ -50,63 +50,63 @@ export interface SystemPromptServiceDeps { * State is owned (not ambient): the storage reference lives in this closure. */ export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService { - return { - async construct(conversationId, cwd, context) { - let template = await deps.storage.get(TEMPLATE_KEY); - if (template === null) template = DEFAULT_TEMPLATE; + return { + async construct(conversationId, cwd, context) { + let template = await deps.storage.get(TEMPLATE_KEY); + if (template === null) template = DEFAULT_TEMPLATE; - const referencedKeys = extractVariables(template); - const resolverContext: ResolverContext = { - conversationId, - ...(context?.model !== undefined ? { model: context.model } : {}), - ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), - }; + const referencedKeys = extractVariables(template); + const resolverContext: ResolverContext = { + conversationId, + ...(context?.model !== undefined ? { model: context.model } : {}), + ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), + }; - // Select adapters: when computerId is set, use remote-backed adapters - // (read files / run commands on the REMOTE machine via SSH). Otherwise - // use the local adapters. - const computerId = context?.computerId; - const adapters = - computerId !== undefined && deps.resolveRemoteAdapters !== undefined - ? await deps.resolveRemoteAdapters(computerId, cwd) - : deps.adapters; + // Select adapters: when computerId is set, use remote-backed adapters + // (read files / run commands on the REMOTE machine via SSH). Otherwise + // use the local adapters. + const computerId = context?.computerId; + const adapters = + computerId !== undefined && deps.resolveRemoteAdapters !== undefined + ? await deps.resolveRemoteAdapters(computerId, cwd) + : deps.adapters; - const vars = await resolveVariables(cwd, adapters, { - context: resolverContext, - referencedKeys, - }); - const result = parseTemplate(template, vars); + const vars = await resolveVariables(cwd, adapters, { + context: resolverContext, + referencedKeys, + }); + const result = parseTemplate(template, vars); - await deps.storage.set(resolvedKey(conversationId), result); - await deps.storage.set(resolvedCwdKey(conversationId), cwd); - // Store the computerId (or empty string for local) so the cache can be - // invalidated when the computer changes. - await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? ""); - return result; - }, + await deps.storage.set(resolvedKey(conversationId), result); + await deps.storage.set(resolvedCwdKey(conversationId), cwd); + // Store the computerId (or empty string for local) so the cache can be + // invalidated when the computer changes. + await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? ""); + return result; + }, - async get(conversationId) { - return deps.storage.get(resolvedKey(conversationId)); - }, + async get(conversationId) { + return deps.storage.get(resolvedKey(conversationId)); + }, - async getWithMeta(conversationId) { - const [prompt, cwd, computerIdStored] = await Promise.all([ - deps.storage.get(resolvedKey(conversationId)), - deps.storage.get(resolvedCwdKey(conversationId)), - deps.storage.get(resolvedComputerIdKey(conversationId)), - ]); - // Empty string → null (local, no computerId). Non-empty → the alias. - const computerId = computerIdStored === null ? null : computerIdStored || null; - return { prompt, cwd, computerId }; - }, + async getWithMeta(conversationId) { + const [prompt, cwd, computerIdStored] = await Promise.all([ + deps.storage.get(resolvedKey(conversationId)), + deps.storage.get(resolvedCwdKey(conversationId)), + deps.storage.get(resolvedComputerIdKey(conversationId)), + ]); + // Empty string → null (local, no computerId). Non-empty → the alias. + const computerId = computerIdStored === null ? null : computerIdStored || null; + return { prompt, cwd, computerId }; + }, - async getTemplate() { - const stored = await deps.storage.get(TEMPLATE_KEY); - return stored ?? DEFAULT_TEMPLATE; - }, + async getTemplate() { + const stored = await deps.storage.get(TEMPLATE_KEY); + return stored ?? DEFAULT_TEMPLATE; + }, - async setTemplate(template) { - await deps.storage.set(TEMPLATE_KEY, template); - }, - }; + async setTemplate(template) { + await deps.storage.set(TEMPLATE_KEY, template); + }, + }; } diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index a9fe3ca..534814e 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -15,46 +15,46 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel"; * no per-turn reconstruction). */ export interface SystemPromptService { - /** - * Resolve the template against the current environment and persist the - * result under `resolved:`. Returns the resolved string. - * When no template is stored, the built-in default template is used. An - * empty template yields an empty string. - * - * When `context.computerId` is set, the resolver uses remote-backed adapters - * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via - * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. - */ - construct( - conversationId: string, - cwd: string, - context?: { - readonly model?: string; - readonly workspaceId?: string; - readonly computerId?: string; - }, - ): Promise; + /** + * Resolve the template against the current environment and persist the + * result under `resolved:`. Returns the resolved string. + * When no template is stored, the built-in default template is used. An + * empty template yields an empty string. + * + * When `context.computerId` is set, the resolver uses remote-backed adapters + * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via + * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. + */ + construct( + conversationId: string, + cwd: string, + context?: { + readonly model?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, + ): Promise; - /** Read the persisted resolved system prompt, or `null` if never constructed. */ - get(conversationId: string): Promise; + /** Read the persisted resolved system prompt, or `null` if never constructed. */ + get(conversationId: string): Promise; - /** - * Read the persisted resolved system prompt AND the cwd + computerId it was - * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if - * never constructed. Consumers use this to detect whether the cached prompt - * is stale relative to the current effective cwd or computerId. - */ - getWithMeta(conversationId: string): Promise<{ - readonly prompt: string | null; - readonly cwd: string | null; - readonly computerId: string | null; - }>; + /** + * Read the persisted resolved system prompt AND the cwd + computerId it was + * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if + * never constructed. Consumers use this to detect whether the cached prompt + * is stale relative to the current effective cwd or computerId. + */ + getWithMeta(conversationId: string): Promise<{ + readonly prompt: string | null; + readonly cwd: string | null; + readonly computerId: string | null; + }>; - /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ - getTemplate(): Promise; + /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ + getTemplate(): Promise; - /** Set (upsert) the global template. An empty string means "no system prompt". */ - setTemplate(template: string): Promise; + /** Set (upsert) the global template. An empty string means "no system prompt". */ + setTemplate(template: string): Promise; } /** @@ -62,4 +62,4 @@ export interface SystemPromptService { * session-orchestrator imports to reach the builder — no string-keyed lookup. */ export const systemPromptHandle: ServiceHandle = - defineService("system-prompt"); + defineService("system-prompt"); diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json index 883420c..a83400a 100644 --- a/packages/system-prompt/tsconfig.json +++ b/packages/system-prompt/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../transport-contract" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../transport-contract" }] } diff --git a/packages/throughput-store/package.json b/packages/throughput-store/package.json index 9624c7c..b0c1e23 100644 --- a/packages/throughput-store/package.json +++ b/packages/throughput-store/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/throughput-store", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/throughput-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/throughput-store/src/aggregate.test.ts b/packages/throughput-store/src/aggregate.test.ts index 27b800c..a712c8f 100644 --- a/packages/throughput-store/src/aggregate.test.ts +++ b/packages/throughput-store/src/aggregate.test.ts @@ -2,49 +2,49 @@ import { describe, expect, it } from "vitest"; import { aggregateSamples, type ThroughputSample } from "./aggregate.js"; const S = (model: string, ts: number, outputTokens: number, genMs: number): ThroughputSample => ({ - model, - ts, - outputTokens, - genMs, + model, + ts, + outputTokens, + genMs, }); describe("aggregateSamples", () => { - it("token-weights tok/s (Σtokens / Σgen-seconds), so large turns dominate", () => { - const samples = [ - S("claude/haiku", 100, 1000, 10_000), // 100 tok/s, big turn - S("claude/haiku", 200, 10, 1000), // 10 tok/s, small turn - ]; - const [row] = aggregateSamples(samples, 0, 1000); - expect(row?.model).toBe("claude/haiku"); - // 1010 tokens / 11 s = 91.82, NOT the simple mean (55) - expect(row?.tokensPerSecond).toBeCloseTo(91.82, 1); - expect(row?.totalOutputTokens).toBe(1010); - expect(row?.totalGenMs).toBe(11_000); - expect(row?.turns).toBe(2); - }); + it("token-weights tok/s (Σtokens / Σgen-seconds), so large turns dominate", () => { + const samples = [ + S("claude/haiku", 100, 1000, 10_000), // 100 tok/s, big turn + S("claude/haiku", 200, 10, 1000), // 10 tok/s, small turn + ]; + const [row] = aggregateSamples(samples, 0, 1000); + expect(row?.model).toBe("claude/haiku"); + // 1010 tokens / 11 s = 91.82, NOT the simple mean (55) + expect(row?.tokensPerSecond).toBeCloseTo(91.82, 1); + expect(row?.totalOutputTokens).toBe(1010); + expect(row?.totalGenMs).toBe(11_000); + expect(row?.turns).toBe(2); + }); - it("excludes samples outside the [start, end) range", () => { - const samples = [S("m", 50, 100, 1000), S("m", 500, 100, 1000), S("m", 1500, 999, 1000)]; - const [row] = aggregateSamples(samples, 100, 1000); - expect(row?.turns).toBe(1); // only ts=500 is in [100, 1000) - expect(row?.totalOutputTokens).toBe(100); - }); + it("excludes samples outside the [start, end) range", () => { + const samples = [S("m", 50, 100, 1000), S("m", 500, 100, 1000), S("m", 1500, 999, 1000)]; + const [row] = aggregateSamples(samples, 100, 1000); + expect(row?.turns).toBe(1); // only ts=500 is in [100, 1000) + expect(row?.totalOutputTokens).toBe(100); + }); - it("groups by model and sorts by tok/s descending", () => { - const samples = [ - S("slow", 10, 50, 5000), // 10 tok/s - S("fast", 10, 500, 1000), // 500 tok/s - ]; - const rows = aggregateSamples(samples, 0, 100); - expect(rows.map((r) => r.model)).toEqual(["fast", "slow"]); - }); + it("groups by model and sorts by tok/s descending", () => { + const samples = [ + S("slow", 10, 50, 5000), // 10 tok/s + S("fast", 10, 500, 1000), // 500 tok/s + ]; + const rows = aggregateSamples(samples, 0, 100); + expect(rows.map((r) => r.model)).toEqual(["fast", "slow"]); + }); - it("reports 0 tok/s when generation time is 0 (avoids divide-by-zero)", () => { - const [row] = aggregateSamples([S("m", 10, 100, 0)], 0, 100); - expect(row?.tokensPerSecond).toBe(0); - }); + it("reports 0 tok/s when generation time is 0 (avoids divide-by-zero)", () => { + const [row] = aggregateSamples([S("m", 10, 100, 0)], 0, 100); + expect(row?.tokensPerSecond).toBe(0); + }); - it("returns an empty list when no samples match", () => { - expect(aggregateSamples([], 0, 100)).toEqual([]); - }); + it("returns an empty list when no samples match", () => { + expect(aggregateSamples([], 0, 100)).toEqual([]); + }); }); diff --git a/packages/throughput-store/src/aggregate.ts b/packages/throughput-store/src/aggregate.ts index 2437d9f..28e326a 100644 --- a/packages/throughput-store/src/aggregate.ts +++ b/packages/throughput-store/src/aggregate.ts @@ -12,23 +12,23 @@ */ export interface ThroughputSample { - readonly model: string; - /** Epoch-ms the turn completed. */ - readonly ts: number; - /** Output tokens generated in the turn. */ - readonly outputTokens: number; - /** Pure generation time for the turn (ms), summed across its steps. */ - readonly genMs: number; + readonly model: string; + /** Epoch-ms the turn completed. */ + readonly ts: number; + /** Output tokens generated in the turn. */ + readonly outputTokens: number; + /** Pure generation time for the turn (ms), summed across its steps. */ + readonly genMs: number; } export interface ModelThroughput { - readonly model: string; - /** Token-weighted average tokens/second over the period. */ - readonly tokensPerSecond: number; - readonly totalOutputTokens: number; - readonly totalGenMs: number; - /** Number of turns that contributed. */ - readonly turns: number; + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + readonly totalOutputTokens: number; + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; } /** @@ -36,37 +36,37 @@ export interface ModelThroughput { * throughput, sorted by tok/s descending (ties broken by model name). */ export function aggregateSamples( - samples: readonly ThroughputSample[], - start: number, - end: number, + samples: readonly ThroughputSample[], + start: number, + end: number, ): ModelThroughput[] { - const byModel = new Map(); + const byModel = new Map(); - for (const s of samples) { - if (s.ts < start || s.ts >= end) continue; - const acc = byModel.get(s.model) ?? { tokens: 0, genMs: 0, turns: 0 }; - acc.tokens += s.outputTokens; - acc.genMs += s.genMs; - acc.turns += 1; - byModel.set(s.model, acc); - } + for (const s of samples) { + if (s.ts < start || s.ts >= end) continue; + const acc = byModel.get(s.model) ?? { tokens: 0, genMs: 0, turns: 0 }; + acc.tokens += s.outputTokens; + acc.genMs += s.genMs; + acc.turns += 1; + byModel.set(s.model, acc); + } - const result: ModelThroughput[] = []; - for (const [model, acc] of byModel) { - const tokensPerSecond = acc.genMs > 0 ? round2(acc.tokens / (acc.genMs / 1000)) : 0; - result.push({ - model, - tokensPerSecond, - totalOutputTokens: acc.tokens, - totalGenMs: acc.genMs, - turns: acc.turns, - }); - } + const result: ModelThroughput[] = []; + for (const [model, acc] of byModel) { + const tokensPerSecond = acc.genMs > 0 ? round2(acc.tokens / (acc.genMs / 1000)) : 0; + result.push({ + model, + tokensPerSecond, + totalOutputTokens: acc.tokens, + totalGenMs: acc.genMs, + turns: acc.turns, + }); + } - result.sort((a, b) => b.tokensPerSecond - a.tokensPerSecond || a.model.localeCompare(b.model)); - return result; + result.sort((a, b) => b.tokensPerSecond - a.tokensPerSecond || a.model.localeCompare(b.model)); + return result; } function round2(n: number): number { - return Math.round(n * 100) / 100; + return Math.round(n * 100) / 100; } diff --git a/packages/throughput-store/src/extension.ts b/packages/throughput-store/src/extension.ts index 01d1549..13c4a64 100644 --- a/packages/throughput-store/src/extension.ts +++ b/packages/throughput-store/src/extension.ts @@ -3,22 +3,22 @@ import { throughputStoreHandle } from "./service.js"; import { createThroughputStore } from "./store.js"; export const manifest: Manifest = { - id: "throughput-store", - name: "Throughput Store", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - capabilities: { db: true }, - contributes: { services: ["throughput-store/store"] }, - activation: "eager", + id: "throughput-store", + name: "Throughput Store", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + capabilities: { db: true }, + contributes: { services: ["throughput-store/store"] }, + activation: "eager", }; export const extension: Extension = { - manifest, - activate: (host: HostAPI) => { - const storage = host.storage("throughput-store"); - const store = createThroughputStore({ storage, logger: host.logger }); - host.provideService(throughputStoreHandle, store); - host.logger.info("throughput-store: registered"); - }, + manifest, + activate: (host: HostAPI) => { + const storage = host.storage("throughput-store"); + const store = createThroughputStore({ storage, logger: host.logger }); + host.provideService(throughputStoreHandle, store); + host.logger.info("throughput-store: registered"); + }, }; diff --git a/packages/throughput-store/src/index.ts b/packages/throughput-store/src/index.ts index 24ebaba..f9a02d7 100644 --- a/packages/throughput-store/src/index.ts +++ b/packages/throughput-store/src/index.ts @@ -1,16 +1,16 @@ export { - aggregateSamples, - type ModelThroughput, - type ThroughputSample, + aggregateSamples, + type ModelThroughput, + type ThroughputSample, } from "./aggregate.js"; export { extension, manifest } from "./extension.js"; export { dayKeyOf, type Period, resolvePeriod } from "./period.js"; export { throughputStoreHandle } from "./service.js"; export { - createThroughputStore, - type ThroughputQuery, - ThroughputQueryError, - type ThroughputReport, - type ThroughputStore, - type ThroughputStoreDeps, + createThroughputStore, + type ThroughputQuery, + ThroughputQueryError, + type ThroughputReport, + type ThroughputStore, + type ThroughputStoreDeps, } from "./store.js"; diff --git a/packages/throughput-store/src/period.test.ts b/packages/throughput-store/src/period.test.ts index b39437c..01625c5 100644 --- a/packages/throughput-store/src/period.test.ts +++ b/packages/throughput-store/src/period.test.ts @@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest"; import { dayKeyOf, resolvePeriod } from "./period.js"; describe("dayKeyOf", () => { - it("formats a local YYYY-MM-DD key", () => { - // Build a local-midnight timestamp so the key is timezone-stable. - const ts = new Date(2026, 5, 10).getTime(); - expect(dayKeyOf(ts)).toBe("2026-06-10"); - }); + it("formats a local YYYY-MM-DD key", () => { + // Build a local-midnight timestamp so the key is timezone-stable. + const ts = new Date(2026, 5, 10).getTime(); + expect(dayKeyOf(ts)).toBe("2026-06-10"); + }); }); describe("resolvePeriod day", () => { - it("spans a single local day", () => { - const r = resolvePeriod("day", "2026-06-10"); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.dayKeys).toEqual(["2026-06-10"]); - expect(r.start).toBe(new Date(2026, 5, 10).getTime()); - expect(r.end).toBe(new Date(2026, 5, 11).getTime()); - }); + it("spans a single local day", () => { + const r = resolvePeriod("day", "2026-06-10"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.dayKeys).toEqual(["2026-06-10"]); + expect(r.start).toBe(new Date(2026, 5, 10).getTime()); + expect(r.end).toBe(new Date(2026, 5, 11).getTime()); + }); - it("rejects malformed / impossible dates", () => { - expect(resolvePeriod("day", "2026-13-01").ok).toBe(false); - expect(resolvePeriod("day", "2026-02-30").ok).toBe(false); - expect(resolvePeriod("day", "nope").ok).toBe(false); - expect(resolvePeriod("day", "2026-06").ok).toBe(false); - }); + it("rejects malformed / impossible dates", () => { + expect(resolvePeriod("day", "2026-13-01").ok).toBe(false); + expect(resolvePeriod("day", "2026-02-30").ok).toBe(false); + expect(resolvePeriod("day", "nope").ok).toBe(false); + expect(resolvePeriod("day", "2026-06").ok).toBe(false); + }); }); describe("resolvePeriod week", () => { - it("spans the Monday–Sunday ISO week containing the date", () => { - const r = resolvePeriod("week", "2026-06-10"); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.dayKeys).toHaveLength(7); - // start is a Monday (local) - expect(new Date(r.start).getDay()).toBe(1); - // the queried date falls within the week - expect(r.dayKeys).toContain("2026-06-10"); - // end is exactly 7 local days after start - expect(new Date(r.end).getDay()).toBe(1); - }); + it("spans the Monday–Sunday ISO week containing the date", () => { + const r = resolvePeriod("week", "2026-06-10"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.dayKeys).toHaveLength(7); + // start is a Monday (local) + expect(new Date(r.start).getDay()).toBe(1); + // the queried date falls within the week + expect(r.dayKeys).toContain("2026-06-10"); + // end is exactly 7 local days after start + expect(new Date(r.end).getDay()).toBe(1); + }); }); describe("resolvePeriod month", () => { - it("spans a full calendar month", () => { - const r = resolvePeriod("month", "2026-06"); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.dayKeys).toHaveLength(30); // June has 30 days - expect(r.dayKeys[0]).toBe("2026-06-01"); - expect(r.dayKeys[29]).toBe("2026-06-30"); - expect(r.start).toBe(new Date(2026, 5, 1).getTime()); - expect(r.end).toBe(new Date(2026, 6, 1).getTime()); - }); + it("spans a full calendar month", () => { + const r = resolvePeriod("month", "2026-06"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.dayKeys).toHaveLength(30); // June has 30 days + expect(r.dayKeys[0]).toBe("2026-06-01"); + expect(r.dayKeys[29]).toBe("2026-06-30"); + expect(r.start).toBe(new Date(2026, 5, 1).getTime()); + expect(r.end).toBe(new Date(2026, 6, 1).getTime()); + }); - it("handles February length", () => { - const r = resolvePeriod("month", "2026-02"); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.dayKeys).toHaveLength(28); - }); + it("handles February length", () => { + const r = resolvePeriod("month", "2026-02"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.dayKeys).toHaveLength(28); + }); - it("rejects a YYYY-MM-DD date for month", () => { - expect(resolvePeriod("month", "2026-06-10").ok).toBe(false); - }); + it("rejects a YYYY-MM-DD date for month", () => { + expect(resolvePeriod("month", "2026-06-10").ok).toBe(false); + }); }); diff --git a/packages/throughput-store/src/period.ts b/packages/throughput-store/src/period.ts index d8225f8..4b84528 100644 --- a/packages/throughput-store/src/period.ts +++ b/packages/throughput-store/src/period.ts @@ -15,55 +15,55 @@ export type Period = "day" | "week" | "month"; export interface ResolvedPeriod { - readonly ok: true; - /** Inclusive start, epoch-ms (local midnight). */ - readonly start: number; - /** Exclusive end, epoch-ms (local midnight). */ - readonly end: number; - /** Local `YYYY-MM-DD` day keys this period spans, in order. */ - readonly dayKeys: readonly string[]; - /** The normalized input date string. */ - readonly date: string; + readonly ok: true; + /** Inclusive start, epoch-ms (local midnight). */ + readonly start: number; + /** Exclusive end, epoch-ms (local midnight). */ + readonly end: number; + /** Local `YYYY-MM-DD` day keys this period spans, in order. */ + readonly dayKeys: readonly string[]; + /** The normalized input date string. */ + readonly date: string; } export interface PeriodError { - readonly ok: false; - readonly error: string; + readonly ok: false; + readonly error: string; } function pad2(n: number): string { - return n < 10 ? `0${n}` : String(n); + return n < 10 ? `0${n}` : String(n); } /** Local `YYYY-MM-DD` key for an epoch-ms timestamp. */ export function dayKeyOf(ts: number): string { - const d = new Date(ts); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + const d = new Date(ts); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** Local `YYYY-MM-DD` key for a local calendar date. */ function dayKey(year: number, monthIndex: number, day: number): string { - const d = new Date(year, monthIndex, day); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + const d = new Date(year, monthIndex, day); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function parseYmd(date: string): { y: number; m: number; d: number } | null { - if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; - const [y, m, d] = date.split("-").map(Number) as [number, number, number]; - if (m < 1 || m > 12 || d < 1 || d > 31) return null; - // Reject impossible dates (e.g. 2026-02-30) by round-tripping through Date. - const probe = new Date(y, m - 1, d); - if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== d) { - return null; - } - return { y, m, d }; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + const [y, m, d] = date.split("-").map(Number) as [number, number, number]; + if (m < 1 || m > 12 || d < 1 || d > 31) return null; + // Reject impossible dates (e.g. 2026-02-30) by round-tripping through Date. + const probe = new Date(y, m - 1, d); + if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== d) { + return null; + } + return { y, m, d }; } function parseYm(date: string): { y: number; m: number } | null { - if (!/^\d{4}-\d{2}$/.test(date)) return null; - const [y, m] = date.split("-").map(Number) as [number, number]; - if (m < 1 || m > 12) return null; - return { y, m }; + if (!/^\d{4}-\d{2}$/.test(date)) return null; + const [y, m] = date.split("-").map(Number) as [number, number]; + if (m < 1 || m > 12) return null; + return { y, m }; } /** @@ -71,43 +71,43 @@ function parseYm(date: string): { y: number; m: number } | null { * keys it covers. Returns a `PeriodError` for malformed input. */ export function resolvePeriod(period: Period, date: string): ResolvedPeriod | PeriodError { - if (period === "day") { - const p = parseYmd(date); - if (!p) return { ok: false, error: `invalid day date "${date}" (expected YYYY-MM-DD)` }; - const start = new Date(p.y, p.m - 1, p.d).getTime(); - const end = new Date(p.y, p.m - 1, p.d + 1).getTime(); - return { ok: true, start, end, dayKeys: [dayKey(p.y, p.m - 1, p.d)], date }; - } + if (period === "day") { + const p = parseYmd(date); + if (!p) return { ok: false, error: `invalid day date "${date}" (expected YYYY-MM-DD)` }; + const start = new Date(p.y, p.m - 1, p.d).getTime(); + const end = new Date(p.y, p.m - 1, p.d + 1).getTime(); + return { ok: true, start, end, dayKeys: [dayKey(p.y, p.m - 1, p.d)], date }; + } - if (period === "week") { - const p = parseYmd(date); - if (!p) return { ok: false, error: `invalid week date "${date}" (expected YYYY-MM-DD)` }; - // ISO week: Monday-based. JS getDay() is 0=Sun..6=Sat. - const base = new Date(p.y, p.m - 1, p.d); - const offset = (base.getDay() + 6) % 7; // days since Monday - const monStart = new Date(p.y, p.m - 1, p.d - offset); - const start = monStart.getTime(); - const end = new Date( - monStart.getFullYear(), - monStart.getMonth(), - monStart.getDate() + 7, - ).getTime(); - const dayKeys: string[] = []; - for (let i = 0; i < 7; i++) { - dayKeys.push(dayKey(monStart.getFullYear(), monStart.getMonth(), monStart.getDate() + i)); - } - return { ok: true, start, end, dayKeys, date }; - } + if (period === "week") { + const p = parseYmd(date); + if (!p) return { ok: false, error: `invalid week date "${date}" (expected YYYY-MM-DD)` }; + // ISO week: Monday-based. JS getDay() is 0=Sun..6=Sat. + const base = new Date(p.y, p.m - 1, p.d); + const offset = (base.getDay() + 6) % 7; // days since Monday + const monStart = new Date(p.y, p.m - 1, p.d - offset); + const start = monStart.getTime(); + const end = new Date( + monStart.getFullYear(), + monStart.getMonth(), + monStart.getDate() + 7, + ).getTime(); + const dayKeys: string[] = []; + for (let i = 0; i < 7; i++) { + dayKeys.push(dayKey(monStart.getFullYear(), monStart.getMonth(), monStart.getDate() + i)); + } + return { ok: true, start, end, dayKeys, date }; + } - // month - const p = parseYm(date); - if (!p) return { ok: false, error: `invalid month date "${date}" (expected YYYY-MM)` }; - const start = new Date(p.y, p.m - 1, 1).getTime(); - const end = new Date(p.y, p.m, 1).getTime(); - const lastDay = new Date(p.y, p.m, 0).getDate(); - const dayKeys: string[] = []; - for (let d = 1; d <= lastDay; d++) { - dayKeys.push(dayKey(p.y, p.m - 1, d)); - } - return { ok: true, start, end, dayKeys, date }; + // month + const p = parseYm(date); + if (!p) return { ok: false, error: `invalid month date "${date}" (expected YYYY-MM)` }; + const start = new Date(p.y, p.m - 1, 1).getTime(); + const end = new Date(p.y, p.m, 1).getTime(); + const lastDay = new Date(p.y, p.m, 0).getDate(); + const dayKeys: string[] = []; + for (let d = 1; d <= lastDay; d++) { + dayKeys.push(dayKey(p.y, p.m - 1, d)); + } + return { ok: true, start, end, dayKeys, date }; } diff --git a/packages/throughput-store/src/store.test.ts b/packages/throughput-store/src/store.test.ts index e81201a..fa8f9e5 100644 --- a/packages/throughput-store/src/store.test.ts +++ b/packages/throughput-store/src/store.test.ts @@ -4,69 +4,69 @@ import { dayKeyOf } from "./period.js"; import { createThroughputStore, ThroughputQueryError } from "./store.js"; function memStorage(): StorageNamespace { - const map = new Map(); - return { - get: async (k) => map.get(k) ?? null, - set: async (k, v) => { - map.set(k, v); - }, - delete: async (k) => { - map.delete(k); - }, - has: async (k) => map.has(k), - keys: async (prefix) => - [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const map = new Map(); + return { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + delete: async (k) => { + map.delete(k); + }, + has: async (k) => map.has(k), + keys: async (prefix) => + [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } let id = 0; const store = () => createThroughputStore({ storage: memStorage(), newId: () => `id${id++}` }); describe("ThroughputStore", () => { - it("records a sample and aggregates it for that day", async () => { - const s = store(); - const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); - await s.record({ model: "claude/haiku", ts, outputTokens: 300, genMs: 1500 }); + it("records a sample and aggregates it for that day", async () => { + const s = store(); + const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); + await s.record({ model: "claude/haiku", ts, outputTokens: 300, genMs: 1500 }); - const report = await s.aggregate({ period: "day", date: dayKeyOf(ts) }); - expect(report.models).toHaveLength(1); - expect(report.models[0]).toMatchObject({ - model: "claude/haiku", - totalOutputTokens: 300, - totalGenMs: 1500, - turns: 1, - tokensPerSecond: 200, // 300 / 1.5s - }); - }); + const report = await s.aggregate({ period: "day", date: dayKeyOf(ts) }); + expect(report.models).toHaveLength(1); + expect(report.models[0]).toMatchObject({ + model: "claude/haiku", + totalOutputTokens: 300, + totalGenMs: 1500, + turns: 1, + tokensPerSecond: 200, // 300 / 1.5s + }); + }); - it("does not lose concurrent samples (single-set writes)", async () => { - const s = store(); - const ts = new Date(2026, 5, 10, 9, 0, 0).getTime(); - await Promise.all([ - s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), - s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), - s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), - ]); - const report = await s.aggregate({ period: "day", date: dayKeyOf(ts) }); - expect(report.models[0]?.turns).toBe(3); - expect(report.models[0]?.totalOutputTokens).toBe(300); - }); + it("does not lose concurrent samples (single-set writes)", async () => { + const s = store(); + const ts = new Date(2026, 5, 10, 9, 0, 0).getTime(); + await Promise.all([ + s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), + s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), + s.record({ model: "m", ts, outputTokens: 100, genMs: 1000 }), + ]); + const report = await s.aggregate({ period: "day", date: dayKeyOf(ts) }); + expect(report.models[0]?.turns).toBe(3); + expect(report.models[0]?.totalOutputTokens).toBe(300); + }); - it("aggregates multiple days within a week", async () => { - const s = store(); - const mon = new Date(2026, 5, 8, 10, 0, 0).getTime(); // Mon 2026-06-08 - const wed = new Date(2026, 5, 10, 10, 0, 0).getTime(); - await s.record({ model: "m", ts: mon, outputTokens: 100, genMs: 1000 }); - await s.record({ model: "m", ts: wed, outputTokens: 200, genMs: 1000 }); + it("aggregates multiple days within a week", async () => { + const s = store(); + const mon = new Date(2026, 5, 8, 10, 0, 0).getTime(); // Mon 2026-06-08 + const wed = new Date(2026, 5, 10, 10, 0, 0).getTime(); + await s.record({ model: "m", ts: mon, outputTokens: 100, genMs: 1000 }); + await s.record({ model: "m", ts: wed, outputTokens: 200, genMs: 1000 }); - const report = await s.aggregate({ period: "week", date: dayKeyOf(wed) }); - expect(report.models[0]?.turns).toBe(2); - expect(report.models[0]?.totalOutputTokens).toBe(300); - }); + const report = await s.aggregate({ period: "week", date: dayKeyOf(wed) }); + expect(report.models[0]?.turns).toBe(2); + expect(report.models[0]?.totalOutputTokens).toBe(300); + }); - it("throws ThroughputQueryError on a malformed date", async () => { - await expect(store().aggregate({ period: "day", date: "garbage" })).rejects.toBeInstanceOf( - ThroughputQueryError, - ); - }); + it("throws ThroughputQueryError on a malformed date", async () => { + await expect(store().aggregate({ period: "day", date: "garbage" })).rejects.toBeInstanceOf( + ThroughputQueryError, + ); + }); }); diff --git a/packages/throughput-store/src/store.ts b/packages/throughput-store/src/store.ts index 94675b1..7c5991b 100644 --- a/packages/throughput-store/src/store.ts +++ b/packages/throughput-store/src/store.ts @@ -3,18 +3,18 @@ import { aggregateSamples, type ModelThroughput, type ThroughputSample } from ". import { dayKeyOf, type Period, resolvePeriod } from "./period.js"; export interface ThroughputReport { - readonly period: Period; - readonly date: string; - /** Inclusive start, epoch-ms. */ - readonly start: number; - /** Exclusive end, epoch-ms. */ - readonly end: number; - readonly models: readonly ModelThroughput[]; + readonly period: Period; + readonly date: string; + /** Inclusive start, epoch-ms. */ + readonly start: number; + /** Exclusive end, epoch-ms. */ + readonly end: number; + readonly models: readonly ModelThroughput[]; } export interface ThroughputQuery { - readonly period: Period; - readonly date: string; + readonly period: Period; + readonly date: string; } /** @@ -24,15 +24,15 @@ export interface ThroughputQuery { * key so a period query addresses only its own day buckets. */ export interface ThroughputStore { - readonly record: (sample: ThroughputSample) => Promise; - readonly aggregate: (query: ThroughputQuery) => Promise; + readonly record: (sample: ThroughputSample) => Promise; + readonly aggregate: (query: ThroughputQuery) => Promise; } export interface ThroughputStoreDeps { - readonly storage: StorageNamespace; - readonly logger?: Logger; - /** Injectable unique-id generator (default crypto.randomUUID). */ - readonly newId?: () => string; + readonly storage: StorageNamespace; + readonly logger?: Logger; + /** Injectable unique-id generator (default crypto.randomUUID). */ + readonly newId?: () => string; } /** Thrown when a query's `(period, date)` is malformed. */ @@ -41,43 +41,43 @@ export class ThroughputQueryError extends Error {} const SAMPLE_PREFIX = "sample"; export function createThroughputStore(deps: ThroughputStoreDeps): ThroughputStore { - const newId = deps.newId ?? (() => crypto.randomUUID()); + const newId = deps.newId ?? (() => crypto.randomUUID()); - return { - async record(sample) { - // Per-sample key under the local-day bucket → write is a single set - // (no read-modify-write, so concurrent turns can't lose a sample). - const day = dayKeyOf(sample.ts); - const key = `${SAMPLE_PREFIX}:${day}:${sample.ts}:${newId()}`; - await deps.storage.set(key, JSON.stringify(sample)); - }, + return { + async record(sample) { + // Per-sample key under the local-day bucket → write is a single set + // (no read-modify-write, so concurrent turns can't lose a sample). + const day = dayKeyOf(sample.ts); + const key = `${SAMPLE_PREFIX}:${day}:${sample.ts}:${newId()}`; + await deps.storage.set(key, JSON.stringify(sample)); + }, - async aggregate(query) { - const resolved = resolvePeriod(query.period, query.date); - if (!resolved.ok) throw new ThroughputQueryError(resolved.error); + async aggregate(query) { + const resolved = resolvePeriod(query.period, query.date); + if (!resolved.ok) throw new ThroughputQueryError(resolved.error); - const samples: ThroughputSample[] = []; - for (const day of resolved.dayKeys) { - const keys = await deps.storage.keys(`${SAMPLE_PREFIX}:${day}:`); - for (const k of keys) { - const raw = await deps.storage.get(k); - if (raw === null) continue; - try { - samples.push(JSON.parse(raw) as ThroughputSample); - } catch { - // Skip a malformed row rather than failing the whole query. - } - } - } + const samples: ThroughputSample[] = []; + for (const day of resolved.dayKeys) { + const keys = await deps.storage.keys(`${SAMPLE_PREFIX}:${day}:`); + for (const k of keys) { + const raw = await deps.storage.get(k); + if (raw === null) continue; + try { + samples.push(JSON.parse(raw) as ThroughputSample); + } catch { + // Skip a malformed row rather than failing the whole query. + } + } + } - const models = aggregateSamples(samples, resolved.start, resolved.end); - return { - period: query.period, - date: resolved.date, - start: resolved.start, - end: resolved.end, - models, - }; - }, - }; + const models = aggregateSamples(samples, resolved.start, resolved.end); + return { + period: query.period, + date: resolved.date, + start: resolved.start, + end: resolved.end, + models, + }; + }, + }; } diff --git a/packages/throughput-store/tsconfig.json b/packages/throughput-store/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/throughput-store/tsconfig.json +++ b/packages/throughput-store/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/todo/package.json b/packages/todo/package.json index 14b8e55..b0456b5 100644 --- a/packages/todo/package.json +++ b/packages/todo/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/todo", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/ui-contract": "workspace:*" - } + "name": "@dispatch/todo", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } } diff --git a/packages/todo/src/extension.test.ts b/packages/todo/src/extension.test.ts index 8b9e84b..42aa670 100644 --- a/packages/todo/src/extension.test.ts +++ b/packages/todo/src/extension.test.ts @@ -4,135 +4,135 @@ import { describe, expect, it, vi } from "vitest"; import { activate, extension, manifest } from "./extension.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } interface FakeHost { - readonly host: HostAPI; - readonly registry: SurfaceRegistry; - readonly defineTool: ReturnType; - readonly getProvider: () => SurfaceProvider | undefined; + readonly host: HostAPI; + readonly registry: SurfaceRegistry; + readonly defineTool: ReturnType; + readonly getProvider: () => SurfaceProvider | undefined; } function makeFakeHost(): FakeHost { - const defineTool = vi.fn(); - let provider: SurfaceProvider | undefined; - const registry: SurfaceRegistry = { - register(p) { - provider = p; - return () => { - provider = undefined; - }; - }, - getCatalog() { - return provider === undefined ? [] : [provider.catalogEntry]; - }, - getSurface(id) { - if (provider === undefined) return undefined; - return provider.catalogEntry.id === id ? provider : undefined; - }, - }; - const host = { - defineTool, - getService: () => registry, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - span: vi.fn(() => ({ end: vi.fn() })), - }, - } as unknown as HostAPI; - return { host, registry, defineTool, getProvider: () => provider }; + const defineTool = vi.fn(); + let provider: SurfaceProvider | undefined; + const registry: SurfaceRegistry = { + register(p) { + provider = p; + return () => { + provider = undefined; + }; + }, + getCatalog() { + return provider === undefined ? [] : [provider.catalogEntry]; + }, + getSurface(id) { + if (provider === undefined) return undefined; + return provider.catalogEntry.id === id ? provider : undefined; + }, + }; + const host = { + defineTool, + getService: () => registry, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + span: vi.fn(() => ({ end: vi.fn() })), + }, + } as unknown as HostAPI; + return { host, registry, defineTool, getProvider: () => provider }; } describe("todo manifest", () => { - it("declares todo_write contribution + surface-registry dependency", () => { - expect(manifest.id).toBe("todo"); - expect(manifest.activation).toBe("eager"); - expect(manifest.trust).toBe("bundled"); - expect(manifest.dependsOn).toEqual(["surface-registry"]); - expect(manifest.contributes).toEqual({ tools: ["todo_write"] }); - expect(manifest.capabilities).toEqual({}); - }); + it("declares todo_write contribution + surface-registry dependency", () => { + expect(manifest.id).toBe("todo"); + expect(manifest.activation).toBe("eager"); + expect(manifest.trust).toBe("bundled"); + expect(manifest.dependsOn).toEqual(["surface-registry"]); + expect(manifest.contributes).toEqual({ tools: ["todo_write"] }); + expect(manifest.capabilities).toEqual({}); + }); - it("extension bundles the manifest + activate", () => { - expect(extension.manifest).toBe(manifest); - expect(typeof extension.activate).toBe("function"); - }); + it("extension bundles the manifest + activate", () => { + expect(extension.manifest).toBe(manifest); + expect(typeof extension.activate).toBe("function"); + }); }); describe("todo activation", () => { - it("activate registers the todo_write tool", () => { - const { host, defineTool } = makeFakeHost(); - activate(host); - expect(defineTool).toHaveBeenCalledTimes(1); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - expect(tool.name).toBe("todo_write"); - expect(tool.concurrencySafe).toBe(false); - }); + it("activate registers the todo_write tool", () => { + const { host, defineTool } = makeFakeHost(); + activate(host); + expect(defineTool).toHaveBeenCalledTimes(1); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + expect(tool.name).toBe("todo_write"); + expect(tool.concurrencySafe).toBe(false); + }); - it("activate registers a surface with scope 'conversation'", () => { - const { host, getProvider } = makeFakeHost(); - activate(host); - const provider = getProvider(); - if (!provider) throw new Error("no surface provider registered"); - expect(provider.catalogEntry.id).toBe("todo"); - expect(provider.catalogEntry.scope).toBe("conversation"); - expect(provider.catalogEntry.region).toBe("side"); - expect(provider.catalogEntry.title).toBe("Tasks"); - }); + it("activate registers a surface with scope 'conversation'", () => { + const { host, getProvider } = makeFakeHost(); + activate(host); + const provider = getProvider(); + if (!provider) throw new Error("no surface provider registered"); + expect(provider.catalogEntry.id).toBe("todo"); + expect(provider.catalogEntry.scope).toBe("conversation"); + expect(provider.catalogEntry.region).toBe("side"); + expect(provider.catalogEntry.title).toBe("Tasks"); + }); - it("surface getSpec returns todos for the conversation", async () => { - const { host, defineTool, getProvider } = makeFakeHost(); - activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute( - { todos: [{ content: "a", status: "pending" }] }, - stubCtx({ conversationId: "c1" }), - ); - const provider = getProvider(); - if (!provider) throw new Error("no surface provider registered"); - const spec = await provider.getSpec({ conversationId: "c1" }); - expect(spec.id).toBe("todo"); - expect(spec.fields).toHaveLength(1); - const field = spec.fields[0]; - if (field === undefined || field.kind !== "custom") { - throw new Error("expected a custom field"); - } - const payload = field.payload as { todos: readonly { content: string }[] }; - expect(payload.todos).toHaveLength(1); - expect(payload.todos[0]?.content).toBe("a"); - }); + it("surface getSpec returns todos for the conversation", async () => { + const { host, defineTool, getProvider } = makeFakeHost(); + activate(host); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute( + { todos: [{ content: "a", status: "pending" }] }, + stubCtx({ conversationId: "c1" }), + ); + const provider = getProvider(); + if (!provider) throw new Error("no surface provider registered"); + const spec = await provider.getSpec({ conversationId: "c1" }); + expect(spec.id).toBe("todo"); + expect(spec.fields).toHaveLength(1); + const field = spec.fields[0]; + if (field === undefined || field.kind !== "custom") { + throw new Error("expected a custom field"); + } + const payload = field.payload as { todos: readonly { content: string }[] }; + expect(payload.todos).toHaveLength(1); + expect(payload.todos[0]?.content).toBe("a"); + }); - it("surface subscribe notifies on todo_write", async () => { - const { host, defineTool, getProvider } = makeFakeHost(); - activate(host); - const provider = getProvider(); - if (!provider) throw new Error("no surface provider registered"); - const calls = { value: 0 }; - const unsub = provider.subscribe?.(() => { - calls.value += 1; - }); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute( - { todos: [{ content: "x", status: "pending" }] }, - stubCtx({ conversationId: "c1" }), - ); - expect(calls.value).toBe(1); - if (unsub) unsub(); - }); + it("surface subscribe notifies on todo_write", async () => { + const { host, defineTool, getProvider } = makeFakeHost(); + activate(host); + const provider = getProvider(); + if (!provider) throw new Error("no surface provider registered"); + const calls = { value: 0 }; + const unsub = provider.subscribe?.(() => { + calls.value += 1; + }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute( + { todos: [{ content: "x", status: "pending" }] }, + stubCtx({ conversationId: "c1" }), + ); + expect(calls.value).toBe(1); + if (unsub) unsub(); + }); }); diff --git a/packages/todo/src/extension.ts b/packages/todo/src/extension.ts index 80af7aa..fb67265 100644 --- a/packages/todo/src/extension.ts +++ b/packages/todo/src/extension.ts @@ -15,67 +15,67 @@ import { buildTodoSpec, getTodos, TODO_SURFACE_ID, type TodoState } from "./pure import { createTodoWriteTool } from "./tool.js"; export const manifest: Manifest = { - id: "todo", - name: "Todo Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["surface-registry"], - capabilities: {}, - contributes: { - tools: ["todo_write"], - }, + id: "todo", + name: "Todo Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["surface-registry"], + capabilities: {}, + contributes: { + tools: ["todo_write"], + }, }; export function activate(host: HostAPI): void { - const registry = host.getService(surfaceRegistryHandle); + const registry = host.getService(surfaceRegistryHandle); - const state: TodoState = new Map(); - const subscribers = new Set<() => void>(); + const state: TodoState = new Map(); + const subscribers = new Set<() => void>(); - function notify(): void { - for (const sub of subscribers) { - sub(); - } - } + function notify(): void { + for (const sub of subscribers) { + sub(); + } + } - host.defineTool(createTodoWriteTool({ state, notify })); + host.defineTool(createTodoWriteTool({ state, notify })); - function getSpec(context?: SurfaceContext): SurfaceSpec { - const convId = context?.conversationId; - const todos = convId === undefined ? [] : getTodos(state, convId); - return buildTodoSpec(todos); - } + function getSpec(context?: SurfaceContext): SurfaceSpec { + const convId = context?.conversationId; + const todos = convId === undefined ? [] : getTodos(state, convId); + return buildTodoSpec(todos); + } - function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { - // The todo surface is read-only: the model mutates the list via the - // `todo_write` tool; no client-facing surface actions. - } + function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void { + // The todo surface is read-only: the model mutates the list via the + // `todo_write` tool; no client-facing surface actions. + } - const provider: SurfaceProvider = { - catalogEntry: { - id: TODO_SURFACE_ID, - region: "side", - title: "Tasks", - scope: "conversation", - }, - getSpec, - invoke, - subscribe(onChange) { - subscribers.add(onChange); - return () => { - subscribers.delete(onChange); - }; - }, - }; + const provider: SurfaceProvider = { + catalogEntry: { + id: TODO_SURFACE_ID, + region: "side", + title: "Tasks", + scope: "conversation", + }, + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; + }, + }; - registry.register(provider); + registry.register(provider); - host.logger.info("todo: registered"); + host.logger.info("todo: registered"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/todo/src/format.test.ts b/packages/todo/src/format.test.ts index bffb8a3..e589db1 100644 --- a/packages/todo/src/format.test.ts +++ b/packages/todo/src/format.test.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest"; import { formatTodoResult, type TodoItem } from "./pure.js"; describe("formatTodoResult", () => { - it("formatTodoResult: returns JSON string of the todos", () => { - const todos: TodoItem[] = [ - { content: "alpha", status: "in_progress" }, - { content: "beta", status: "pending" }, - ]; - expect(formatTodoResult(todos)).toBe(JSON.stringify(todos, null, 2)); - // spot-check it is pretty-printed JSON (indented key) - expect(formatTodoResult(todos)).toContain('"content": "alpha"'); - }); + it("formatTodoResult: returns JSON string of the todos", () => { + const todos: TodoItem[] = [ + { content: "alpha", status: "in_progress" }, + { content: "beta", status: "pending" }, + ]; + expect(formatTodoResult(todos)).toBe(JSON.stringify(todos, null, 2)); + // spot-check it is pretty-printed JSON (indented key) + expect(formatTodoResult(todos)).toContain('"content": "alpha"'); + }); - it('formatTodoResult: empty array returns "[]"', () => { - expect(formatTodoResult([])).toBe("[]"); - }); + it('formatTodoResult: empty array returns "[]"', () => { + expect(formatTodoResult([])).toBe("[]"); + }); }); diff --git a/packages/todo/src/index.ts b/packages/todo/src/index.ts index 320b674..9c444de 100644 --- a/packages/todo/src/index.ts +++ b/packages/todo/src/index.ts @@ -8,17 +8,17 @@ export { extension, manifest } from "./extension.js"; export { - buildTodoSpec, - clearTodos, - formatTodoResult, - getTodos, - setTodos, - TODO_RENDERER_ID, - TODO_SURFACE_ID, - type TodoItem, - type TodoState, - type TodoStatus, - type ValidationResult, - validateTodos, + buildTodoSpec, + clearTodos, + formatTodoResult, + getTodos, + setTodos, + TODO_RENDERER_ID, + TODO_SURFACE_ID, + type TodoItem, + type TodoState, + type TodoStatus, + type ValidationResult, + validateTodos, } from "./pure.js"; export { createTodoWriteTool, type TodoWriteToolDeps } from "./tool.js"; diff --git a/packages/todo/src/pure.ts b/packages/todo/src/pure.ts index b6a6a32..f010197 100644 --- a/packages/todo/src/pure.ts +++ b/packages/todo/src/pure.ts @@ -24,8 +24,8 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; * pattern — the model passes the FULL list each call, so position is identity. */ export interface TodoItem { - readonly content: string; - readonly status: TodoStatus; + readonly content: string; + readonly status: TodoStatus; } /** The todo store: a per-conversation map of todo lists. */ @@ -37,10 +37,10 @@ export const TODO_SURFACE_ID = "todo"; export const TODO_RENDERER_ID = "todo"; const VALID_STATUSES: ReadonlySet = new Set([ - "pending", - "in_progress", - "completed", - "cancelled", + "pending", + "in_progress", + "completed", + "cancelled", ]); /** Result of `validateTodos`: the validated list, or an error message. */ @@ -52,37 +52,37 @@ export type ValidationResult = TodoItem[] | { readonly error: string }; * an empty array (the model clears the list). Pure: no I/O, no ambient state. */ export function validateTodos(args: unknown): ValidationResult { - if (args === null || typeof args !== "object" || Array.isArray(args)) { - return { error: "Error: todo_write args must be an object with a `todos` array." }; - } - const todos = (args as { todos?: unknown }).todos; - if (!Array.isArray(todos)) { - return { error: "Error: `todos` must be an array." }; - } - const validated: TodoItem[] = []; - for (let i = 0; i < todos.length; i++) { - const item = todos[i]; - if (item === null || typeof item !== "object" || Array.isArray(item)) { - return { error: `Error: todos[${i}] must be an object.` }; - } - const { content, status } = item as { - content?: unknown; - status?: unknown; - }; - if (typeof content !== "string" || content.trim().length === 0) { - return { error: `Error: todos[${i}].content must be a non-empty string.` }; - } - if (typeof status !== "string" || !VALID_STATUSES.has(status)) { - return { - error: `Error: todos[${i}].status must be one of pending|in_progress|completed|cancelled.`, - }; - } - validated.push({ - content, - status: status as TodoStatus, - }); - } - return validated; + if (args === null || typeof args !== "object" || Array.isArray(args)) { + return { error: "Error: todo_write args must be an object with a `todos` array." }; + } + const todos = (args as { todos?: unknown }).todos; + if (!Array.isArray(todos)) { + return { error: "Error: `todos` must be an array." }; + } + const validated: TodoItem[] = []; + for (let i = 0; i < todos.length; i++) { + const item = todos[i]; + if (item === null || typeof item !== "object" || Array.isArray(item)) { + return { error: `Error: todos[${i}] must be an object.` }; + } + const { content, status } = item as { + content?: unknown; + status?: unknown; + }; + if (typeof content !== "string" || content.trim().length === 0) { + return { error: `Error: todos[${i}].content must be a non-empty string.` }; + } + if (typeof status !== "string" || !VALID_STATUSES.has(status)) { + return { + error: `Error: todos[${i}].status must be one of pending|in_progress|completed|cancelled.`, + }; + } + validated.push({ + content, + status: status as TodoStatus, + }); + } + return validated; } /** @@ -91,9 +91,9 @@ export function validateTodos(args: unknown): ValidationResult { * array does not affect live state (items are readonly). */ export function getTodos(state: TodoState, conversationId: string): TodoItem[] { - const existing = state.get(conversationId); - if (existing === undefined) return []; - return [...existing]; + const existing = state.get(conversationId); + if (existing === undefined) return []; + return [...existing]; } /** @@ -103,17 +103,17 @@ export function getTodos(state: TodoState, conversationId: string): TodoItem[] { * mutate live state through the returned value. */ export function setTodos( - state: TodoState, - conversationId: string, - todos: readonly TodoItem[], + state: TodoState, + conversationId: string, + todos: readonly TodoItem[], ): TodoItem[] { - state.set(conversationId, [...todos]); - return getTodos(state, conversationId); + state.set(conversationId, [...todos]); + return getTodos(state, conversationId); } /** Delete a conversation's todo list. No-op if the conversation has none. */ export function clearTodos(state: TodoState, conversationId: string): void { - state.delete(conversationId); + state.delete(conversationId); } /** @@ -123,18 +123,18 @@ export function clearTodos(state: TodoState, conversationId: string): void { * surface-registry re-fetches this on every notify. Mirrors `buildQueueSpec`. */ export function buildTodoSpec(todos: readonly TodoItem[]): SurfaceSpec { - const payload: { todos: readonly TodoItem[] } = { todos }; - const field: CustomField = { - kind: "custom", - rendererId: TODO_RENDERER_ID, - payload, - }; - return { - id: TODO_SURFACE_ID, - region: "side", - title: "Tasks", - fields: [field], - }; + const payload: { todos: readonly TodoItem[] } = { todos }; + const field: CustomField = { + kind: "custom", + rendererId: TODO_RENDERER_ID, + payload, + }; + return { + id: TODO_SURFACE_ID, + region: "side", + title: "Tasks", + fields: [field], + }; } /** @@ -144,5 +144,5 @@ export function buildTodoSpec(todos: readonly TodoItem[]): SurfaceSpec { * conversation history, so it needs no separate read tool. */ export function formatTodoResult(todos: readonly TodoItem[]): string { - return JSON.stringify(todos, null, 2); + return JSON.stringify(todos, null, 2); } diff --git a/packages/todo/src/store.test.ts b/packages/todo/src/store.test.ts index 92130a3..e61d77a 100644 --- a/packages/todo/src/store.test.ts +++ b/packages/todo/src/store.test.ts @@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest"; import { clearTodos, getTodos, setTodos, type TodoState } from "./pure.js"; describe("getTodos", () => { - it("getTodos: returns fresh array copy (not the live array)", () => { - const state: TodoState = new Map(); - setTodos(state, "c1", [{ content: "a", status: "pending" }]); - const snap = getTodos(state, "c1"); - expect(snap).toHaveLength(1); - // mutating the snapshot array does not affect live state - snap.push({ content: "evil", status: "completed" }); - expect(getTodos(state, "c1")).toHaveLength(1); - }); + it("getTodos: returns fresh array copy (not the live array)", () => { + const state: TodoState = new Map(); + setTodos(state, "c1", [{ content: "a", status: "pending" }]); + const snap = getTodos(state, "c1"); + expect(snap).toHaveLength(1); + // mutating the snapshot array does not affect live state + snap.push({ content: "evil", status: "completed" }); + expect(getTodos(state, "c1")).toHaveLength(1); + }); - it("getTodos: empty array for unknown conversation", () => { - const state: TodoState = new Map(); - expect(getTodos(state, "nope")).toEqual([]); - }); + it("getTodos: empty array for unknown conversation", () => { + const state: TodoState = new Map(); + expect(getTodos(state, "nope")).toEqual([]); + }); }); describe("setTodos", () => { - it("setTodos: replaces the list and returns a copy", () => { - const state: TodoState = new Map(); - setTodos(state, "c1", [{ content: "first", status: "pending" }]); - // replace with a different list - const snap = setTodos(state, "c1", [{ content: "second", status: "in_progress" }]); - expect(snap).toHaveLength(1); - const first = snap[0]; - if (first === undefined) throw new Error("expected an item"); - expect(first.content).toBe("second"); - // the previous list is gone - expect(getTodos(state, "c1").map((t) => t.content)).toEqual(["second"]); - }); + it("setTodos: replaces the list and returns a copy", () => { + const state: TodoState = new Map(); + setTodos(state, "c1", [{ content: "first", status: "pending" }]); + // replace with a different list + const snap = setTodos(state, "c1", [{ content: "second", status: "in_progress" }]); + expect(snap).toHaveLength(1); + const first = snap[0]; + if (first === undefined) throw new Error("expected an item"); + expect(first.content).toBe("second"); + // the previous list is gone + expect(getTodos(state, "c1").map((t) => t.content)).toEqual(["second"]); + }); }); describe("clearTodos", () => { - it("clearTodos: deletes the conversation's list", () => { - const state: TodoState = new Map(); - setTodos(state, "c1", [{ content: "x", status: "pending" }]); - expect(getTodos(state, "c1")).toHaveLength(1); - clearTodos(state, "c1"); - expect(getTodos(state, "c1")).toEqual([]); - // a second clear is a no-op - clearTodos(state, "c1"); - expect(getTodos(state, "c1")).toEqual([]); - }); + it("clearTodos: deletes the conversation's list", () => { + const state: TodoState = new Map(); + setTodos(state, "c1", [{ content: "x", status: "pending" }]); + expect(getTodos(state, "c1")).toHaveLength(1); + clearTodos(state, "c1"); + expect(getTodos(state, "c1")).toEqual([]); + // a second clear is a no-op + clearTodos(state, "c1"); + expect(getTodos(state, "c1")).toEqual([]); + }); }); diff --git a/packages/todo/src/tool.test.ts b/packages/todo/src/tool.test.ts index a125786..7811570 100644 --- a/packages/todo/src/tool.test.ts +++ b/packages/todo/src/tool.test.ts @@ -4,98 +4,98 @@ import { getTodos, type TodoState } from "./pure.js"; import { createTodoWriteTool } from "./tool.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } describe("todo_write", () => { - it("todo_write: replaces list + returns JSON result", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - const todos = [ - { content: "a", status: "pending" }, - { content: "b", status: "in_progress" }, - ]; - const result = await tool.execute({ todos }, stubCtx({ conversationId: "c1" })); - expect(result.isError).toBeUndefined(); - expect(result.content).toBe(JSON.stringify(todos, null, 2)); - expect(getTodos(state, "c1")).toEqual(todos); - }); + it("todo_write: replaces list + returns JSON result", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + const todos = [ + { content: "a", status: "pending" }, + { content: "b", status: "in_progress" }, + ]; + const result = await tool.execute({ todos }, stubCtx({ conversationId: "c1" })); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe(JSON.stringify(todos, null, 2)); + expect(getTodos(state, "c1")).toEqual(todos); + }); - it("todo_write: calls notify after write", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - expect(notify).not.toHaveBeenCalled(); - await tool.execute( - { todos: [{ content: "x", status: "pending" }] }, - stubCtx({ conversationId: "c1" }), - ); - expect(notify).toHaveBeenCalledTimes(1); - }); + it("todo_write: calls notify after write", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + expect(notify).not.toHaveBeenCalled(); + await tool.execute( + { todos: [{ content: "x", status: "pending" }] }, + stubCtx({ conversationId: "c1" }), + ); + expect(notify).toHaveBeenCalledTimes(1); + }); - it("todo_write: validation error returns isError", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - const result = await tool.execute( - { todos: [{ content: "x", status: "bogus" }] }, - stubCtx({ conversationId: "c1" }), - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("Error:"); - expect(notify).not.toHaveBeenCalled(); - }); + it("todo_write: validation error returns isError", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + const result = await tool.execute( + { todos: [{ content: "x", status: "bogus" }] }, + stubCtx({ conversationId: "c1" }), + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("Error:"); + expect(notify).not.toHaveBeenCalled(); + }); - it("todo_write: uses conversationId from ctx", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - await tool.execute( - { todos: [{ content: "x", status: "pending" }] }, - stubCtx({ conversationId: "conv-42" }), - ); - expect(getTodos(state, "conv-42")).toHaveLength(1); - // a different conversation is unaffected - expect(getTodos(state, "conv-other")).toEqual([]); - }); + it("todo_write: uses conversationId from ctx", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + await tool.execute( + { todos: [{ content: "x", status: "pending" }] }, + stubCtx({ conversationId: "conv-42" }), + ); + expect(getTodos(state, "conv-42")).toHaveLength(1); + // a different conversation is unaffected + expect(getTodos(state, "conv-other")).toEqual([]); + }); - it("todo_write: errors when conversationId is absent", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - const result = await tool.execute({ todos: [{ content: "x", status: "pending" }] }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toBe("Error: no conversation context for todo."); - expect(notify).not.toHaveBeenCalled(); - expect(state.size).toBe(0); - }); + it("todo_write: errors when conversationId is absent", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + const result = await tool.execute({ todos: [{ content: "x", status: "pending" }] }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toBe("Error: no conversation context for todo."); + expect(notify).not.toHaveBeenCalled(); + expect(state.size).toBe(0); + }); - it("todo_write: accepts empty array (clears list)", async () => { - const state: TodoState = new Map(); - const notify = vi.fn(); - const tool = createTodoWriteTool({ state, notify }); - // seed - await tool.execute( - { todos: [{ content: "seed", status: "pending" }] }, - stubCtx({ conversationId: "c1" }), - ); - expect(getTodos(state, "c1")).toHaveLength(1); - // clear via empty list - const result = await tool.execute({ todos: [] }, stubCtx({ conversationId: "c1" })); - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("[]"); - expect(getTodos(state, "c1")).toEqual([]); - expect(notify).toHaveBeenCalledTimes(2); - }); + it("todo_write: accepts empty array (clears list)", async () => { + const state: TodoState = new Map(); + const notify = vi.fn(); + const tool = createTodoWriteTool({ state, notify }); + // seed + await tool.execute( + { todos: [{ content: "seed", status: "pending" }] }, + stubCtx({ conversationId: "c1" }), + ); + expect(getTodos(state, "c1")).toHaveLength(1); + // clear via empty list + const result = await tool.execute({ todos: [] }, stubCtx({ conversationId: "c1" })); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("[]"); + expect(getTodos(state, "c1")).toEqual([]); + expect(notify).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/todo/src/tool.ts b/packages/todo/src/tool.ts index d95e949..7ec8db0 100644 --- a/packages/todo/src/tool.ts +++ b/packages/todo/src/tool.ts @@ -15,10 +15,10 @@ import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/ker import { formatTodoResult, setTodos, type TodoState, validateTodos } from "./pure.js"; export interface TodoWriteToolDeps { - /** Per-conversation todo store (owned by the extension shell). */ - readonly state: TodoState; - /** Fire surface subscribers after a successful write. */ - readonly notify: () => void; + /** Per-conversation todo store (owned by the extension shell). */ + readonly state: TodoState; + /** Fire surface subscribers after a successful write. */ + readonly notify: () => void; } const TODO_WRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for your current session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. @@ -189,52 +189,52 @@ When in doubt, use this tool. Being proactive with task management demonstrates /** Create the `todo_write` tool, closing over the shared state + notifier. */ export function createTodoWriteTool(deps: TodoWriteToolDeps): ToolContract { - const { state, notify } = deps; - return { - name: "todo_write", - description: TODO_WRITE_DESCRIPTION, - parameters: { - type: "object", - properties: { - todos: { - type: "array", - description: "The updated todo list (replaces the existing list).", - items: { - type: "object", - properties: { - content: { - type: "string", - description: "Brief description of the task.", - }, - status: { - type: "string", - enum: ["pending", "in_progress", "completed", "cancelled"], - description: "Current status of the task.", - }, - }, - required: ["content", "status"], - }, - }, - }, - required: ["todos"], - }, - concurrencySafe: false, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const conversationId = ctx.conversationId; - if (conversationId === undefined) { - return { content: "Error: no conversation context for todo.", isError: true }; - } - const validated = validateTodos(args); - if (!Array.isArray(validated)) { - return { content: validated.error, isError: true }; - } - const snapshot = setTodos(state, conversationId, validated); - notify(); - ctx.log.debug("todo_write: replaced list", { - conversationId, - count: snapshot.length, - }); - return { content: formatTodoResult(snapshot) }; - }, - }; + const { state, notify } = deps; + return { + name: "todo_write", + description: TODO_WRITE_DESCRIPTION, + parameters: { + type: "object", + properties: { + todos: { + type: "array", + description: "The updated todo list (replaces the existing list).", + items: { + type: "object", + properties: { + content: { + type: "string", + description: "Brief description of the task.", + }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed", "cancelled"], + description: "Current status of the task.", + }, + }, + required: ["content", "status"], + }, + }, + }, + required: ["todos"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const conversationId = ctx.conversationId; + if (conversationId === undefined) { + return { content: "Error: no conversation context for todo.", isError: true }; + } + const validated = validateTodos(args); + if (!Array.isArray(validated)) { + return { content: validated.error, isError: true }; + } + const snapshot = setTodos(state, conversationId, validated); + notify(); + ctx.log.debug("todo_write: replaced list", { + conversationId, + count: snapshot.length, + }); + return { content: formatTodoResult(snapshot) }; + }, + }; } diff --git a/packages/todo/src/validate.test.ts b/packages/todo/src/validate.test.ts index c518041..720f208 100644 --- a/packages/todo/src/validate.test.ts +++ b/packages/todo/src/validate.test.ts @@ -2,65 +2,65 @@ import { describe, expect, it } from "vitest"; import { validateTodos } from "./pure.js"; describe("validateTodos", () => { - it("validateTodos: accepts valid list", () => { - const result = validateTodos({ - todos: [ - { content: "Do thing A", status: "pending" }, - { content: "Do thing B", status: "in_progress" }, - { content: "Do thing C", status: "completed" }, - ], - }); - expect(result).toEqual([ - { content: "Do thing A", status: "pending" }, - { content: "Do thing B", status: "in_progress" }, - { content: "Do thing C", status: "completed" }, - ]); - }); + it("validateTodos: accepts valid list", () => { + const result = validateTodos({ + todos: [ + { content: "Do thing A", status: "pending" }, + { content: "Do thing B", status: "in_progress" }, + { content: "Do thing C", status: "completed" }, + ], + }); + expect(result).toEqual([ + { content: "Do thing A", status: "pending" }, + { content: "Do thing B", status: "in_progress" }, + { content: "Do thing C", status: "completed" }, + ]); + }); - it("validateTodos: accepts empty array (clears the list)", () => { - const result = validateTodos({ todos: [] }); - expect(result).toEqual([]); - }); + it("validateTodos: accepts empty array (clears the list)", () => { + const result = validateTodos({ todos: [] }); + expect(result).toEqual([]); + }); - it("validateTodos: rejects invalid status", () => { - const result = validateTodos({ - todos: [{ content: "x", status: "bogus" }], - }); - expect(result).toHaveProperty("error"); - }); + it("validateTodos: rejects invalid status", () => { + const result = validateTodos({ + todos: [{ content: "x", status: "bogus" }], + }); + expect(result).toHaveProperty("error"); + }); - it("validateTodos: rejects empty content", () => { - const empty = validateTodos({ - todos: [{ content: "", status: "pending" }], - }); - expect(empty).toHaveProperty("error"); - const whitespace = validateTodos({ - todos: [{ content: " ", status: "pending" }], - }); - expect(whitespace).toHaveProperty("error"); - }); + it("validateTodos: rejects empty content", () => { + const empty = validateTodos({ + todos: [{ content: "", status: "pending" }], + }); + expect(empty).toHaveProperty("error"); + const whitespace = validateTodos({ + todos: [{ content: " ", status: "pending" }], + }); + expect(whitespace).toHaveProperty("error"); + }); - it("validateTodos: rejects non-array todos", () => { - const result = validateTodos({ todos: "not-an-array" }); - expect(result).toHaveProperty("error"); - }); + it("validateTodos: rejects non-array todos", () => { + const result = validateTodos({ todos: "not-an-array" }); + expect(result).toHaveProperty("error"); + }); - it("validateTodos: rejects null/non-object args", () => { - expect(validateTodos(null)).toHaveProperty("error"); - expect(validateTodos(undefined)).toHaveProperty("error"); - expect(validateTodos("string")).toHaveProperty("error"); - expect(validateTodos(42)).toHaveProperty("error"); - expect(validateTodos([])).toHaveProperty("error"); - }); + it("validateTodos: rejects null/non-object args", () => { + expect(validateTodos(null)).toHaveProperty("error"); + expect(validateTodos(undefined)).toHaveProperty("error"); + expect(validateTodos("string")).toHaveProperty("error"); + expect(validateTodos(42)).toHaveProperty("error"); + expect(validateTodos([])).toHaveProperty("error"); + }); - it("validateTodos: does NOT enforce one in_progress (allows multiple — description guides the model)", () => { - const result = validateTodos({ - todos: [ - { content: "a", status: "in_progress" }, - { content: "b", status: "in_progress" }, - ], - }); - expect(Array.isArray(result)).toBe(true); - expect(result).toHaveLength(2); - }); + it("validateTodos: does NOT enforce one in_progress (allows multiple — description guides the model)", () => { + const result = validateTodos({ + todos: [ + { content: "a", status: "in_progress" }, + { content: "b", status: "in_progress" }, + ], + }); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + }); }); diff --git a/packages/todo/tsconfig.json b/packages/todo/tsconfig.json index 102c8f0..9030dd2 100644 --- a/packages/todo/tsconfig.json +++ b/packages/todo/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../surface-registry" }, - { "path": "../ui-contract" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../surface-registry" }, + { "path": "../ui-contract" } + ] } diff --git a/packages/tool-edit-file/package.json b/packages/tool-edit-file/package.json index 4bfac38..248064a 100644 --- a/packages/tool-edit-file/package.json +++ b/packages/tool-edit-file/package.json @@ -1,13 +1,13 @@ { - "name": "@dispatch/tool-edit-file", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/exec-backend": "workspace:*", - "@dispatch/lsp": "workspace:*" - } + "name": "@dispatch/tool-edit-file", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*", + "@dispatch/lsp": "workspace:*" + } } diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts index 9341102..694a59d 100644 --- a/packages/tool-edit-file/src/edit-file.test.ts +++ b/packages/tool-edit-file/src/edit-file.test.ts @@ -5,31 +5,31 @@ import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - computeReplacement, - createEditFileTool, - type DiagnosticsHook, - validateArgs, + computeReplacement, + createEditFileTool, + type DiagnosticsHook, + validateArgs, } from "./edit-file.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** No-op diagnostics — the post-edit LSP hook returning "no diagnostics". */ const noopDiagnostics: DiagnosticsHook = async () => ({ - formatted: "", - slow: false, - timedOut: false, + formatted: "", + slow: false, + timedOut: false, }); /** @@ -40,397 +40,397 @@ const noopDiagnostics: DiagnosticsHook = async () => ({ * build the tool inline. */ function makeTool( - diagnostics: DiagnosticsHook = noopDiagnostics, + diagnostics: DiagnosticsHook = noopDiagnostics, ): ReturnType { - return createEditFileTool({ - resolveBackend: () => localExecBackend, - workdir, - diagnostics, - }); + return createEditFileTool({ + resolveBackend: () => localExecBackend, + workdir, + diagnostics, + }); } let workdir: string; beforeEach(async () => { - workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-")); + workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-")); }); afterEach(async () => { - await rm(workdir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" }); - expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false }); - }); - - it("parses replaceAll true", () => { - const result = validateArgs({ - path: "f.txt", - oldString: "a", - newString: "b", - replaceAll: true, - }); - expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true }); - }); - - it("defaults replaceAll to false when omitted", () => { - const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" }); - expect(result).toHaveProperty("replaceAll", false); - }); - - it("returns error for null args", () => { - const result = validateArgs(null); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing path", () => { - const result = validateArgs({ oldString: "a", newString: "b" }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing oldString", () => { - const result = validateArgs({ path: "f.txt", newString: "b" }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing newString", () => { - const result = validateArgs({ path: "f.txt", oldString: "a" }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-string path", () => { - const result = validateArgs({ path: 123, oldString: "a", newString: "b" }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-string oldString", () => { - const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" }); - expect(result).toHaveProperty("error"); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" }); + expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false }); + }); + + it("parses replaceAll true", () => { + const result = validateArgs({ + path: "f.txt", + oldString: "a", + newString: "b", + replaceAll: true, + }); + expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true }); + }); + + it("defaults replaceAll to false when omitted", () => { + const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" }); + expect(result).toHaveProperty("replaceAll", false); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + const result = validateArgs({ oldString: "a", newString: "b" }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing oldString", () => { + const result = validateArgs({ path: "f.txt", newString: "b" }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing newString", () => { + const result = validateArgs({ path: "f.txt", oldString: "a" }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-string path", () => { + const result = validateArgs({ path: 123, oldString: "a", newString: "b" }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-string oldString", () => { + const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" }); + expect(result).toHaveProperty("error"); + }); }); describe("computeReplacement", () => { - it("replaces a single occurrence", () => { - const result = computeReplacement("hello world", "world", "there", false); - expect(result).toEqual({ content: "hello there", count: 1 }); - }); - - it("replaces all occurrences when replaceAll is true", () => { - const result = computeReplacement("aaa", "a", "b", true); - expect(result).toEqual({ content: "bbb", count: 3 }); - }); - - it("returns identical error when newString equals oldString", () => { - const result = computeReplacement("hello", "hello", "hello", false); - expect(result).toEqual({ kind: "identical" }); - }); - - it("returns notFound error when oldString is not in content", () => { - const result = computeReplacement("hello", "xyz", "abc", false); - expect(result).toEqual({ kind: "notFound" }); - }); - - it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => { - const result = computeReplacement("abc abc abc", "abc", "xyz", false); - expect(result).toEqual({ kind: "notUnique", count: 3 }); - }); - - it("replaces only the single match when unique", () => { - const result = computeReplacement("foo bar baz", "bar", "qux", false); - expect(result).toEqual({ content: "foo qux baz", count: 1 }); - }); - - it("handles replaceAll with multiple occurrences", () => { - const result = computeReplacement("one two one two", "two", "three", true); - expect(result).toEqual({ content: "one three one three", count: 2 }); - }); - - it("handles empty oldString as notFound (empty string not searched)", () => { - // empty oldString would cause infinite loop in split, so we treat it as not-found - const result = computeReplacement("hello", "", "x", false); - expect(result).toEqual({ kind: "notFound" }); - }); - - it("handles oldString at start of content", () => { - const result = computeReplacement("hello world", "hello", "goodbye", false); - expect(result).toEqual({ content: "goodbye world", count: 1 }); - }); - - it("handles oldString at end of content", () => { - const result = computeReplacement("hello world", "world", "there", false); - expect(result).toEqual({ content: "hello there", count: 1 }); - }); - - it("handles multiline oldString and newString", () => { - const content = "line1\nold line\nline3"; - const result = computeReplacement(content, "old line", "new line", false); - expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 }); - }); + it("replaces a single occurrence", () => { + const result = computeReplacement("hello world", "world", "there", false); + expect(result).toEqual({ content: "hello there", count: 1 }); + }); + + it("replaces all occurrences when replaceAll is true", () => { + const result = computeReplacement("aaa", "a", "b", true); + expect(result).toEqual({ content: "bbb", count: 3 }); + }); + + it("returns identical error when newString equals oldString", () => { + const result = computeReplacement("hello", "hello", "hello", false); + expect(result).toEqual({ kind: "identical" }); + }); + + it("returns notFound error when oldString is not in content", () => { + const result = computeReplacement("hello", "xyz", "abc", false); + expect(result).toEqual({ kind: "notFound" }); + }); + + it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => { + const result = computeReplacement("abc abc abc", "abc", "xyz", false); + expect(result).toEqual({ kind: "notUnique", count: 3 }); + }); + + it("replaces only the single match when unique", () => { + const result = computeReplacement("foo bar baz", "bar", "qux", false); + expect(result).toEqual({ content: "foo qux baz", count: 1 }); + }); + + it("handles replaceAll with multiple occurrences", () => { + const result = computeReplacement("one two one two", "two", "three", true); + expect(result).toEqual({ content: "one three one three", count: 2 }); + }); + + it("handles empty oldString as notFound (empty string not searched)", () => { + // empty oldString would cause infinite loop in split, so we treat it as not-found + const result = computeReplacement("hello", "", "x", false); + expect(result).toEqual({ kind: "notFound" }); + }); + + it("handles oldString at start of content", () => { + const result = computeReplacement("hello world", "hello", "goodbye", false); + expect(result).toEqual({ content: "goodbye world", count: 1 }); + }); + + it("handles oldString at end of content", () => { + const result = computeReplacement("hello world", "world", "there", false); + expect(result).toEqual({ content: "hello there", count: 1 }); + }); + + it("handles multiline oldString and newString", () => { + const content = "line1\nold line\nline3"; + const result = computeReplacement(content, "old line", "new line", false); + expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 }); + }); }); describe("createEditFileTool", () => { - it("replaces a single occurrence", async () => { - const filePath = join(workdir, "test.txt"); - await writeFile(filePath, "hello world\n", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "test.txt", oldString: "world", newString: "there" }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 1 occurrence"); - - const content = await readFile(filePath, "utf8"); - expect(content).toBe("hello there\n"); - }); - - it("replaces all occurrences when replaceAll is true", async () => { - const filePath = join(workdir, "test.txt"); - await writeFile(filePath, "aaa\n", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "test.txt", oldString: "a", newString: "b", replaceAll: true }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 3 occurrences"); - - const content = await readFile(filePath, "utf8"); - expect(content).toBe("bbb\n"); - }); - - it("errors when oldString is not found", async () => { - const filePath = join(workdir, "test.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "test.txt", oldString: "xyz", newString: "abc" }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("oldString not found"); - }); - - it("errors when oldString is non-unique and replaceAll is false", async () => { - const filePath = join(workdir, "test.txt"); - await writeFile(filePath, "abc abc abc\n", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "test.txt", oldString: "abc", newString: "xyz" }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Found 3 matches"); - }); - - it("errors when newString equals oldString", async () => { - const filePath = join(workdir, "test.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "test.txt", oldString: "hello", newString: "hello" }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("newString must differ from oldString"); - }); - - it("errors / not-found for a nonexistent file", async () => { - const tool = makeTool(); - const result = await tool.execute( - { path: "nonexistent.txt", oldString: "a", newString: "b" }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("not found"); - }); - - it("reads file under ctx.cwd when set", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); - try { - const filePath = join(ctxDir, "ctx-file.txt"); - await writeFile(filePath, "hello world", "utf8"); - - const tool = makeTool(); - const result = await tool.execute( - { path: "ctx-file.txt", oldString: "world", newString: "there" }, - stubCtx({ cwd: ctxDir }), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 1 occurrence"); - - const content = await readFile(filePath, "utf8"); - expect(content).toBe("hello there"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("falls back to baked workdir when ctx.cwd is omitted", async () => { - const filePath = join(workdir, "baked-file.txt"); - await writeFile(filePath, "hello world", "utf8"); - - const tool = makeTool(); - const ctx = stubCtx(); - expect(ctx.cwd).toBeUndefined(); - const result = await tool.execute( - { path: "baked-file.txt", oldString: "world", newString: "there" }, - ctx, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 1 occurrence"); - }); - - it("never throws on bad input (always returns ToolResult)", async () => { - const tool = makeTool(); - - const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; - for (const input of inputs) { - const result = await tool.execute(input, stubCtx()); - expect(result).toHaveProperty("content"); - expect(typeof result.content).toBe("string"); - } - }); - - it("concurrencySafe is false", () => { - const tool = makeTool(); - expect(tool.concurrencySafe).toBe(false); - }); - - it("has correct name and parameters shape", () => { - const tool = makeTool(); - expect(tool.name).toBe("edit_file"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]); - expect(tool.parameters.properties?.path?.type).toBe("string"); - expect(tool.parameters.properties?.oldString?.type).toBe("string"); - expect(tool.parameters.properties?.newString?.type).toBe("string"); - expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean"); - }); - - it("appends LSP diagnostics to the result when local and errors exist", async () => { - const filePath = join(workdir, "diag.txt"); - await writeFile(filePath, "hello world\n", "utf8"); - - let called = false; - const diagnostics: DiagnosticsHook = async (opts) => { - called = true; - expect(opts.text).toBe("hello there\n"); - return { formatted: "⚠️ 2 errors", slow: false, timedOut: false }; - }; - const tool = makeTool(diagnostics); - - const result = await tool.execute( - { path: "diag.txt", oldString: "world", newString: "there" }, - stubCtx(), - ); - - expect(called).toBe(true); - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 1 occurrence"); - expect(result.content).toContain("⚠️ 2 errors"); - }); - - it("appends the slow-diagnostics notice when LSP is slow", async () => { - const filePath = join(workdir, "slow.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - const diagnostics: DiagnosticsHook = async () => ({ - formatted: "", - slow: true, - timedOut: false, - }); - const tool = makeTool(diagnostics); - - const result = await tool.execute( - { path: "slow.txt", oldString: "hello", newString: "hi" }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Replaced 1 occurrence"); - expect(result.content).toContain("LSP is taking unusually long"); - }); - - it("calls LSP diagnostics when local (computerId undefined)", async () => { - const filePath = join(workdir, "local.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - let called = false; - const diagnostics: DiagnosticsHook = async () => { - called = true; - return { formatted: "", slow: false, timedOut: false }; - }; - const tool = makeTool(diagnostics); - - const result = await tool.execute( - { path: "local.txt", oldString: "hello", newString: "hi" }, - stubCtx(), // computerId omitted → undefined → local - ); - - expect(called).toBe(true); - expect(result.isError).toBeUndefined(); - expect(result.content).toBe('Replaced 1 occurrence in "local.txt".'); - }); - - it("skips LSP diagnostics when computerId is set (remote)", async () => { - const filePath = join(workdir, "remote.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - let called = false; - const diagnostics: DiagnosticsHook = async () => { - called = true; - return { formatted: "DIAG-SHOULD-NOT-APPEAR", slow: false, timedOut: false }; - }; - const tool = makeTool(diagnostics); - - const result = await tool.execute( - { path: "remote.txt", oldString: "hello", newString: "hi" }, - stubCtx({ computerId: "remote-host" }), - ); - - // Remote: the diagnostics hook is never invoked (LSP servers are local - // processes that can't see remote files over SFTP). - expect(called).toBe(false); - expect(result.isError).toBeUndefined(); - // The edit itself still succeeded against the (local) backend. - expect(result.content).toBe('Replaced 1 occurrence in "remote.txt".'); - expect(result.content).not.toContain("DIAG-SHOULD-NOT-APPEAR"); - - const content = await readFile(filePath, "utf8"); - expect(content).toBe("hi\n"); - }); - - it("swallows a throwing diagnostics hook (edit already succeeded)", async () => { - const filePath = join(workdir, "throw.txt"); - await writeFile(filePath, "hello\n", "utf8"); - - const diagnostics: DiagnosticsHook = async () => { - throw new Error("LSP exploded"); - }; - const tool = makeTool(diagnostics); - - const result = await tool.execute( - { path: "throw.txt", oldString: "hello", newString: "hi" }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe('Replaced 1 occurrence in "throw.txt".'); - }); + it("replaces a single occurrence", async () => { + const filePath = join(workdir, "test.txt"); + await writeFile(filePath, "hello world\n", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "test.txt", oldString: "world", newString: "there" }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("hello there\n"); + }); + + it("replaces all occurrences when replaceAll is true", async () => { + const filePath = join(workdir, "test.txt"); + await writeFile(filePath, "aaa\n", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "test.txt", oldString: "a", newString: "b", replaceAll: true }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 3 occurrences"); + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("bbb\n"); + }); + + it("errors when oldString is not found", async () => { + const filePath = join(workdir, "test.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "test.txt", oldString: "xyz", newString: "abc" }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("oldString not found"); + }); + + it("errors when oldString is non-unique and replaceAll is false", async () => { + const filePath = join(workdir, "test.txt"); + await writeFile(filePath, "abc abc abc\n", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "test.txt", oldString: "abc", newString: "xyz" }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Found 3 matches"); + }); + + it("errors when newString equals oldString", async () => { + const filePath = join(workdir, "test.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "test.txt", oldString: "hello", newString: "hello" }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("newString must differ from oldString"); + }); + + it("errors / not-found for a nonexistent file", async () => { + const tool = makeTool(); + const result = await tool.execute( + { path: "nonexistent.txt", oldString: "a", newString: "b" }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("reads file under ctx.cwd when set", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); + try { + const filePath = join(ctxDir, "ctx-file.txt"); + await writeFile(filePath, "hello world", "utf8"); + + const tool = makeTool(); + const result = await tool.execute( + { path: "ctx-file.txt", oldString: "world", newString: "there" }, + stubCtx({ cwd: ctxDir }), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("hello there"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + const filePath = join(workdir, "baked-file.txt"); + await writeFile(filePath, "hello world", "utf8"); + + const tool = makeTool(); + const ctx = stubCtx(); + expect(ctx.cwd).toBeUndefined(); + const result = await tool.execute( + { path: "baked-file.txt", oldString: "world", newString: "there" }, + ctx, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = makeTool(); + + const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; + for (const input of inputs) { + const result = await tool.execute(input, stubCtx()); + expect(result).toHaveProperty("content"); + expect(typeof result.content).toBe("string"); + } + }); + + it("concurrencySafe is false", () => { + const tool = makeTool(); + expect(tool.concurrencySafe).toBe(false); + }); + + it("has correct name and parameters shape", () => { + const tool = makeTool(); + expect(tool.name).toBe("edit_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + expect(tool.parameters.properties?.oldString?.type).toBe("string"); + expect(tool.parameters.properties?.newString?.type).toBe("string"); + expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean"); + }); + + it("appends LSP diagnostics to the result when local and errors exist", async () => { + const filePath = join(workdir, "diag.txt"); + await writeFile(filePath, "hello world\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async (opts) => { + called = true; + expect(opts.text).toBe("hello there\n"); + return { formatted: "⚠️ 2 errors", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "diag.txt", oldString: "world", newString: "there" }, + stubCtx(), + ); + + expect(called).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + expect(result.content).toContain("⚠️ 2 errors"); + }); + + it("appends the slow-diagnostics notice when LSP is slow", async () => { + const filePath = join(workdir, "slow.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const diagnostics: DiagnosticsHook = async () => ({ + formatted: "", + slow: true, + timedOut: false, + }); + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "slow.txt", oldString: "hello", newString: "hi" }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + expect(result.content).toContain("LSP is taking unusually long"); + }); + + it("calls LSP diagnostics when local (computerId undefined)", async () => { + const filePath = join(workdir, "local.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async () => { + called = true; + return { formatted: "", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "local.txt", oldString: "hello", newString: "hi" }, + stubCtx(), // computerId omitted → undefined → local + ); + + expect(called).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe('Replaced 1 occurrence in "local.txt".'); + }); + + it("skips LSP diagnostics when computerId is set (remote)", async () => { + const filePath = join(workdir, "remote.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async () => { + called = true; + return { formatted: "DIAG-SHOULD-NOT-APPEAR", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "remote.txt", oldString: "hello", newString: "hi" }, + stubCtx({ computerId: "remote-host" }), + ); + + // Remote: the diagnostics hook is never invoked (LSP servers are local + // processes that can't see remote files over SFTP). + expect(called).toBe(false); + expect(result.isError).toBeUndefined(); + // The edit itself still succeeded against the (local) backend. + expect(result.content).toBe('Replaced 1 occurrence in "remote.txt".'); + expect(result.content).not.toContain("DIAG-SHOULD-NOT-APPEAR"); + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("hi\n"); + }); + + it("swallows a throwing diagnostics hook (edit already succeeded)", async () => { + const filePath = join(workdir, "throw.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const diagnostics: DiagnosticsHook = async () => { + throw new Error("LSP exploded"); + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "throw.txt", oldString: "hello", newString: "hi" }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe('Replaced 1 occurrence in "throw.txt".'); + }); }); diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts index e588f66..2704bb6 100644 --- a/packages/tool-edit-file/src/edit-file.ts +++ b/packages/tool-edit-file/src/edit-file.ts @@ -5,102 +5,102 @@ import type { ToolContract, ToolResult } from "@dispatch/kernel"; // --- Pure types --- interface ValidatedArgs { - readonly path: string; - readonly oldString: string; - readonly newString: string; - readonly replaceAll: boolean; + readonly path: string; + readonly oldString: string; + readonly newString: string; + readonly replaceAll: boolean; } export type ReplacementError = - | { readonly kind: "identical" } - | { readonly kind: "notFound" } - | { readonly kind: "notUnique"; readonly count: number }; + | { readonly kind: "identical" } + | { readonly kind: "notFound" } + | { readonly kind: "notUnique"; readonly count: number }; export interface ReplacementSuccess { - readonly content: string; - readonly count: number; + readonly content: string; + readonly count: number; } // --- Pure functions --- /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; - - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.length === 0) { - return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; - } - - const rawOld = obj.oldString; - if (typeof rawOld !== "string" || rawOld.length === 0) { - return { - error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).', - }; - } - - const rawNew = obj.newString; - if (typeof rawNew !== "string") { - return { - error: 'Error: Missing or invalid "newString" parameter (must be a string).', - }; - } - - const rawReplaceAll = obj.replaceAll; - const replaceAll = rawReplaceAll === true; - - return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; + } + + const rawOld = obj.oldString; + if (typeof rawOld !== "string" || rawOld.length === 0) { + return { + error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).', + }; + } + + const rawNew = obj.newString; + if (typeof rawNew !== "string") { + return { + error: 'Error: Missing or invalid "newString" parameter (must be a string).', + }; + } + + const rawReplaceAll = obj.replaceAll; + const replaceAll = rawReplaceAll === true; + + return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll }; } /** Pure: compute the replacement result given file content + params. */ export function computeReplacement( - content: string, - oldString: string, - newString: string, - replaceAll: boolean, + content: string, + oldString: string, + newString: string, + replaceAll: boolean, ): ReplacementSuccess | ReplacementError { - if (oldString === newString) { - return { kind: "identical" }; - } - - if (oldString === "") { - return { kind: "notFound" }; - } - - if (!content.includes(oldString)) { - return { kind: "notFound" }; - } - - if (replaceAll) { - const parts = content.split(oldString); - const count = parts.length - 1; - return { content: parts.join(newString), count }; - } - - // Single replacement — check uniqueness. - const firstIndex = content.indexOf(oldString); - const secondIndex = content.indexOf(oldString, firstIndex + oldString.length); - if (secondIndex !== -1) { - // Count total occurrences. - let count = 0; - let idx = 0; - while (true) { - idx = content.indexOf(oldString, idx); - if (idx === -1) break; - count++; - idx += oldString.length; - } - return { kind: "notUnique", count }; - } - - return { - content: - content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length), - count: 1, - }; + if (oldString === newString) { + return { kind: "identical" }; + } + + if (oldString === "") { + return { kind: "notFound" }; + } + + if (!content.includes(oldString)) { + return { kind: "notFound" }; + } + + if (replaceAll) { + const parts = content.split(oldString); + const count = parts.length - 1; + return { content: parts.join(newString), count }; + } + + // Single replacement — check uniqueness. + const firstIndex = content.indexOf(oldString); + const secondIndex = content.indexOf(oldString, firstIndex + oldString.length); + if (secondIndex !== -1) { + // Count total occurrences. + let count = 0; + let idx = 0; + while (true) { + idx = content.indexOf(oldString, idx); + if (idx === -1) break; + count++; + idx += oldString.length; + } + return { kind: "notUnique", count }; + } + + return { + content: + content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length), + count: 1, + }; } // --- Diagnostics hook --- @@ -111,13 +111,13 @@ export function computeReplacement( * service; absent when no LSP is available (graceful degradation). */ export type DiagnosticsHook = (opts: { - readonly filePath: string; - readonly text: string; - readonly cwd: string; + readonly filePath: string; + readonly text: string; + readonly cwd: string; }) => Promise<{ - readonly formatted: string; - readonly slow: boolean; - readonly timedOut: boolean; + readonly formatted: string; + readonly slow: boolean; + readonly timedOut: boolean; }>; // --- Shell / edge --- @@ -141,156 +141,156 @@ export type DiagnosticsHook = (opts: { * remote files over SFTP, so the no-LSP degradation path is used instead. */ export function createEditFileTool(deps: { - readonly resolveBackend: ExecBackendResolver; - readonly workdir?: string; - readonly diagnostics: DiagnosticsHook; + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; + readonly diagnostics: DiagnosticsHook; }): ToolContract { - const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; - - return { - name: "edit_file", - description: - "Perform an exact string replacement in an existing file. " + - "Provide oldString (the text to find) and newString (the replacement). " + - "By default replaces a single occurrence; set replaceAll to replace every match.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - oldString: { - type: "string", - description: "The exact string to find and replace.", - }, - newString: { - type: "string", - description: "The string to replace oldString with.", - }, - replaceAll: { - type: "boolean", - description: "Replace all occurrences (default: false).", - default: false, - }, - }, - required: ["path", "oldString", "newString"], - }, - concurrencySafe: false, - async execute(args: unknown, ctx): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { path: relPath, oldString, newString, replaceAll } = validated; - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; - if (effectiveBase === undefined) { - return { - content: - "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", - isError: true, - }; - } - const resolvedPath = resolve(effectiveBase, relPath); - - const backend: ExecBackend = deps.resolveBackend(ctx.computerId); - - // Read the file. - let content: string; - try { - content = await backend.readFile(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Pure replacement decision. - const result = computeReplacement(content, oldString, newString, replaceAll); - - if ("kind" in result) { - switch (result.kind) { - case "identical": - return { - content: "Error: newString must differ from oldString.", - isError: true, - }; - case "notFound": - return { - content: `Error: oldString not found in content of "${relPath}".`, - isError: true, - }; - case "notUnique": - return { - content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`, - isError: true, - }; - } - } - - // Write the modified content back. - try { - await backend.writeFile(resolvedPath, result.content); - } catch (err: unknown) { - return { - content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - const plural = result.count === 1 ? "" : "s"; - let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`; - - // After a successful edit, query LSP diagnostics (if available). - // Only append if there are actual errors/warnings (no noise on clean edits). - const diagnostics = deps.diagnostics; - if (diagnostics) { - let diag: { - readonly formatted: string; - readonly slow: boolean; - readonly timedOut: boolean; - }; - if (ctx.computerId !== undefined) { - // REMOTE: LSP servers are local processes that can't see remote - // files over SFTP — skip the diagnostics call (the no-LSP - // degradation path). Forward-compatible: computerId is always - // undefined this wave, so behavior is byte-identical to today. - diag = { formatted: "", slow: false, timedOut: false }; - } else { - try { - const cwd = ctx.cwd ?? process.cwd(); - diag = await diagnostics({ - filePath: resolvedPath, - text: result.content, - cwd, - }); - } catch { - // LSP diagnostics failure is non-fatal — the edit already succeeded. - diag = { formatted: "", slow: false, timedOut: false }; - } - } - const suffix: string[] = []; - if (diag.slow) { - suffix.push( - "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", - ); - } - if (diag.formatted) { - suffix.push(diag.formatted); - } - if (suffix.length > 0) { - baseContent += `\n\n${suffix.join("\n\n")}`; - } - } - - return { content: baseContent }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "edit_file", + description: + "Perform an exact string replacement in an existing file. " + + "Provide oldString (the text to find) and newString (the replacement). " + + "By default replaces a single occurrence; set replaceAll to replace every match.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + oldString: { + type: "string", + description: "The exact string to find and replace.", + }, + newString: { + type: "string", + description: "The string to replace oldString with.", + }, + replaceAll: { + type: "boolean", + description: "Replace all occurrences (default: false).", + default: false, + }, + }, + required: ["path", "oldString", "newString"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, oldString, newString, replaceAll } = validated; + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } + const resolvedPath = resolve(effectiveBase, relPath); + + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Read the file. + let content: string; + try { + content = await backend.readFile(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Pure replacement decision. + const result = computeReplacement(content, oldString, newString, replaceAll); + + if ("kind" in result) { + switch (result.kind) { + case "identical": + return { + content: "Error: newString must differ from oldString.", + isError: true, + }; + case "notFound": + return { + content: `Error: oldString not found in content of "${relPath}".`, + isError: true, + }; + case "notUnique": + return { + content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`, + isError: true, + }; + } + } + + // Write the modified content back. + try { + await backend.writeFile(resolvedPath, result.content); + } catch (err: unknown) { + return { + content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + const plural = result.count === 1 ? "" : "s"; + let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`; + + // After a successful edit, query LSP diagnostics (if available). + // Only append if there are actual errors/warnings (no noise on clean edits). + const diagnostics = deps.diagnostics; + if (diagnostics) { + let diag: { + readonly formatted: string; + readonly slow: boolean; + readonly timedOut: boolean; + }; + if (ctx.computerId !== undefined) { + // REMOTE: LSP servers are local processes that can't see remote + // files over SFTP — skip the diagnostics call (the no-LSP + // degradation path). Forward-compatible: computerId is always + // undefined this wave, so behavior is byte-identical to today. + diag = { formatted: "", slow: false, timedOut: false }; + } else { + try { + const cwd = ctx.cwd ?? process.cwd(); + diag = await diagnostics({ + filePath: resolvedPath, + text: result.content, + cwd, + }); + } catch { + // LSP diagnostics failure is non-fatal — the edit already succeeded. + diag = { formatted: "", slow: false, timedOut: false }; + } + } + const suffix: string[] = []; + if (diag.slow) { + suffix.push( + "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", + ); + } + if (diag.formatted) { + suffix.push(diag.formatted); + } + if (suffix.length > 0) { + baseContent += `\n\n${suffix.join("\n\n")}`; + } + } + + return { content: baseContent }; + }, + }; } diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts index 9dbebda..b50247c 100644 --- a/packages/tool-edit-file/src/extension.ts +++ b/packages/tool-edit-file/src/extension.ts @@ -4,51 +4,51 @@ import { type LspService, lspServiceHandle } from "@dispatch/lsp"; import { createEditFileTool, type DiagnosticsHook } from "./edit-file.js"; export const extension: Extension = { - manifest: { - id: "tool-edit-file", - name: "Edit File Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { fs: true }, - contributes: { tools: ["edit_file"] }, - // Host activates exec-backend first → host.getService at activation is safe. - // LSP stays lazy (looked up at edit time, not activation): the LSP extension - // activates AFTER us in the CORE_EXTENSIONS array, so resolving it here would - // throw; the diagnostics hook below defers the lookup to execute(). - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); + manifest: { + id: "tool-edit-file", + name: "Edit File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["edit_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + // LSP stays lazy (looked up at edit time, not activation): the LSP extension + // activates AFTER us in the CORE_EXTENSIONS array, so resolving it here would + // throw; the diagnostics hook below defers the lookup to execute(). + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); - // Lazy LSP lookup: the LSP extension activates AFTER us in the - // CORE_EXTENSIONS array, so host.getService would throw at activation - // time. Instead, defer the lookup to edit time — by then all extensions - // have activated. If LSP isn't loaded, the try/catch returns a no-op - // (graceful degradation: edits proceed without diagnostics). - const diagnostics: DiagnosticsHook = async (opts) => { - let lspService: LspService | undefined; - try { - lspService = host.getService(lspServiceHandle); - } catch { - return { formatted: "", slow: false, timedOut: false }; - } - if (!lspService) { - return { formatted: "", slow: false, timedOut: false }; - } - return lspService.getDiagnostics({ - filePath: opts.filePath, - text: opts.text, - cwd: opts.cwd, - // 10s matches the LSP service's per-server cap (see packages/lsp). - // The service clamps this anyway; stated explicitly so the call - // site is honest about the effective live-diagnostics budget. - timeoutMs: 10_000, - minSeverity: 2, // errors + warnings only - }); - }; + // Lazy LSP lookup: the LSP extension activates AFTER us in the + // CORE_EXTENSIONS array, so host.getService would throw at activation + // time. Instead, defer the lookup to edit time — by then all extensions + // have activated. If LSP isn't loaded, the try/catch returns a no-op + // (graceful degradation: edits proceed without diagnostics). + const diagnostics: DiagnosticsHook = async (opts) => { + let lspService: LspService | undefined; + try { + lspService = host.getService(lspServiceHandle); + } catch { + return { formatted: "", slow: false, timedOut: false }; + } + if (!lspService) { + return { formatted: "", slow: false, timedOut: false }; + } + return lspService.getDiagnostics({ + filePath: opts.filePath, + text: opts.text, + cwd: opts.cwd, + // 10s matches the LSP service's per-server cap (see packages/lsp). + // The service clamps this anyway; stated explicitly so the call + // site is honest about the effective live-diagnostics budget. + timeoutMs: 10_000, + minSeverity: 2, // errors + warnings only + }); + }; - host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics })); - }, + host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics })); + }, }; diff --git a/packages/tool-edit-file/tsconfig.json b/packages/tool-edit-file/tsconfig.json index e4ee1eb..b4476e7 100644 --- a/packages/tool-edit-file/tsconfig.json +++ b/packages/tool-edit-file/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }, { "path": "../lsp" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }, { "path": "../lsp" }] } diff --git a/packages/tool-read-file/package.json b/packages/tool-read-file/package.json index ffb974d..5a08095 100644 --- a/packages/tool-read-file/package.json +++ b/packages/tool-read-file/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/tool-read-file", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/exec-backend": "workspace:*" - } + "name": "@dispatch/tool-read-file", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" + } } diff --git a/packages/tool-read-file/src/extension.ts b/packages/tool-read-file/src/extension.ts index 5a0b7c5..273727f 100644 --- a/packages/tool-read-file/src/extension.ts +++ b/packages/tool-read-file/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createReadFileTool } from "./read-file.js"; export const extension: Extension = { - manifest: { - id: "tool-read-file", - name: "Read File Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { fs: true }, - contributes: { tools: ["read_file"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() })); - }, + manifest: { + id: "tool-read-file", + name: "Read File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["read_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() })); + }, }; diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts index bac5902..785769a 100644 --- a/packages/tool-read-file/src/read-file.test.ts +++ b/packages/tool-read-file/src/read-file.test.ts @@ -5,25 +5,25 @@ import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createReadFileTool, - formatDirectoryEntries, - renderLines, - sliceLines, - validateArgs, + createReadFileTool, + formatDirectoryEntries, + renderLines, + sliceLines, + validateArgs, } from "./read-file.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** @@ -32,355 +32,355 @@ function stubCtx(overrides?: Partial): ToolExecuteContext { * real fs edge is exercised, matching the constitution's strict-core rule. */ function makeTool(workdir: string) { - return createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); + return createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); } let workdir: string; beforeEach(async () => { - workdir = await mkdtemp(join(tmpdir(), "tool-read-file-test-")); + workdir = await mkdtemp(join(tmpdir(), "tool-read-file-test-")); }); afterEach(async () => { - await rm(workdir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ path: "foo.txt" }); - expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 500 }); - }); - - it("parses offset and limit", () => { - const result = validateArgs({ path: "foo.txt", offset: 5, limit: 10 }); - expect(result).toEqual({ path: "foo.txt", offset: 5, limit: 10 }); - }); - - it("clamps limit to hard cap of 5000", () => { - const result = validateArgs({ path: "foo.txt", limit: 99999 }); - expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 5000 }); - }); - - it("returns error for null args", () => { - const result = validateArgs(null); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing path", () => { - const result = validateArgs({}); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-string path", () => { - const result = validateArgs({ path: 123 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid offset", () => { - const result = validateArgs({ path: "foo.txt", offset: -1 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid limit", () => { - const result = validateArgs({ path: "foo.txt", limit: 0 }); - expect(result).toHaveProperty("error"); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "foo.txt" }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 500 }); + }); + + it("parses offset and limit", () => { + const result = validateArgs({ path: "foo.txt", offset: 5, limit: 10 }); + expect(result).toEqual({ path: "foo.txt", offset: 5, limit: 10 }); + }); + + it("clamps limit to hard cap of 5000", () => { + const result = validateArgs({ path: "foo.txt", limit: 99999 }); + expect(result).toEqual({ path: "foo.txt", offset: 1, limit: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-string path", () => { + const result = validateArgs({ path: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid offset", () => { + const result = validateArgs({ path: "foo.txt", offset: -1 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid limit", () => { + const result = validateArgs({ path: "foo.txt", limit: 0 }); + expect(result).toHaveProperty("error"); + }); }); describe("sliceLines", () => { - it("returns all lines with offset=1, limit=500", () => { - const content = "line1\nline2\nline3"; - const result = sliceLines(content, 1, 500); - expect(result.lines).toEqual(["line1", "line2", "line3"]); - expect(result.totalLines).toBe(3); - }); - - it("slices with offset", () => { - const content = "line1\nline2\nline3\nline4"; - const result = sliceLines(content, 2, 2); - expect(result.lines).toEqual(["line2", "line3"]); - expect(result.totalLines).toBe(4); - }); - - it("handles offset beyond content", () => { - const content = "line1\nline2"; - const result = sliceLines(content, 10, 5); - expect(result.lines).toEqual([]); - expect(result.totalLines).toBe(2); - }); - - it("handles single line (no newline)", () => { - const content = "only line"; - const result = sliceLines(content, 1, 10); - expect(result.lines).toEqual(["only line"]); - expect(result.totalLines).toBe(1); - }); + it("returns all lines with offset=1, limit=500", () => { + const content = "line1\nline2\nline3"; + const result = sliceLines(content, 1, 500); + expect(result.lines).toEqual(["line1", "line2", "line3"]); + expect(result.totalLines).toBe(3); + }); + + it("slices with offset", () => { + const content = "line1\nline2\nline3\nline4"; + const result = sliceLines(content, 2, 2); + expect(result.lines).toEqual(["line2", "line3"]); + expect(result.totalLines).toBe(4); + }); + + it("handles offset beyond content", () => { + const content = "line1\nline2"; + const result = sliceLines(content, 10, 5); + expect(result.lines).toEqual([]); + expect(result.totalLines).toBe(2); + }); + + it("handles single line (no newline)", () => { + const content = "only line"; + const result = sliceLines(content, 1, 10); + expect(result.lines).toEqual(["only line"]); + expect(result.totalLines).toBe(1); + }); }); describe("renderLines", () => { - it("renders lines with 1-indexed line numbers", () => { - const result = renderLines(["a", "b", "c"], 1); - expect(result).toBe("1: a\n2: b\n3: c"); - }); - - it("renders with custom offset", () => { - const result = renderLines(["x", "y"], 10); - expect(result).toBe("10: x\n11: y"); - }); + it("renders lines with 1-indexed line numbers", () => { + const result = renderLines(["a", "b", "c"], 1); + expect(result).toBe("1: a\n2: b\n3: c"); + }); + + it("renders with custom offset", () => { + const result = renderLines(["x", "y"], 10); + expect(result).toBe("10: x\n11: y"); + }); }); describe("formatDirectoryEntries", () => { - it("lists directory entries sorted with trailing slash on subdirectories", () => { - const entries = [ - { name: "zebra.txt", isDirectory: false }, - { name: "alpha", isDirectory: true }, - { name: "readme.md", isDirectory: false }, - { name: "beta", isDirectory: true }, - ]; - const result = formatDirectoryEntries(entries, "mydir"); - expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt"); - }); - - it("returns empty-directory message for an empty dir", () => { - const result = formatDirectoryEntries([], "empty-dir"); - expect(result).toBe("(empty directory: empty-dir)"); - }); - - it("handles mixed files and directories with same name sorting", () => { - const entries = [ - { name: "b", isDirectory: false }, - { name: "a", isDirectory: true }, - ]; - const result = formatDirectoryEntries(entries, "."); - expect(result).toBe("a/\nb"); - }); + it("lists directory entries sorted with trailing slash on subdirectories", () => { + const entries = [ + { name: "zebra.txt", isDirectory: false }, + { name: "alpha", isDirectory: true }, + { name: "readme.md", isDirectory: false }, + { name: "beta", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "mydir"); + expect(result).toBe("alpha/\nbeta/\nreadme.md\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", () => { + const result = formatDirectoryEntries([], "empty-dir"); + expect(result).toBe("(empty directory: empty-dir)"); + }); + + it("handles mixed files and directories with same name sorting", () => { + const entries = [ + { name: "b", isDirectory: false }, + { name: "a", isDirectory: true }, + ]; + const result = formatDirectoryEntries(entries, "."); + expect(result).toBe("a/\nb"); + }); }); describe("createReadFileTool", () => { - it("reads a real temp file", async () => { - const filePath = join(workdir, "hello.txt"); - await writeFile(filePath, "hello\nworld\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "hello.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: hello"); - expect(result.content).toContain("2: world"); - }); - - it("respects offset and limit", async () => { - const filePath = join(workdir, "lines.txt"); - await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("2: b\n3: c"); - }); - - it("returns error for missing file", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("not found"); - }); - - it("returns empty-file content for empty file", async () => { - const filePath = join(workdir, "empty.txt"); - await writeFile(filePath, "", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("empty file"); - expect(result.content).toContain("empty.txt"); - }); - - it("returns error for offset beyond file length", async () => { - const filePath = join(workdir, "short.txt"); - await writeFile(filePath, "one\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("exceeds total lines"); - }); - - it("never throws on bad input (always returns ToolResult)", async () => { - const tool = makeTool(workdir); - - const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; - for (const input of inputs) { - const result = await tool.execute(input, stubCtx()); - expect(result).toHaveProperty("content"); - expect(typeof result.content).toBe("string"); - } - }); - - it("concurrencySafe is true", () => { - const tool = makeTool(workdir); - expect(tool.concurrencySafe).toBe(true); - }); - - it("has correct name and parameters shape", () => { - const tool = makeTool(workdir); - expect(tool.name).toBe("read_file"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["path"]); - expect(tool.parameters.properties?.path?.type).toBe("string"); - }); - - it("reads file under ctx.cwd when set (not baked workdir)", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); - try { - const filePath = join(ctxDir, "ctx-file.txt"); - await writeFile(filePath, "from ctx cwd", "utf8"); - - const tool = makeTool(workdir); // baked workdir is different - const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir })); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: from ctx cwd"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("falls back to baked workdir when ctx.cwd is omitted", async () => { - const filePath = join(workdir, "baked-file.txt"); - await writeFile(filePath, "from baked workdir", "utf8"); - - const tool = makeTool(workdir); - const ctx = stubCtx(); - // Ensure cwd is undefined - expect(ctx.cwd).toBeUndefined(); - const result = await tool.execute({ path: "baked-file.txt" }, ctx); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: from baked workdir"); - }); - - it("lists directory entries sorted with trailing slash on subdirectories", async () => { - await mkdir(join(workdir, "subdir")); - await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); - await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "." }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt"); - }); - - it("returns empty-directory message for an empty dir", async () => { - await mkdir(join(workdir, "empty-dir")); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty-dir" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("(empty directory: empty-dir)"); - }); - - it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { - await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("2: b\n3: c\n4: d"); - }); - - it("returns not-found for a nonexistent path", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("not found"); - }); - - it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => { - // A fake backend records what it is asked to do. Proves the tool programs - // against the ExecBackend surface (not node:fs) and that the resolver is - // invoked with ctx.computerId — the SSH seam. No real fs involved. - let statCalls = 0; - let readFileCalls = 0; - let readdirCalls = 0; - let receivedComputerId: string | undefined = "__sentinel__"; - const fakeBackend = { - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), - readFile: async (path: string) => { - readFileCalls++; - expect(path).toContain("seam.txt"); - return "fake-line-1\nfake-line-2"; - }, - writeFile: async () => {}, - stat: async (path: string) => { - statCalls++; - expect(path).toContain("seam.txt"); - return { isFile: true, isDirectory: false }; - }, - readdir: async () => { - readdirCalls++; - return []; - }, - exists: async () => true, - } as const; - - const tool = createReadFileTool({ - resolveBackend: (computerId) => { - receivedComputerId = computerId; - return fakeBackend; - }, - workdir, - }); - const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" })); - - expect(receivedComputerId).toBe("prod-ssh"); - expect(statCalls).toBe(1); - expect(readFileCalls).toBe(1); - expect(readdirCalls).toBe(0); - expect(result.isError).toBeUndefined(); - expect(result.content).toBe("1: fake-line-1\n2: fake-line-2"); - }); - - it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => { - // computerId undefined → resolver returns localExecBackend → real fs. - const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); - const filePath = join(workdir, "compat.txt"); - await writeFile(filePath, "real fs via backend\n", "utf8"); - - const result = await tool.execute({ path: "compat.txt" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("1: real fs via backend"); - }); - - it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => { - const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { - code: "ENOENT", - }); - const fakeBackend = { - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), - readFile: async () => "unused", - writeFile: async () => {}, - stat: async () => { - throw enoent; - }, - readdir: async () => [], - exists: async () => false, - } as const; - - const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir }); - const result = await tool.execute({ path: "ghost.txt" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toBe('Error: File "ghost.txt" not found.'); - }); + it("reads a real temp file", async () => { + const filePath = join(workdir, "hello.txt"); + await writeFile(filePath, "hello\nworld\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "hello.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: hello"); + expect(result.content).toContain("2: world"); + }); + + it("respects offset and limit", async () => { + const filePath = join(workdir, "lines.txt"); + await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c"); + }); + + it("returns error for missing file", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("returns empty-file content for empty file", async () => { + const filePath = join(workdir, "empty.txt"); + await writeFile(filePath, "", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("empty file"); + expect(result.content).toContain("empty.txt"); + }); + + it("returns error for offset beyond file length", async () => { + const filePath = join(workdir, "short.txt"); + await writeFile(filePath, "one\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("exceeds total lines"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = makeTool(workdir); + + const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; + for (const input of inputs) { + const result = await tool.execute(input, stubCtx()); + expect(result).toHaveProperty("content"); + expect(typeof result.content).toBe("string"); + } + }); + + it("concurrencySafe is true", () => { + const tool = makeTool(workdir); + expect(tool.concurrencySafe).toBe(true); + }); + + it("has correct name and parameters shape", () => { + const tool = makeTool(workdir); + expect(tool.name).toBe("read_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + }); + + it("reads file under ctx.cwd when set (not baked workdir)", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); + try { + const filePath = join(ctxDir, "ctx-file.txt"); + await writeFile(filePath, "from ctx cwd", "utf8"); + + const tool = makeTool(workdir); // baked workdir is different + const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir })); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: from ctx cwd"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + const filePath = join(workdir, "baked-file.txt"); + await writeFile(filePath, "from baked workdir", "utf8"); + + const tool = makeTool(workdir); + const ctx = stubCtx(); + // Ensure cwd is undefined + expect(ctx.cwd).toBeUndefined(); + const result = await tool.execute({ path: "baked-file.txt" }, ctx); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: from baked workdir"); + }); + + it("lists directory entries sorted with trailing slash on subdirectories", async () => { + await mkdir(join(workdir, "subdir")); + await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); + await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "." }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("alpha.txt\nsubdir/\nzebra.txt"); + }); + + it("returns empty-directory message for an empty dir", async () => { + await mkdir(join(workdir, "empty-dir")); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty-dir" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("(empty directory: empty-dir)"); + }); + + it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { + await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("2: b\n3: c\n4: d"); + }); + + it("returns not-found for a nonexistent path", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("not found"); + }); + + it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => { + // A fake backend records what it is asked to do. Proves the tool programs + // against the ExecBackend surface (not node:fs) and that the resolver is + // invoked with ctx.computerId — the SSH seam. No real fs involved. + let statCalls = 0; + let readFileCalls = 0; + let readdirCalls = 0; + let receivedComputerId: string | undefined = "__sentinel__"; + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async (path: string) => { + readFileCalls++; + expect(path).toContain("seam.txt"); + return "fake-line-1\nfake-line-2"; + }, + writeFile: async () => {}, + stat: async (path: string) => { + statCalls++; + expect(path).toContain("seam.txt"); + return { isFile: true, isDirectory: false }; + }, + readdir: async () => { + readdirCalls++; + return []; + }, + exists: async () => true, + } as const; + + const tool = createReadFileTool({ + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend; + }, + workdir, + }); + const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" })); + + expect(receivedComputerId).toBe("prod-ssh"); + expect(statCalls).toBe(1); + expect(readFileCalls).toBe(1); + expect(readdirCalls).toBe(0); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("1: fake-line-1\n2: fake-line-2"); + }); + + it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => { + // computerId undefined → resolver returns localExecBackend → real fs. + const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); + const filePath = join(workdir, "compat.txt"); + await writeFile(filePath, "real fs via backend\n", "utf8"); + + const result = await tool.execute({ path: "compat.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: real fs via backend"); + }); + + it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { + code: "ENOENT", + }); + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async () => "unused", + writeFile: async () => {}, + stat: async () => { + throw enoent; + }, + readdir: async () => [], + exists: async () => false, + } as const; + + const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir }); + const result = await tool.execute({ path: "ghost.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toBe('Error: File "ghost.txt" not found.'); + }); }); diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts index b88c241..b77f63c 100644 --- a/packages/tool-read-file/src/read-file.ts +++ b/packages/tool-read-file/src/read-file.ts @@ -6,68 +6,68 @@ const DEFAULT_LIMIT = 500; const HARD_CAP = 5000; interface ValidatedArgs { - readonly path: string; - readonly offset: number; - readonly limit: number; + readonly path: string; + readonly offset: number; + readonly limit: number; } /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; - - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.length === 0) { - return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; - } - - let offset = 1; - if (obj.offset !== undefined) { - const n = Number(obj.offset); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; - } - offset = Math.floor(n); - } - - let limit = DEFAULT_LIMIT; - if (obj.limit !== undefined) { - const n = Number(obj.limit); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; - } - limit = Math.min(Math.floor(n), HARD_CAP); - } else { - limit = Math.min(limit, HARD_CAP); - } - - return { path: rawPath, offset, limit }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; + } + + let offset = 1; + if (obj.offset !== undefined) { + const n = Number(obj.offset); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "offset" parameter (must be a positive integer).' }; + } + offset = Math.floor(n); + } + + let limit = DEFAULT_LIMIT; + if (obj.limit !== undefined) { + const n = Number(obj.limit); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "limit" parameter (must be a positive integer).' }; + } + limit = Math.min(Math.floor(n), HARD_CAP); + } else { + limit = Math.min(limit, HARD_CAP); + } + + return { path: rawPath, offset, limit }; } /** Pure: slice lines from content (1-indexed offset). */ export function sliceLines( - content: string, - offset: number, - limit: number, + content: string, + offset: number, + limit: number, ): { readonly lines: readonly string[]; readonly totalLines: number } { - const allLines = content.split("\n"); - const totalLines = allLines.length; - const start = offset - 1; // convert to 0-indexed - const sliced = allLines.slice(start, start + limit); - return { lines: sliced, totalLines }; + const allLines = content.split("\n"); + const totalLines = allLines.length; + const start = offset - 1; // convert to 0-indexed + const sliced = allLines.slice(start, start + limit); + return { lines: sliced, totalLines }; } /** Pure: render lines into a string with line numbers. */ export function renderLines(lines: readonly string[], offset: number): string { - return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); + return lines.map((line, i) => `${offset + i}: ${line}`).join("\n"); } /** A directory entry with its type. */ export interface DirEntry { - readonly name: string; - readonly isDirectory: boolean; + readonly name: string; + readonly isDirectory: boolean; } /** @@ -75,11 +75,11 @@ export interface DirEntry { * Subdirectories get a trailing `/`. Empty input returns an empty-directory message. */ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: string): string { - if (entries.length === 0) { - return `(empty directory: ${dirPath})`; - } - const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); + if (entries.length === 0) { + return `(empty directory: ${dirPath})`; + } + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + return sorted.map((e) => (e.isDirectory ? `${e.name}/` : e.name)).join("\n"); } /** @@ -94,120 +94,120 @@ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: st * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createReadFileTool(deps: { - readonly resolveBackend: ExecBackendResolver; - readonly workdir?: string; + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; }): ToolContract { - const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; - - return { - name: "read_file", - description: - "Read the contents of a file or list a directory's contents. " + - "For files, returns lines with 1-indexed line numbers. " + - "Supports offset/limit for reading specific sections of large files. " + - "For directories, returns sorted entries with subdirectories suffixed by /.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - offset: { - type: "number", - description: "1-indexed start line number (default: 1).", - default: 1, - }, - limit: { - type: "number", - description: "Maximum number of lines to return (default: 500, hard cap: 5000).", - default: 500, - }, - }, - required: ["path"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { path: relPath, offset, limit } = validated; - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; - if (effectiveBase === undefined) { - return { - content: - "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", - isError: true, - }; - } - const resolvedPath = resolve(effectiveBase, relPath); - - const backend: ExecBackend = deps.resolveBackend(ctx.computerId); - - // Stat to determine if this is a file or directory. - let pathStat: StatResult; - try { - pathStat = await backend.stat(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Directory listing branch. backend.readdir already returns - // {name, isDirectory}[] entries, so no per-entry collapse is needed. - if (pathStat.isDirectory) { - let entries: readonly DirEntry[]; - try { - entries = await backend.readdir(resolvedPath); - } catch (err: unknown) { - return { - content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - return { content: formatDirectoryEntries(entries, relPath) }; - } - - // File branch — read the file. - let content: string; - try { - content = await backend.readFile(resolvedPath); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { content: `Error: File "${relPath}" not found.`, isError: true }; - } - return { - content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - // Handle empty file. - if (content.length === 0) { - return { content: `(empty file: ${relPath})` }; - } - - // Apply offset/limit line slicing. - const { lines, totalLines } = sliceLines(content, offset, limit); - - if (offset > totalLines) { - return { - content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, - isError: true, - }; - } - - return { content: renderLines(lines, offset) }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "read_file", + description: + "Read the contents of a file or list a directory's contents. " + + "For files, returns lines with 1-indexed line numbers. " + + "Supports offset/limit for reading specific sections of large files. " + + "For directories, returns sorted entries with subdirectories suffixed by /.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + offset: { + type: "number", + description: "1-indexed start line number (default: 1).", + default: 1, + }, + limit: { + type: "number", + description: "Maximum number of lines to return (default: 500, hard cap: 5000).", + default: 500, + }, + }, + required: ["path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, offset, limit } = validated; + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } + const resolvedPath = resolve(effectiveBase, relPath); + + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Stat to determine if this is a file or directory. + let pathStat: StatResult; + try { + pathStat = await backend.stat(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading path: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Directory listing branch. backend.readdir already returns + // {name, isDirectory}[] entries, so no per-entry collapse is needed. + if (pathStat.isDirectory) { + let entries: readonly DirEntry[]; + try { + entries = await backend.readdir(resolvedPath); + } catch (err: unknown) { + return { + content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + return { content: formatDirectoryEntries(entries, relPath) }; + } + + // File branch — read the file. + let content: string; + try { + content = await backend.readFile(resolvedPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { content: `Error: File "${relPath}" not found.`, isError: true }; + } + return { + content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + // Handle empty file. + if (content.length === 0) { + return { content: `(empty file: ${relPath})` }; + } + + // Apply offset/limit line slicing. + const { lines, totalLines } = sliceLines(content, offset, limit); + + if (offset > totalLines) { + return { + content: `Error: offset ${offset} exceeds total lines (${totalLines}) in "${relPath}".`, + isError: true, + }; + } + + return { content: renderLines(lines, offset) }; + }, + }; } diff --git a/packages/tool-read-file/tsconfig.json b/packages/tool-read-file/tsconfig.json index 30cdc4d..b790281 100644 --- a/packages/tool-read-file/tsconfig.json +++ b/packages/tool-read-file/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/tool-shell/package.json b/packages/tool-shell/package.json index 606525f..f0a188c 100644 --- a/packages/tool-shell/package.json +++ b/packages/tool-shell/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/tool-shell", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/exec-backend": "workspace:*" - } + "name": "@dispatch/tool-shell", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" + } } diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts index 984263e..74e0ac9 100644 --- a/packages/tool-shell/src/extension.ts +++ b/packages/tool-shell/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createRunShellTool } from "./shell.js"; export const extension: Extension = { - manifest: { - id: "tool-shell", - name: "Shell Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { shell: true }, - contributes: { tools: ["run_shell"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend })); - }, + manifest: { + id: "tool-shell", + name: "Shell Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { shell: true }, + contributes: { tools: ["run_shell"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend })); + }, }; diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts index 4a579b0..42127e6 100644 --- a/packages/tool-shell/src/shell.test.ts +++ b/packages/tool-shell/src/shell.test.ts @@ -4,436 +4,436 @@ import { describe, expect, it } from "vitest"; import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** A fake backend whose `spawn` resolves a fixed result (no real I/O). */ function fakeBackend(result: { - exitCode: number | null; - timedOut: boolean; - aborted?: boolean; + exitCode: number | null; + timedOut: boolean; + aborted?: boolean; }): ExecBackend { - return { - ...localExecBackend, - spawn: async () => ({ aborted: false, ...result }), - }; + return { + ...localExecBackend, + spawn: async () => ({ aborted: false, ...result }), + }; } /** Wrap a fake backend in the resolver the factory expects (computerId-agnostic). */ function resolverFor(backend: ExecBackend) { - return () => backend; + return () => backend; } describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ command: "echo hello" }); - expect(result).toEqual({ command: "echo hello", timeout: 120_000 }); - }); - - it("parses custom timeout", () => { - const result = validateArgs({ command: "echo hello", timeout: 5000 }); - expect(result).toEqual({ command: "echo hello", timeout: 5000 }); - }); - - it("floors fractional timeout", () => { - const result = validateArgs({ command: "echo hello", timeout: 5000.7 }); - expect(result).toEqual({ command: "echo hello", timeout: 5000 }); - }); - - it("returns error for null args", () => { - const result = validateArgs(null); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-object args", () => { - const result = validateArgs("string"); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing command", () => { - const result = validateArgs({}); - expect(result).toHaveProperty("error"); - }); - - it("rejects missing or empty command", () => { - const empty = validateArgs({ command: "" }); - expect(empty).toHaveProperty("error"); - const whitespace = validateArgs({ command: " " }); - expect(whitespace).toHaveProperty("error"); - const missing = validateArgs({ timeout: 5000 }); - expect(missing).toHaveProperty("error"); - }); - - it("returns error for non-string command", () => { - const result = validateArgs({ command: 123 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid timeout", () => { - const negative = validateArgs({ command: "echo", timeout: -1 }); - expect(negative).toHaveProperty("error"); - const zero = validateArgs({ command: "echo", timeout: 0 }); - expect(zero).toHaveProperty("error"); - const nan = validateArgs({ command: "echo", timeout: Number.NaN }); - expect(nan).toHaveProperty("error"); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ command: "echo hello" }); + expect(result).toEqual({ command: "echo hello", timeout: 120_000 }); + }); + + it("parses custom timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("floors fractional timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000.7 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-object args", () => { + const result = validateArgs("string"); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing command", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("rejects missing or empty command", () => { + const empty = validateArgs({ command: "" }); + expect(empty).toHaveProperty("error"); + const whitespace = validateArgs({ command: " " }); + expect(whitespace).toHaveProperty("error"); + const missing = validateArgs({ timeout: 5000 }); + expect(missing).toHaveProperty("error"); + }); + + it("returns error for non-string command", () => { + const result = validateArgs({ command: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid timeout", () => { + const negative = validateArgs({ command: "echo", timeout: -1 }); + expect(negative).toHaveProperty("error"); + const zero = validateArgs({ command: "echo", timeout: 0 }); + expect(zero).toHaveProperty("error"); + const nan = validateArgs({ command: "echo", timeout: Number.NaN }); + expect(nan).toHaveProperty("error"); + }); }); describe("truncateOutput", () => { - it("returns output unchanged when under cap", () => { - const output = "short output"; - expect(truncateOutput(output, 100)).toBe("short output"); - }); - - it("returns output unchanged when exactly at cap", () => { - const output = "exact"; - expect(truncateOutput(output, 5)).toBe("exact"); - }); - - it("truncates output beyond the cap and appends a notice", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50); - expect(result).toContain("a".repeat(50)); - expect(result).toContain("[Output truncated: exceeded 50 characters]"); - expect(result.length).toBeLessThan(output.length + 100); - }); + it("returns output unchanged when under cap", () => { + const output = "short output"; + expect(truncateOutput(output, 100)).toBe("short output"); + }); + + it("returns output unchanged when exactly at cap", () => { + const output = "exact"; + expect(truncateOutput(output, 5)).toBe("exact"); + }); + + it("truncates output beyond the cap and appends a notice", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); }); describe("buildResult", () => { - it("maps a zero exit code to a success result", () => { - const result = buildResult({ - exitCode: 0, - timedOut: false, - aborted: false, - output: "all good", - cap: 50_000, - }); - expect(result.content).toBe("all good"); - expect(result.isError).toBeUndefined(); - }); - - it("maps a non-zero exit code to an isError result", () => { - const result = buildResult({ - exitCode: 1, - timedOut: false, - aborted: false, - output: "some error", - cap: 50_000, - }); - expect(result.content).toBe("some error"); - expect(result.isError).toBe(true); - }); - - it("reports a timeout as an isError result", () => { - const result = buildResult({ - exitCode: null, - timedOut: true, - aborted: false, - output: "partial", - cap: 50_000, - }); - expect(result.content).toContain("partial"); - expect(result.content).toContain("[Command timed out]"); - expect(result.isError).toBe(true); - }); - - it("reports abort as an isError result", () => { - const result = buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output: "interrupted", - cap: 50_000, - }); - expect(result.content).toBe("interrupted"); - expect(result.isError).toBe(true); - }); - - it("truncates output in result when over cap", () => { - const output = "x".repeat(60_000); - const result = buildResult({ - exitCode: 0, - timedOut: false, - aborted: false, - output, - cap: 50_000, - }); - expect(result.content).toContain("[Output truncated"); - expect(result.content.length).toBeLessThan(60_000); - }); + it("maps a zero exit code to a success result", () => { + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output: "all good", + cap: 50_000, + }); + expect(result.content).toBe("all good"); + expect(result.isError).toBeUndefined(); + }); + + it("maps a non-zero exit code to an isError result", () => { + const result = buildResult({ + exitCode: 1, + timedOut: false, + aborted: false, + output: "some error", + cap: 50_000, + }); + expect(result.content).toBe("some error"); + expect(result.isError).toBe(true); + }); + + it("reports a timeout as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: true, + aborted: false, + output: "partial", + cap: 50_000, + }); + expect(result.content).toContain("partial"); + expect(result.content).toContain("[Command timed out]"); + expect(result.isError).toBe(true); + }); + + it("reports abort as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "interrupted", + cap: 50_000, + }); + expect(result.content).toBe("interrupted"); + expect(result.isError).toBe(true); + }); + + it("truncates output in result when over cap", () => { + const output = "x".repeat(60_000); + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output, + cap: 50_000, + }); + expect(result.content).toContain("[Output truncated"); + expect(result.content.length).toBeLessThan(60_000); + }); }); describe("createRunShellTool", () => { - it("has correct name and parameters shape", () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - expect(tool.name).toBe("run_shell"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["command"]); - expect(tool.parameters.properties?.command?.type).toBe("string"); - expect(tool.parameters.properties?.timeout?.type).toBe("number"); - }); - - it("concurrencySafe is false", () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - expect(tool.concurrencySafe).toBe(false); - }); - - it("rejects missing or empty command", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - const result = await tool.execute({}, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("Missing or empty"); - }); - - it("maps a zero exit code to a success result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - params.onOutput("hello\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - const result = await tool.execute({ command: "echo hello" }, stubCtx()); - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("hello"); - }); - - it("maps a non-zero exit code to an isError result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - params.onOutput("error output\n", "stderr"); - return { exitCode: 1, timedOut: false, aborted: false }; - }, - }), - }); - const result = await tool.execute({ command: "false" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("error output"); - }); - - it("reports a timeout as an isError result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - params.onOutput("partial\n", "stdout"); - return { exitCode: null, timedOut: true, aborted: false }; - }, - }), - }); - const result = await tool.execute({ command: "sleep 999" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("[Command timed out]"); - }); - - it("truncates output beyond the cap and appends a notice", async () => { - const cap = 100; - const tool = createRunShellTool({ - workdir: "/tmp", - outputCap: cap, - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - params.onOutput("a".repeat(200), "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - const result = await tool.execute({ command: "gen" }, stubCtx()); - expect(result.content).toContain("[Output truncated"); - expect(result.content.length).toBeLessThan(200); - }); - - it("streams output to ctx.onOutput", async () => { - const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - params.onOutput("line1\n", "stdout"); - params.onOutput("err1\n", "stderr"); - params.onOutput("line2\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - await tool.execute( - { command: "test" }, - stubCtx({ - onOutput: (data, stream) => chunks.push({ data, stream }), - }), - ); - expect(chunks).toEqual([ - { data: "line1\n", stream: "stdout" }, - { data: "err1\n", stream: "stderr" }, - { data: "line2\n", stream: "stdout" }, - ]); - }); - - it("uses ctx.cwd when present over baked workdir", async () => { - let receivedCwd = ""; - const tool = createRunShellTool({ - workdir: "/baked", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" })); - expect(receivedCwd).toBe("/custom"); - }); - - it("falls back to baked workdir when ctx.cwd is omitted", async () => { - let receivedCwd = ""; - const tool = createRunShellTool({ - workdir: "/baked", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - await tool.execute({ command: "pwd" }, stubCtx()); - expect(receivedCwd).toBe("/baked"); - }); - - it("returns error for spawn failure", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async () => { - throw new Error("spawn failed"); - }, - }), - }); - const result = await tool.execute({ command: "bad" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("Error spawning command"); - }); - - it("reports abort as isError when signal fires before spawn completes", async () => { - const controller = new AbortController(); - controller.abort(); - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); - expect(result.isError).toBe(true); - }); - - it("passes timeout to spawn", async () => { - let receivedTimeout = 0; - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async (params) => { - receivedTimeout = params.timeout; - return { exitCode: 0, timedOut: false, aborted: false }; - }, - }), - }); - await tool.execute({ command: "test", timeout: 5000 }, stubCtx()); - expect(receivedTimeout).toBe(5000); - }); - - it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => { - let receivedComputerId: string | undefined = "__unset__"; - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: (computerId) => { - receivedComputerId = computerId; - return fakeBackend({ exitCode: 0, timedOut: false }); - }, - }); - await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" })); - expect(receivedComputerId).toBe("my-host"); - }); - - it("resolves the local backend when ctx.computerId is undefined", async () => { - let receivedComputerId: string | undefined = "__unset__"; - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: (computerId) => { - receivedComputerId = computerId; - return fakeBackend({ exitCode: 0, timedOut: false }); - }, - }); - await tool.execute({ command: "test" }, stubCtx()); - expect(receivedComputerId).toBeUndefined(); - }); + it("has correct name and parameters shape", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + expect(tool.name).toBe("run_shell"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["command"]); + expect(tool.parameters.properties?.command?.type).toBe("string"); + expect(tool.parameters.properties?.timeout?.type).toBe("number"); + }); + + it("concurrencySafe is false", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + expect(tool.concurrencySafe).toBe(false); + }); + + it("rejects missing or empty command", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + const result = await tool.execute({}, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Missing or empty"); + }); + + it("maps a zero exit code to a success result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("hello\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + const result = await tool.execute({ command: "echo hello" }, stubCtx()); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("hello"); + }); + + it("maps a non-zero exit code to an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("error output\n", "stderr"); + return { exitCode: 1, timedOut: false, aborted: false }; + }, + }), + }); + const result = await tool.execute({ command: "false" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("error output"); + }); + + it("reports a timeout as an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("partial\n", "stdout"); + return { exitCode: null, timedOut: true, aborted: false }; + }, + }), + }); + const result = await tool.execute({ command: "sleep 999" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("[Command timed out]"); + }); + + it("truncates output beyond the cap and appends a notice", async () => { + const cap = 100; + const tool = createRunShellTool({ + workdir: "/tmp", + outputCap: cap, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("a".repeat(200), "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + const result = await tool.execute({ command: "gen" }, stubCtx()); + expect(result.content).toContain("[Output truncated"); + expect(result.content.length).toBeLessThan(200); + }); + + it("streams output to ctx.onOutput", async () => { + const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("line1\n", "stdout"); + params.onOutput("err1\n", "stderr"); + params.onOutput("line2\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + await tool.execute( + { command: "test" }, + stubCtx({ + onOutput: (data, stream) => chunks.push({ data, stream }), + }), + ); + expect(chunks).toEqual([ + { data: "line1\n", stream: "stdout" }, + { data: "err1\n", stream: "stderr" }, + { data: "line2\n", stream: "stdout" }, + ]); + }); + + it("uses ctx.cwd when present over baked workdir", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" })); + expect(receivedCwd).toBe("/custom"); + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + await tool.execute({ command: "pwd" }, stubCtx()); + expect(receivedCwd).toBe("/baked"); + }); + + it("returns error for spawn failure", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async () => { + throw new Error("spawn failed"); + }, + }), + }); + const result = await tool.execute({ command: "bad" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Error spawning command"); + }); + + it("reports abort as isError when signal fires before spawn completes", async () => { + const controller = new AbortController(); + controller.abort(); + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); + expect(result.isError).toBe(true); + }); + + it("passes timeout to spawn", async () => { + let receivedTimeout = 0; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedTimeout = params.timeout; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), + }); + await tool.execute({ command: "test", timeout: 5000 }, stubCtx()); + expect(receivedTimeout).toBe(5000); + }); + + it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" })); + expect(receivedComputerId).toBe("my-host"); + }); + + it("resolves the local backend when ctx.computerId is undefined", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx()); + expect(receivedComputerId).toBeUndefined(); + }); }); describe("createRunShellTool (integration)", () => { - it("runs a real echo command through the local backend and captures stdout + cwd", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: () => localExecBackend, - }); - let streamed = ""; - const result = await tool.execute( - { command: "echo hello-from-shell" }, - stubCtx({ - onOutput: (data) => { - streamed += data; - }, - }), - ); - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("hello-from-shell"); - expect(streamed).toContain("hello-from-shell"); - }); - - it("aborts a real long-running command through the local backend and resolves with aborted", async () => { - const controller = new AbortController(); - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: () => localExecBackend, - }); - const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal })); - // Let the sleep actually start. - await new Promise((r) => setTimeout(r, 300)); - controller.abort(); - const result = await promise; - expect(result.isError).toBe(true); - // The detailed process-group-kill semantics (grandchild holding the pipes, - // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts — - // realSpawn was ported there byte-for-byte. Here we only confirm the tool - // wires the backend's aborted result through to an isError result. - await new Promise((r) => setTimeout(r, 200)); - }); + it("runs a real echo command through the local backend and captures stdout + cwd", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, + }); + let streamed = ""; + const result = await tool.execute( + { command: "echo hello-from-shell" }, + stubCtx({ + onOutput: (data) => { + streamed += data; + }, + }), + ); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("hello-from-shell"); + expect(streamed).toContain("hello-from-shell"); + }); + + it("aborts a real long-running command through the local backend and resolves with aborted", async () => { + const controller = new AbortController(); + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, + }); + const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal })); + // Let the sleep actually start. + await new Promise((r) => setTimeout(r, 300)); + controller.abort(); + const result = await promise; + expect(result.isError).toBe(true); + // The detailed process-group-kill semantics (grandchild holding the pipes, + // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts — + // realSpawn was ported there byte-for-byte. Here we only confirm the tool + // wires the backend's aborted result through to an isError result. + await new Promise((r) => setTimeout(r, 200)); + }); }); diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts index dac7fab..e9b676a 100644 --- a/packages/tool-shell/src/shell.ts +++ b/packages/tool-shell/src/shell.ts @@ -6,170 +6,170 @@ const DEFAULT_TIMEOUT = 120_000; const OUTPUT_CAP = 50_000; export interface ValidatedArgs { - readonly command: string; - readonly timeout: number; + readonly command: string; + readonly timeout: number; } export interface SpawnResult { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; } export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; - - const rawCommand = obj.command; - if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) { - return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' }; - } - - let timeout = DEFAULT_TIMEOUT; - if (obj.timeout !== undefined) { - const n = Number(obj.timeout); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' }; - } - timeout = Math.floor(n); - } - - return { command: rawCommand, timeout }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; + + const rawCommand = obj.command; + if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) { + return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' }; + } + + let timeout = DEFAULT_TIMEOUT; + if (obj.timeout !== undefined) { + const n = Number(obj.timeout); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' }; + } + timeout = Math.floor(n); + } + + return { command: rawCommand, timeout }; } export function truncateOutput(output: string, cap: number): string { - if (output.length <= cap) { - return output; - } - const truncated = output.slice(0, cap); - return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; } export function buildResult(params: { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; - readonly output: string; - readonly cap: number; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; + readonly output: string; + readonly cap: number; }): ToolResult { - if (params.aborted) { - return { - content: truncateOutput(params.output, params.cap), - isError: true, - }; - } - if (params.timedOut) { - const content = truncateOutput(params.output, params.cap); - return { - content: `${content}\n\n[Command timed out]`, - isError: true, - }; - } - const exitCode = params.exitCode; - if (exitCode !== null && exitCode !== 0) { - return { - content: truncateOutput(params.output, params.cap), - isError: true, - }; - } - return { - content: truncateOutput(params.output, params.cap), - }; + if (params.aborted) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + if (params.timedOut) { + const content = truncateOutput(params.output, params.cap); + return { + content: `${content}\n\n[Command timed out]`, + isError: true, + }; + } + const exitCode = params.exitCode; + if (exitCode !== null && exitCode !== 0) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + return { + content: truncateOutput(params.output, params.cap), + }; } export function createRunShellTool(deps: { - readonly workdir: string; - readonly resolveBackend: ExecBackendResolver; - readonly outputCap?: number; + readonly workdir: string; + readonly resolveBackend: ExecBackendResolver; + readonly outputCap?: number; }): ToolContract { - const workdir = resolve(deps.workdir); - const cap = deps.outputCap ?? OUTPUT_CAP; - - return { - name: "run_shell", - description: - "Execute a shell command and return its output. " + - "Use for running CLI tools, scripts, or system commands.", - parameters: { - type: "object", - properties: { - command: { - type: "string", - description: "The shell command to execute.", - }, - timeout: { - type: "number", - description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`, - default: DEFAULT_TIMEOUT, - }, - }, - required: ["command"], - }, - concurrencySafe: false, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { command, timeout } = validated; - const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir; - - if (ctx.signal.aborted) { - return buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output: "", - cap, - }); - } - - let output = ""; - const appendOutput = (data: string, _stream: "stdout" | "stderr") => { - output += data; - }; - - const backend = deps.resolveBackend(ctx.computerId); - - let spawnResult: SpawnResult; - - try { - spawnResult = await backend.spawn({ - command, - cwd: effectiveCwd, - signal: ctx.signal, - timeout, - onOutput: (data, stream) => { - ctx.onOutput(data, stream); - appendOutput(data, stream); - }, - }); - } catch (err: unknown) { - if (ctx.signal.aborted) { - return buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output, - cap, - }); - } - return { - content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - return buildResult({ - exitCode: spawnResult.exitCode, - timedOut: spawnResult.timedOut, - aborted: spawnResult.aborted, - output, - cap, - }); - }, - }; + const workdir = resolve(deps.workdir); + const cap = deps.outputCap ?? OUTPUT_CAP; + + return { + name: "run_shell", + description: + "Execute a shell command and return its output. " + + "Use for running CLI tools, scripts, or system commands.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to execute.", + }, + timeout: { + type: "number", + description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`, + default: DEFAULT_TIMEOUT, + }, + }, + required: ["command"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { command, timeout } = validated; + const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir; + + if (ctx.signal.aborted) { + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "", + cap, + }); + } + + let output = ""; + const appendOutput = (data: string, _stream: "stdout" | "stderr") => { + output += data; + }; + + const backend = deps.resolveBackend(ctx.computerId); + + let spawnResult: SpawnResult; + + try { + spawnResult = await backend.spawn({ + command, + cwd: effectiveCwd, + signal: ctx.signal, + timeout, + onOutput: (data, stream) => { + ctx.onOutput(data, stream); + appendOutput(data, stream); + }, + }); + } catch (err: unknown) { + if (ctx.signal.aborted) { + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output, + cap, + }); + } + return { + content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + return buildResult({ + exitCode: spawnResult.exitCode, + timedOut: spawnResult.timedOut, + aborted: spawnResult.aborted, + output, + cap, + }); + }, + }; } diff --git a/packages/tool-shell/tsconfig.json b/packages/tool-shell/tsconfig.json index 30cdc4d..b790281 100644 --- a/packages/tool-shell/tsconfig.json +++ b/packages/tool-shell/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/tool-web-search/package.json b/packages/tool-web-search/package.json index c41ab7b..3d3503f 100644 --- a/packages/tool-web-search/package.json +++ b/packages/tool-web-search/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/tool-web-search", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/tool-web-search", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/tool-web-search/src/client.test.ts b/packages/tool-web-search/src/client.test.ts index f020a83..ee6e3f9 100644 --- a/packages/tool-web-search/src/client.test.ts +++ b/packages/tool-web-search/src/client.test.ts @@ -2,207 +2,207 @@ import { describe, expect, it } from "vitest"; import { createFirecrawlClient, type FetchLike } from "./client.js"; function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } interface CapturedCall { - url: string; - method?: string | undefined; - body?: string | undefined; + url: string; + method?: string | undefined; + body?: string | undefined; } /** Builds a fake fetch that returns scripted responses in order, capturing each call. */ function makeFetch(responses: Response[]): { fetchFn: FetchLike; calls: CapturedCall[] } { - const calls: CapturedCall[] = []; - let i = 0; - const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calls.push({ - url, - method: init?.method, - body: typeof init?.body === "string" ? init.body : undefined, - }); - return responses[i++] ?? jsonResponse({}); - }) as unknown as FetchLike; - return { fetchFn, calls }; + const calls: CapturedCall[] = []; + let i = 0; + const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push({ + url, + method: init?.method, + body: typeof init?.body === "string" ? init.body : undefined, + }); + return responses[i++] ?? jsonResponse({}); + }) as unknown as FetchLike; + return { fetchFn, calls }; } const BASE = "http://test-firecrawl.local/v1"; const signal = (): AbortSignal => new AbortController().signal; describe("createFirecrawlClient.search", () => { - it("sends POST /search with correct body", async () => { - const { fetchFn, calls } = makeFetch([jsonResponse({ success: true, data: [] })]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - await client.search({ query: "hello", limit: 7 }, signal()); - - const call = calls[0]; - if (!call) throw new Error("no call captured"); - expect(call.url).toBe(`${BASE}/search`); - expect(call.method).toBe("POST"); - expect(JSON.parse(call.body ?? "{}")).toEqual({ query: "hello", limit: 7 }); - }); - - it("returns parsed data on success", async () => { - const data = [{ title: "T", url: "http://x", description: "d" }]; - const { fetchFn } = makeFetch([jsonResponse({ success: true, data })]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - const result = await client.search({ query: "hello", limit: 7 }, signal()); - expect(result).toEqual(data); - }); - - it("throws on !success", async () => { - const { fetchFn } = makeFetch([jsonResponse({ success: false, error: "boom" })]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("boom"); - }); + it("sends POST /search with correct body", async () => { + const { fetchFn, calls } = makeFetch([jsonResponse({ success: true, data: [] })]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + await client.search({ query: "hello", limit: 7 }, signal()); + + const call = calls[0]; + if (!call) throw new Error("no call captured"); + expect(call.url).toBe(`${BASE}/search`); + expect(call.method).toBe("POST"); + expect(JSON.parse(call.body ?? "{}")).toEqual({ query: "hello", limit: 7 }); + }); + + it("returns parsed data on success", async () => { + const data = [{ title: "T", url: "http://x", description: "d" }]; + const { fetchFn } = makeFetch([jsonResponse({ success: true, data })]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + const result = await client.search({ query: "hello", limit: 7 }, signal()); + expect(result).toEqual(data); + }); + + it("throws on !success", async () => { + const { fetchFn } = makeFetch([jsonResponse({ success: false, error: "boom" })]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("boom"); + }); }); describe("createFirecrawlClient.scrape", () => { - it("sends POST /scrape with correct body", async () => { - const { fetchFn, calls } = makeFetch([ - jsonResponse({ success: true, data: { markdown: "md", metadata: { title: "T" } } }), - ]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - await client.scrape({ url: "http://x", formats: ["markdown"] }, signal()); - - const call = calls[0]; - if (!call) throw new Error("no call captured"); - expect(call.url).toBe(`${BASE}/scrape`); - expect(call.method).toBe("POST"); - expect(JSON.parse(call.body ?? "{}")).toEqual({ - url: "http://x", - formats: ["markdown"], - onlyMainContent: true, - }); - }); + it("sends POST /scrape with correct body", async () => { + const { fetchFn, calls } = makeFetch([ + jsonResponse({ success: true, data: { markdown: "md", metadata: { title: "T" } } }), + ]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + await client.scrape({ url: "http://x", formats: ["markdown"] }, signal()); + + const call = calls[0]; + if (!call) throw new Error("no call captured"); + expect(call.url).toBe(`${BASE}/scrape`); + expect(call.method).toBe("POST"); + expect(JSON.parse(call.body ?? "{}")).toEqual({ + url: "http://x", + formats: ["markdown"], + onlyMainContent: true, + }); + }); }); describe("createFirecrawlClient.crawl", () => { - it("polls status URL until completed", async () => { - const { fetchFn, calls } = makeFetch([ - jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), - jsonResponse({ status: "scraping" }), - jsonResponse({ - status: "completed", - data: [{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }], - }), - ]); - const client = createFirecrawlClient({ - baseUrl: BASE, - fetchFn, - sleep: async () => {}, - }); - const pages = await client.crawl( - { url: "http://site", limit: 3, formats: ["markdown"] }, - signal(), - ); - expect(pages).toEqual([{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }]); - expect(calls.length).toBe(3); - }); - - it("returns data when completed", async () => { - const { fetchFn } = makeFetch([ - jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), - jsonResponse({ - status: "completed", - data: [{ markdown: "page", metadata: { title: "T" } }], - }), - ]); - const client = createFirecrawlClient({ - baseUrl: BASE, - fetchFn, - sleep: async () => {}, - }); - const pages = await client.crawl( - { url: "http://site", limit: 3, formats: ["markdown"] }, - signal(), - ); - expect(pages.length).toBe(1); - expect(pages[0]?.markdown).toBe("page"); - }); - - it("throws when status is failed", async () => { - const { fetchFn } = makeFetch([ - jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), - jsonResponse({ status: "failed", error: "boom" }), - ]); - const client = createFirecrawlClient({ - baseUrl: BASE, - fetchFn, - sleep: async () => {}, - }); - await expect( - client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, signal()), - ).rejects.toThrow("failed"); - }); - - it("respects abort signal (stops polling)", async () => { - const controller = new AbortController(); - const { fetchFn, calls } = makeFetch([ - jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), - ]); - const client = createFirecrawlClient({ - baseUrl: BASE, - fetchFn, - sleep: async (_ms, sig) => { - controller.abort(); - if (sig.aborted) throw new Error("Request aborted."); - }, - }); - await expect( - client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, controller.signal), - ).rejects.toThrow(); - expect(calls.length).toBe(1); - }); + it("polls status URL until completed", async () => { + const { fetchFn, calls } = makeFetch([ + jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), + jsonResponse({ status: "scraping" }), + jsonResponse({ + status: "completed", + data: [{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }], + }), + ]); + const client = createFirecrawlClient({ + baseUrl: BASE, + fetchFn, + sleep: async () => {}, + }); + const pages = await client.crawl( + { url: "http://site", limit: 3, formats: ["markdown"] }, + signal(), + ); + expect(pages).toEqual([{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }]); + expect(calls.length).toBe(3); + }); + + it("returns data when completed", async () => { + const { fetchFn } = makeFetch([ + jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), + jsonResponse({ + status: "completed", + data: [{ markdown: "page", metadata: { title: "T" } }], + }), + ]); + const client = createFirecrawlClient({ + baseUrl: BASE, + fetchFn, + sleep: async () => {}, + }); + const pages = await client.crawl( + { url: "http://site", limit: 3, formats: ["markdown"] }, + signal(), + ); + expect(pages.length).toBe(1); + expect(pages[0]?.markdown).toBe("page"); + }); + + it("throws when status is failed", async () => { + const { fetchFn } = makeFetch([ + jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), + jsonResponse({ status: "failed", error: "boom" }), + ]); + const client = createFirecrawlClient({ + baseUrl: BASE, + fetchFn, + sleep: async () => {}, + }); + await expect( + client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, signal()), + ).rejects.toThrow("failed"); + }); + + it("respects abort signal (stops polling)", async () => { + const controller = new AbortController(); + const { fetchFn, calls } = makeFetch([ + jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), + ]); + const client = createFirecrawlClient({ + baseUrl: BASE, + fetchFn, + sleep: async (_ms, sig) => { + controller.abort(); + if (sig.aborted) throw new Error("Request aborted."); + }, + }); + await expect( + client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, controller.signal), + ).rejects.toThrow(); + expect(calls.length).toBe(1); + }); }); describe("createFirecrawlClient.map", () => { - it("sends POST /map and returns links", async () => { - const { fetchFn, calls } = makeFetch([ - jsonResponse({ success: true, links: ["http://a", "http://b"] }), - ]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - const links = await client.map("http://site", signal()); - expect(links).toEqual(["http://a", "http://b"]); - - const call = calls[0]; - if (!call) throw new Error("no call captured"); - expect(call.url).toBe(`${BASE}/map`); - expect(call.method).toBe("POST"); - expect(JSON.parse(call.body ?? "{}")).toEqual({ url: "http://site" }); - }); + it("sends POST /map and returns links", async () => { + const { fetchFn, calls } = makeFetch([ + jsonResponse({ success: true, links: ["http://a", "http://b"] }), + ]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + const links = await client.map("http://site", signal()); + expect(links).toEqual(["http://a", "http://b"]); + + const call = calls[0]; + if (!call) throw new Error("no call captured"); + expect(call.url).toBe(`${BASE}/map`); + expect(call.method).toBe("POST"); + expect(JSON.parse(call.body ?? "{}")).toEqual({ url: "http://site" }); + }); }); describe("createFirecrawlClient.request (error paths)", () => { - it("throws on HTTP error", async () => { - const { fetchFn } = makeFetch([ - new Response("not found", { status: 404, statusText: "Not Found" }), - ]); - const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); - await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("HTTP 404"); - }); - - it("throws on timeout", async () => { - const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => - new Promise((_resolve, reject) => { - const sig = init?.signal; - if (!sig) return; - sig.addEventListener("abort", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - reject(err); - }); - })) as unknown as FetchLike; - const client = createFirecrawlClient({ - baseUrl: BASE, - fetchFn, - timeoutMs: 10, - }); - await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("timed out"); - }); + it("throws on HTTP error", async () => { + const { fetchFn } = makeFetch([ + new Response("not found", { status: 404, statusText: "Not Found" }), + ]); + const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); + await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("HTTP 404"); + }); + + it("throws on timeout", async () => { + const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + const sig = init?.signal; + if (!sig) return; + sig.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + })) as unknown as FetchLike; + const client = createFirecrawlClient({ + baseUrl: BASE, + fetchFn, + timeoutMs: 10, + }); + await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("timed out"); + }); }); diff --git a/packages/tool-web-search/src/client.ts b/packages/tool-web-search/src/client.ts index 071ba97..cd137f5 100644 --- a/packages/tool-web-search/src/client.ts +++ b/packages/tool-web-search/src/client.ts @@ -17,97 +17,97 @@ export const CRAWL_POLL_MS = 2_000; export const CRAWL_MAX_WAIT_MS = 5 * 60 * 1_000; export interface SearchParams { - readonly query: string; - readonly limit: number; - readonly lang?: string; - readonly country?: string; - readonly scrapeOptions?: { - readonly formats: readonly string[]; - readonly onlyMainContent: boolean; - }; + readonly query: string; + readonly limit: number; + readonly lang?: string; + readonly country?: string; + readonly scrapeOptions?: { + readonly formats: readonly string[]; + readonly onlyMainContent: boolean; + }; } export interface ScrapeParams { - readonly url: string; - readonly formats: readonly string[]; + readonly url: string; + readonly formats: readonly string[]; } export interface CrawlParams { - readonly url: string; - readonly limit: number; - readonly formats: readonly string[]; + readonly url: string; + readonly limit: number; + readonly formats: readonly string[]; } export interface FirecrawlClient { - readonly search: (params: SearchParams, signal: AbortSignal) => Promise; - readonly scrape: (params: ScrapeParams, signal: AbortSignal) => Promise; - readonly crawl: (params: CrawlParams, signal: AbortSignal) => Promise; - readonly map: (url: string, signal: AbortSignal) => Promise; + readonly search: (params: SearchParams, signal: AbortSignal) => Promise; + readonly scrape: (params: ScrapeParams, signal: AbortSignal) => Promise; + readonly crawl: (params: CrawlParams, signal: AbortSignal) => Promise; + readonly map: (url: string, signal: AbortSignal) => Promise; } export interface FirecrawlClientDeps { - readonly baseUrl: string; - readonly fetchFn: FetchLike; - readonly timeoutMs?: number; - readonly pollMs?: number; - readonly maxWaitMs?: number; - readonly now?: () => number; - readonly sleep?: (ms: number, signal: AbortSignal) => Promise; + readonly baseUrl: string; + readonly fetchFn: FetchLike; + readonly timeoutMs?: number; + readonly pollMs?: number; + readonly maxWaitMs?: number; + readonly now?: () => number; + readonly sleep?: (ms: number, signal: AbortSignal) => Promise; } interface SearchResponse { - readonly success: boolean; - readonly data?: readonly SearchHit[]; - readonly error?: string; + readonly success: boolean; + readonly data?: readonly SearchHit[]; + readonly error?: string; } interface ScrapeResponse { - readonly success: boolean; - readonly data?: { - readonly markdown?: string; - readonly metadata?: { readonly title?: string }; - }; - readonly error?: string; + readonly success: boolean; + readonly data?: { + readonly markdown?: string; + readonly metadata?: { readonly title?: string }; + }; + readonly error?: string; } interface CrawlStartResponse { - readonly success: boolean; - readonly url?: string; - readonly error?: string; + readonly success: boolean; + readonly url?: string; + readonly error?: string; } interface CrawlStatusResponse { - readonly status: string; - readonly data?: readonly CrawlPage[]; - readonly error?: string; + readonly status: string; + readonly data?: readonly CrawlPage[]; + readonly error?: string; } interface MapResponse { - readonly success: boolean; - readonly links?: readonly string[]; - readonly error?: string; + readonly success: boolean; + readonly links?: readonly string[]; + readonly error?: string; } /** Default sleep: resolve after `ms`, reject on abort. */ async function defaultSleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new Error("Request aborted.")); - return; - } - let timer: ReturnType | undefined; - const onAbort = (): void => { - if (timer !== undefined) { - clearTimeout(timer); - } - reject(new Error("Request aborted.")); - }; - timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal.addEventListener("abort", onAbort, { once: true }); - }); + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("Request aborted.")); + return; + } + let timer: ReturnType | undefined; + const onAbort = (): void => { + if (timer !== undefined) { + clearTimeout(timer); + } + reject(new Error("Request aborted.")); + }; + timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); } /** @@ -116,128 +116,128 @@ async function defaultSleep(ms: number, signal: AbortSignal): Promise { * is combined with the caller's cancellation signal via `AbortSignal.any`. */ export function createFirecrawlClient(deps: FirecrawlClientDeps): FirecrawlClient { - const baseUrl = deps.baseUrl; - const fetchFn = deps.fetchFn; - const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const pollMs = deps.pollMs ?? CRAWL_POLL_MS; - const maxWaitMs = deps.maxWaitMs ?? CRAWL_MAX_WAIT_MS; - const now = deps.now ?? Date.now; - const sleep = deps.sleep ?? defaultSleep; - - async function request( - method: "POST" | "GET", - url: string, - body: unknown, - signal: AbortSignal, - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const combined = AbortSignal.any([signal, controller.signal]); - try { - let response: Response; - try { - response = await fetchFn(url, { - method, - headers: - body !== undefined - ? { "Content-Type": "application/json", Accept: "application/json" } - : { Accept: "application/json" }, - body: body !== undefined ? JSON.stringify(body) : undefined, - signal: combined, - }); - } catch (err) { - if (signal.aborted) { - throw new Error("Request aborted."); - } - if (controller.signal.aborted) { - throw new Error(`Firecrawl request timed out after ${timeoutMs / 1000} seconds.`); - } - throw err; - } - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error(`HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`); - } - try { - return await response.json(); - } catch { - throw new Error("Failed to parse Firecrawl response as JSON"); - } - } finally { - clearTimeout(timeout); - } - } - - async function post(endpoint: string, body: unknown, signal: AbortSignal): Promise { - return request("POST", `${baseUrl}/${endpoint}`, body, signal); - } - - return { - async search(params: SearchParams, signal: AbortSignal): Promise { - const body: Record = { query: params.query, limit: params.limit }; - if (params.lang !== undefined) { - body.lang = params.lang; - } - if (params.country !== undefined) { - body.country = params.country; - } - if (params.scrapeOptions !== undefined) { - body.scrapeOptions = params.scrapeOptions; - } - const json = (await post("search", body, signal)) as SearchResponse; - if (!json.success) { - throw new Error(json.error ?? "Unknown error"); - } - return json.data ?? []; - }, - - async scrape(params: ScrapeParams, signal: AbortSignal): Promise { - const body = { - url: params.url, - formats: params.formats, - onlyMainContent: true, - }; - const json = (await post("scrape", body, signal)) as ScrapeResponse; - if (!json.success) { - throw new Error(json.error ?? "Unknown error"); - } - return json; - }, - - async crawl(params: CrawlParams, signal: AbortSignal): Promise { - const body = { - url: params.url, - limit: params.limit, - scrapeOptions: { formats: params.formats, onlyMainContent: true }, - }; - const startJson = (await post("crawl", body, signal)) as CrawlStartResponse; - if (!startJson.success) { - throw new Error(startJson.error ?? "Unknown error"); - } - const statusUrl = startJson.url; - if (statusUrl === undefined) { - throw new Error("crawl response missing status URL."); - } - const started = now(); - while (now() - started < maxWaitMs) { - await sleep(pollMs, signal); - const status = (await request("GET", statusUrl, undefined, signal)) as CrawlStatusResponse; - if (status.status === "completed") { - return status.data ?? []; - } - if (status.status === "failed") { - throw new Error(`crawl failed: ${status.error ?? "unknown"}`); - } - } - throw new Error("crawl timed out waiting for completion."); - }, - - async map(url: string, signal: AbortSignal): Promise { - const json = (await post("map", { url }, signal)) as MapResponse; - if (!json.success) { - throw new Error(json.error ?? "Unknown error"); - } - return json.links ?? []; - }, - }; + const baseUrl = deps.baseUrl; + const fetchFn = deps.fetchFn; + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const pollMs = deps.pollMs ?? CRAWL_POLL_MS; + const maxWaitMs = deps.maxWaitMs ?? CRAWL_MAX_WAIT_MS; + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? defaultSleep; + + async function request( + method: "POST" | "GET", + url: string, + body: unknown, + signal: AbortSignal, + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const combined = AbortSignal.any([signal, controller.signal]); + try { + let response: Response; + try { + response = await fetchFn(url, { + method, + headers: + body !== undefined + ? { "Content-Type": "application/json", Accept: "application/json" } + : { Accept: "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: combined, + }); + } catch (err) { + if (signal.aborted) { + throw new Error("Request aborted."); + } + if (controller.signal.aborted) { + throw new Error(`Firecrawl request timed out after ${timeoutMs / 1000} seconds.`); + } + throw err; + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`); + } + try { + return await response.json(); + } catch { + throw new Error("Failed to parse Firecrawl response as JSON"); + } + } finally { + clearTimeout(timeout); + } + } + + async function post(endpoint: string, body: unknown, signal: AbortSignal): Promise { + return request("POST", `${baseUrl}/${endpoint}`, body, signal); + } + + return { + async search(params: SearchParams, signal: AbortSignal): Promise { + const body: Record = { query: params.query, limit: params.limit }; + if (params.lang !== undefined) { + body.lang = params.lang; + } + if (params.country !== undefined) { + body.country = params.country; + } + if (params.scrapeOptions !== undefined) { + body.scrapeOptions = params.scrapeOptions; + } + const json = (await post("search", body, signal)) as SearchResponse; + if (!json.success) { + throw new Error(json.error ?? "Unknown error"); + } + return json.data ?? []; + }, + + async scrape(params: ScrapeParams, signal: AbortSignal): Promise { + const body = { + url: params.url, + formats: params.formats, + onlyMainContent: true, + }; + const json = (await post("scrape", body, signal)) as ScrapeResponse; + if (!json.success) { + throw new Error(json.error ?? "Unknown error"); + } + return json; + }, + + async crawl(params: CrawlParams, signal: AbortSignal): Promise { + const body = { + url: params.url, + limit: params.limit, + scrapeOptions: { formats: params.formats, onlyMainContent: true }, + }; + const startJson = (await post("crawl", body, signal)) as CrawlStartResponse; + if (!startJson.success) { + throw new Error(startJson.error ?? "Unknown error"); + } + const statusUrl = startJson.url; + if (statusUrl === undefined) { + throw new Error("crawl response missing status URL."); + } + const started = now(); + while (now() - started < maxWaitMs) { + await sleep(pollMs, signal); + const status = (await request("GET", statusUrl, undefined, signal)) as CrawlStatusResponse; + if (status.status === "completed") { + return status.data ?? []; + } + if (status.status === "failed") { + throw new Error(`crawl failed: ${status.error ?? "unknown"}`); + } + } + throw new Error("crawl timed out waiting for completion."); + }, + + async map(url: string, signal: AbortSignal): Promise { + const json = (await post("map", { url }, signal)) as MapResponse; + if (!json.success) { + throw new Error(json.error ?? "Unknown error"); + } + return json.links ?? []; + }, + }; } diff --git a/packages/tool-web-search/src/extension.test.ts b/packages/tool-web-search/src/extension.test.ts index 6e0a6bc..f6ee198 100644 --- a/packages/tool-web-search/src/extension.test.ts +++ b/packages/tool-web-search/src/extension.test.ts @@ -3,111 +3,111 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { activate, extension, manifest } from "./extension.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } function makeFakeHost(): { host: HostAPI; defineTool: ReturnType } { - const defineTool = vi.fn(); - const host = { - defineTool, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - span: vi.fn(() => ({ end: vi.fn() })), - }, - } as unknown as HostAPI; - return { host, defineTool }; + const defineTool = vi.fn(); + const host = { + defineTool, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + span: vi.fn(() => ({ end: vi.fn() })), + }, + } as unknown as HostAPI; + return { host, defineTool }; } const ORIG_FETCH = globalThis.fetch; const ORIG_ENV = process.env.FIRECRAWL_BASE_URL; function restoreEnv(): void { - if (ORIG_ENV === undefined) { - delete process.env.FIRECRAWL_BASE_URL; - } else { - process.env.FIRECRAWL_BASE_URL = ORIG_ENV; - } + if (ORIG_ENV === undefined) { + delete process.env.FIRECRAWL_BASE_URL; + } else { + process.env.FIRECRAWL_BASE_URL = ORIG_ENV; + } } afterEach(() => { - globalThis.fetch = ORIG_FETCH; - restoreEnv(); + globalThis.fetch = ORIG_FETCH; + restoreEnv(); }); function stubFetchCapture(): { calls: Array<{ url: string }> } { - const calls: Array<{ url: string }> = []; - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - calls.push({ url: String(input) }); - return new Response(JSON.stringify({ success: true, data: [] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }) as unknown as typeof globalThis.fetch; - return { calls }; + const calls: Array<{ url: string }> = []; + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + calls.push({ url: String(input) }); + return new Response(JSON.stringify({ success: true, data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof globalThis.fetch; + return { calls }; } describe("tool-web-search activation", () => { - it("registers the 'web_search' tool (defineTool called)", () => { - const { host, defineTool } = makeFakeHost(); - activate(host); - expect(defineTool).toHaveBeenCalledTimes(1); - const registered = defineTool.mock.calls[0]?.[0]; - if (!registered) throw new Error("no tool registered"); - expect(registered.name).toBe("web_search"); - expect(registered.concurrencySafe).toBe(true); - }); + it("registers the 'web_search' tool (defineTool called)", () => { + const { host, defineTool } = makeFakeHost(); + activate(host); + expect(defineTool).toHaveBeenCalledTimes(1); + const registered = defineTool.mock.calls[0]?.[0]; + if (!registered) throw new Error("no tool registered"); + expect(registered.name).toBe("web_search"); + expect(registered.concurrencySafe).toBe(true); + }); - it("uses FIRECRAWL_BASE_URL from env", async () => { - process.env.FIRECRAWL_BASE_URL = "http://env-firecrawl.local/v1"; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses FIRECRAWL_BASE_URL from env", async () => { + process.env.FIRECRAWL_BASE_URL = "http://env-firecrawl.local/v1"; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ query: "hello" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("http://env-firecrawl.local/v1/search"); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ query: "hello" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("http://env-firecrawl.local/v1/search"); + }); - it("uses default base URL when env unset", async () => { - delete process.env.FIRECRAWL_BASE_URL; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses default base URL when env unset", async () => { + delete process.env.FIRECRAWL_BASE_URL; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ query: "hello" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("100.102.55.49:31329/v1/search"); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ query: "hello" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("100.102.55.49:31329/v1/search"); + }); }); describe("tool-web-search manifest", () => { - it("declares network capability + web_search contribution", () => { - expect(manifest.id).toBe("tool-web-search"); - expect(manifest.capabilities).toEqual({ network: true }); - expect(manifest.contributes).toEqual({ tools: ["web_search"] }); - expect(manifest.trust).toBe("bundled"); - expect(manifest.activation).toBe("eager"); - }); + it("declares network capability + web_search contribution", () => { + expect(manifest.id).toBe("tool-web-search"); + expect(manifest.capabilities).toEqual({ network: true }); + expect(manifest.contributes).toEqual({ tools: ["web_search"] }); + expect(manifest.trust).toBe("bundled"); + expect(manifest.activation).toBe("eager"); + }); - it("extension bundles the manifest + activate", () => { - expect(extension.manifest).toBe(manifest); - expect(typeof extension.activate).toBe("function"); - }); + it("extension bundles the manifest + activate", () => { + expect(extension.manifest).toBe(manifest); + expect(typeof extension.activate).toBe("function"); + }); }); diff --git a/packages/tool-web-search/src/extension.ts b/packages/tool-web-search/src/extension.ts index 1d1803d..7eac643 100644 --- a/packages/tool-web-search/src/extension.ts +++ b/packages/tool-web-search/src/extension.ts @@ -13,20 +13,20 @@ import { createFirecrawlClient, DEFAULT_BASE_URL } from "./client.js"; import { createWebSearchTool } from "./tool.js"; export const manifest: Manifest = { - id: "tool-web-search", - name: "Web Search Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { network: true }, - contributes: { tools: ["web_search"] }, + id: "tool-web-search", + name: "Web Search Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { tools: ["web_search"] }, }; export function activate(host: HostAPI): void { - const baseUrl = process.env.FIRECRAWL_BASE_URL ?? DEFAULT_BASE_URL; - const client = createFirecrawlClient({ baseUrl, fetchFn: globalThis.fetch }); - host.defineTool(createWebSearchTool({ client })); + const baseUrl = process.env.FIRECRAWL_BASE_URL ?? DEFAULT_BASE_URL; + const client = createFirecrawlClient({ baseUrl, fetchFn: globalThis.fetch }); + host.defineTool(createWebSearchTool({ client })); } export const extension: Extension = { manifest, activate }; diff --git a/packages/tool-web-search/src/format.test.ts b/packages/tool-web-search/src/format.test.ts index b98bc02..a3f8c09 100644 --- a/packages/tool-web-search/src/format.test.ts +++ b/packages/tool-web-search/src/format.test.ts @@ -1,87 +1,87 @@ import { describe, expect, it } from "vitest"; import { - formatCrawlResults, - formatMapResults, - formatScrapeResult, - formatSearchResults, - truncateOutput, + formatCrawlResults, + formatMapResults, + formatScrapeResult, + formatSearchResults, + truncateOutput, } from "./format.js"; describe("formatSearchResults", () => { - it("formats title + url + description + optional markdown", () => { - const out = formatSearchResults([ - { title: "T1", url: "http://a", description: "desc", markdown: "md-body" }, - ]); - expect(out).toBe("### T1\nhttp://a\n\ndesc\n\nmd-body"); - }); + it("formats title + url + description + optional markdown", () => { + const out = formatSearchResults([ + { title: "T1", url: "http://a", description: "desc", markdown: "md-body" }, + ]); + expect(out).toBe("### T1\nhttp://a\n\ndesc\n\nmd-body"); + }); - it("joins multiple results with ---", () => { - const out = formatSearchResults([ - { title: "T1", url: "http://a", description: "d1" }, - { title: "T2", url: "http://b", description: "d2" }, - ]); - expect(out).toBe("### T1\nhttp://a\n\nd1\n\n---\n\n### T2\nhttp://b\n\nd2"); - }); + it("joins multiple results with ---", () => { + const out = formatSearchResults([ + { title: "T1", url: "http://a", description: "d1" }, + { title: "T2", url: "http://b", description: "d2" }, + ]); + expect(out).toBe("### T1\nhttp://a\n\nd1\n\n---\n\n### T2\nhttp://b\n\nd2"); + }); - it("empty data returns 'No results found.'", () => { - expect(formatSearchResults([])).toBe("No results found."); - expect(formatSearchResults(null)).toBe("No results found."); - expect(formatSearchResults(undefined)).toBe("No results found."); - }); + it("empty data returns 'No results found.'", () => { + expect(formatSearchResults([])).toBe("No results found."); + expect(formatSearchResults(null)).toBe("No results found."); + expect(formatSearchResults(undefined)).toBe("No results found."); + }); }); describe("formatScrapeResult", () => { - it("formats title + markdown", () => { - const out = formatScrapeResult({ - data: { markdown: "body", metadata: { title: "Title" } }, - }); - expect(out).toBe("# Title\n\nbody"); - }); + it("formats title + markdown", () => { + const out = formatScrapeResult({ + data: { markdown: "body", metadata: { title: "Title" } }, + }); + expect(out).toBe("# Title\n\nbody"); + }); - it("omits title header when absent", () => { - const out = formatScrapeResult({ data: { markdown: "body" } }); - expect(out).toBe("body"); - }); + it("omits title header when absent", () => { + const out = formatScrapeResult({ data: { markdown: "body" } }); + expect(out).toBe("body"); + }); }); describe("formatCrawlResults", () => { - it("formats multiple pages", () => { - const out = formatCrawlResults([ - { markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }, - { markdown: "p2", metadata: { title: "P2", url: "http://p2" } }, - ]); - expect(out).toBe("## P1\nhttp://p1\n\np1\n\n---\n\n## P2\nhttp://p2\n\np2"); - }); + it("formats multiple pages", () => { + const out = formatCrawlResults([ + { markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }, + { markdown: "p2", metadata: { title: "P2", url: "http://p2" } }, + ]); + expect(out).toBe("## P1\nhttp://p1\n\np1\n\n---\n\n## P2\nhttp://p2\n\np2"); + }); - it("empty data returns 'No pages crawled.'", () => { - expect(formatCrawlResults([])).toBe("No pages crawled."); - expect(formatCrawlResults(null)).toBe("No pages crawled."); - }); + it("empty data returns 'No pages crawled.'", () => { + expect(formatCrawlResults([])).toBe("No pages crawled."); + expect(formatCrawlResults(null)).toBe("No pages crawled."); + }); }); describe("formatMapResults", () => { - it("formats links as bullet list", () => { - const out = formatMapResults(["http://a", "http://b"]); - expect(out).toBe("- http://a\n- http://b"); - }); + it("formats links as bullet list", () => { + const out = formatMapResults(["http://a", "http://b"]); + expect(out).toBe("- http://a\n- http://b"); + }); - it("empty links returns 'No links found.'", () => { - expect(formatMapResults([])).toBe("No links found."); - expect(formatMapResults(null)).toBe("No links found."); - }); + it("empty links returns 'No links found.'", () => { + expect(formatMapResults([])).toBe("No links found."); + expect(formatMapResults(null)).toBe("No links found."); + }); }); describe("truncateOutput", () => { - it("truncates with notice when over cap", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50); - expect(result).toContain("a".repeat(50)); - expect(result).toContain("[Output truncated: exceeded 50 characters]"); - expect(result.length).toBeLessThan(output.length + 100); - }); + it("truncates with notice when over cap", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); - it("returns as-is when under cap", () => { - expect(truncateOutput("short", 100)).toBe("short"); - expect(truncateOutput("exact", 5)).toBe("exact"); - }); + it("returns as-is when under cap", () => { + expect(truncateOutput("short", 100)).toBe("short"); + expect(truncateOutput("exact", 5)).toBe("exact"); + }); }); diff --git a/packages/tool-web-search/src/format.ts b/packages/tool-web-search/src/format.ts index cfc9aa0..172fd85 100644 --- a/packages/tool-web-search/src/format.ts +++ b/packages/tool-web-search/src/format.ts @@ -8,28 +8,28 @@ /** A single search hit from Firecrawl's `/search` endpoint. */ export interface SearchHit { - readonly title?: string; - readonly url?: string; - readonly description?: string; - readonly markdown?: string; + readonly title?: string; + readonly url?: string; + readonly description?: string; + readonly markdown?: string; } /** One page from a completed crawl (`/crawl` status `data`). */ export interface CrawlPage { - readonly markdown?: string; - readonly metadata?: { - readonly title?: string; - readonly sourceURL?: string; - readonly url?: string; - }; + readonly markdown?: string; + readonly metadata?: { + readonly title?: string; + readonly sourceURL?: string; + readonly url?: string; + }; } /** The scrape response payload (`/scrape` `data`). */ export interface ScrapeResult { - readonly data?: { - readonly markdown?: string; - readonly metadata?: { readonly title?: string }; - }; + readonly data?: { + readonly markdown?: string; + readonly metadata?: { readonly title?: string }; + }; } /** @@ -37,11 +37,11 @@ export interface ScrapeResult { * spirit to tool-shell. Duplication across features is the intended trade. */ export function truncateOutput(output: string, cap: number): string { - if (output.length <= cap) { - return output; - } - const truncated = output.slice(0, cap); - return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; } /** @@ -49,21 +49,21 @@ export function truncateOutput(output: string, cap: number): string { * joined by `---` separators. Empty → `"No results found."`. */ export function formatSearchResults(data: readonly SearchHit[] | null | undefined): string { - if (!data || data.length === 0) { - return "No results found."; - } - const parts: string[] = []; - for (const r of data) { - const title = r.title ?? "(no title)"; - const url = r.url ?? ""; - const description = r.description ?? ""; - let section = `### ${title}\n${url}\n\n${description}`; - if (r.markdown) { - section += `\n\n${r.markdown}`; - } - parts.push(section); - } - return parts.join("\n\n---\n\n"); + if (!data || data.length === 0) { + return "No results found."; + } + const parts: string[] = []; + for (const r of data) { + const title = r.title ?? "(no title)"; + const url = r.url ?? ""; + const description = r.description ?? ""; + let section = `### ${title}\n${url}\n\n${description}`; + if (r.markdown) { + section += `\n\n${r.markdown}`; + } + parts.push(section); + } + return parts.join("\n\n---\n\n"); } /** @@ -71,12 +71,12 @@ export function formatSearchResults(data: readonly SearchHit[] | null | undefine * the title is absent. */ export function formatScrapeResult(json: ScrapeResult): string { - const md = json.data?.markdown ?? ""; - const title = json.data?.metadata?.title; - if (title) { - return `# ${title}\n\n${md}`; - } - return md; + const md = json.data?.markdown ?? ""; + const title = json.data?.metadata?.title; + if (title) { + return `# ${title}\n\n${md}`; + } + return md; } /** @@ -84,28 +84,28 @@ export function formatScrapeResult(json: ScrapeResult): string { * Empty → `"No pages crawled."`. */ export function formatCrawlResults(data: readonly CrawlPage[] | null | undefined): string { - if (!data || data.length === 0) { - return "No pages crawled."; - } - const parts: string[] = []; - for (const page of data) { - const title = page.metadata?.title ?? "(no title)"; - const url = page.metadata?.sourceURL ?? page.metadata?.url ?? ""; - let section = `## ${title}\n${url}`; - if (page.markdown) { - section += `\n\n${page.markdown}`; - } - parts.push(section); - } - return parts.join("\n\n---\n\n"); + if (!data || data.length === 0) { + return "No pages crawled."; + } + const parts: string[] = []; + for (const page of data) { + const title = page.metadata?.title ?? "(no title)"; + const url = page.metadata?.sourceURL ?? page.metadata?.url ?? ""; + let section = `## ${title}\n${url}`; + if (page.markdown) { + section += `\n\n${page.markdown}`; + } + parts.push(section); + } + return parts.join("\n\n---\n\n"); } /** * Format discovered links as a bullet list. Empty → `"No links found."`. */ export function formatMapResults(links: readonly string[] | null | undefined): string { - if (!links || links.length === 0) { - return "No links found."; - } - return links.map((l) => `- ${l}`).join("\n"); + if (!links || links.length === 0) { + return "No links found."; + } + return links.map((l) => `- ${l}`).join("\n"); } diff --git a/packages/tool-web-search/src/index.ts b/packages/tool-web-search/src/index.ts index 69894d1..519a1fe 100644 --- a/packages/tool-web-search/src/index.ts +++ b/packages/tool-web-search/src/index.ts @@ -1,40 +1,40 @@ export { - CRAWL_MAX_WAIT_MS, - CRAWL_POLL_MS, - type CrawlParams, - createFirecrawlClient, - DEFAULT_BASE_URL, - DEFAULT_TIMEOUT_MS, - type FetchLike, - type FirecrawlClient, - type FirecrawlClientDeps, - type ScrapeParams, - type SearchParams, + CRAWL_MAX_WAIT_MS, + CRAWL_POLL_MS, + type CrawlParams, + createFirecrawlClient, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + type FetchLike, + type FirecrawlClient, + type FirecrawlClientDeps, + type ScrapeParams, + type SearchParams, } from "./client.js"; export { activate, extension, manifest } from "./extension.js"; export { - type CrawlPage, - formatCrawlResults, - formatMapResults, - formatScrapeResult, - formatSearchResults, - type ScrapeResult, - type SearchHit, - truncateOutput, + type CrawlPage, + formatCrawlResults, + formatMapResults, + formatScrapeResult, + formatSearchResults, + type ScrapeResult, + type SearchHit, + truncateOutput, } from "./format.js"; export { createWebSearchTool, type WebSearchToolDeps } from "./tool.js"; export { - CRAWL_DEFAULT_LIMIT, - type CrawlArgs, - FORMATS, - type Format, - MAX_LIMIT, - type MapArgs, - MODES, - type Mode, - type ScrapeArgs, - SEARCH_DEFAULT_LIMIT, - type SearchArgs, - type ValidatedArgs, - validateArgs, + CRAWL_DEFAULT_LIMIT, + type CrawlArgs, + FORMATS, + type Format, + MAX_LIMIT, + type MapArgs, + MODES, + type Mode, + type ScrapeArgs, + SEARCH_DEFAULT_LIMIT, + type SearchArgs, + type ValidatedArgs, + validateArgs, } from "./validate.js"; diff --git a/packages/tool-web-search/src/tool.ts b/packages/tool-web-search/src/tool.ts index 751278d..b2e84fe 100644 --- a/packages/tool-web-search/src/tool.ts +++ b/packages/tool-web-search/src/tool.ts @@ -10,11 +10,11 @@ import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import type { FirecrawlClient } from "./client.js"; import { - formatCrawlResults, - formatMapResults, - formatScrapeResult, - formatSearchResults, - truncateOutput, + formatCrawlResults, + formatMapResults, + formatScrapeResult, + formatSearchResults, + truncateOutput, } from "./format.js"; import type { ValidatedArgs } from "./validate.js"; import { validateArgs } from "./validate.js"; @@ -22,51 +22,51 @@ import { validateArgs } from "./validate.js"; const OUTPUT_CAP = 50_000; export interface WebSearchToolDeps { - readonly client: FirecrawlClient; - readonly outputCap?: number; + readonly client: FirecrawlClient; + readonly outputCap?: number; } /** Dispatch validated args to the right client method and format the result. */ async function runMode( - validated: ValidatedArgs, - client: FirecrawlClient, - signal: AbortSignal, + validated: ValidatedArgs, + client: FirecrawlClient, + signal: AbortSignal, ): Promise { - switch (validated.mode) { - case "search": { - const hits = await client.search( - { - query: validated.query, - limit: validated.limit, - ...(validated.scrape - ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } } - : {}), - ...(validated.lang !== undefined ? { lang: validated.lang } : {}), - ...(validated.country !== undefined ? { country: validated.country } : {}), - }, - signal, - ); - return formatSearchResults(hits); - } - case "scrape": { - const result = await client.scrape( - { url: validated.url, formats: [validated.format] }, - signal, - ); - return formatScrapeResult(result); - } - case "crawl": { - const pages = await client.crawl( - { url: validated.url, limit: validated.limit, formats: [validated.format] }, - signal, - ); - return formatCrawlResults(pages); - } - case "map": { - const links = await client.map(validated.url, signal); - return formatMapResults(links); - } - } + switch (validated.mode) { + case "search": { + const hits = await client.search( + { + query: validated.query, + limit: validated.limit, + ...(validated.scrape + ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } } + : {}), + ...(validated.lang !== undefined ? { lang: validated.lang } : {}), + ...(validated.country !== undefined ? { country: validated.country } : {}), + }, + signal, + ); + return formatSearchResults(hits); + } + case "scrape": { + const result = await client.scrape( + { url: validated.url, formats: [validated.format] }, + signal, + ); + return formatScrapeResult(result); + } + case "crawl": { + const pages = await client.crawl( + { url: validated.url, limit: validated.limit, formats: [validated.format] }, + signal, + ); + return formatCrawlResults(pages); + } + case "map": { + const links = await client.map(validated.url, signal); + return formatMapResults(links); + } + } } /** @@ -75,68 +75,68 @@ async function runMode( * is declared on the extension manifest (not the tool contract). */ export function createWebSearchTool(deps: WebSearchToolDeps): ToolContract { - const client = deps.client; - const cap = deps.outputCap ?? OUTPUT_CAP; + const client = deps.client; + const cap = deps.outputCap ?? OUTPUT_CAP; - return { - name: "web_search", - description: - "Access the web via a self-hosted Firecrawl instance. Supports search, " + - "single-page scrape, site crawling, and sitemap discovery.", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "The search query (search mode)." }, - url: { type: "string", description: "A URL to scrape, crawl, or map." }, - mode: { - type: "string", - enum: ["search", "scrape", "crawl", "map"], - description: - "Operation mode. 'search' (default when query present), 'scrape' " + - "(default when url present), 'crawl' (recursively scrape pages from a site), " + - "'map' (discover URLs on a site).", - }, - limit: { - type: "number", - description: "Max results. Search: default 7, max 10. Crawl: default 3, max 10.", - }, - scrape: { - type: "boolean", - description: "When searching, also scrape full markdown content of each result page.", - }, - lang: { - type: "string", - description: 'Language code to filter search results (e.g. "en", "ja").', - }, - country: { - type: "string", - description: 'Country code to filter search results (e.g. "us", "jp").', - }, - format: { - type: "string", - enum: ["markdown", "text", "html"], - description: "Format for scrape/crawl output (default: markdown).", - }, - }, - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - const span = ctx.log.span("web_search.execute", { mode: validated.mode }); - try { - const output = await runMode(validated, client, ctx.signal); - span.end(); - return { content: truncateOutput(output, cap) }; - } catch (err: unknown) { - span.end({ err }); - return { - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - }, - }; + return { + name: "web_search", + description: + "Access the web via a self-hosted Firecrawl instance. Supports search, " + + "single-page scrape, site crawling, and sitemap discovery.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "The search query (search mode)." }, + url: { type: "string", description: "A URL to scrape, crawl, or map." }, + mode: { + type: "string", + enum: ["search", "scrape", "crawl", "map"], + description: + "Operation mode. 'search' (default when query present), 'scrape' " + + "(default when url present), 'crawl' (recursively scrape pages from a site), " + + "'map' (discover URLs on a site).", + }, + limit: { + type: "number", + description: "Max results. Search: default 7, max 10. Crawl: default 3, max 10.", + }, + scrape: { + type: "boolean", + description: "When searching, also scrape full markdown content of each result page.", + }, + lang: { + type: "string", + description: 'Language code to filter search results (e.g. "en", "ja").', + }, + country: { + type: "string", + description: 'Country code to filter search results (e.g. "us", "jp").', + }, + format: { + type: "string", + enum: ["markdown", "text", "html"], + description: "Format for scrape/crawl output (default: markdown).", + }, + }, + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + const span = ctx.log.span("web_search.execute", { mode: validated.mode }); + try { + const output = await runMode(validated, client, ctx.signal); + span.end(); + return { content: truncateOutput(output, cap) }; + } catch (err: unknown) { + span.end({ err }); + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; } diff --git a/packages/tool-web-search/src/validate.test.ts b/packages/tool-web-search/src/validate.test.ts index 30ae26c..ca1aa7f 100644 --- a/packages/tool-web-search/src/validate.test.ts +++ b/packages/tool-web-search/src/validate.test.ts @@ -1,92 +1,92 @@ import { describe, expect, it } from "vitest"; import { - type CrawlArgs, - type MapArgs, - type ScrapeArgs, - type SearchArgs, - validateArgs, + type CrawlArgs, + type MapArgs, + type ScrapeArgs, + type SearchArgs, + validateArgs, } from "./validate.js"; describe("validateArgs", () => { - it("mode defaults to search when query present", () => { - const result = validateArgs({ query: "hello" }); - expect("error" in result).toBe(false); - if ("error" in result) return; - expect(result.mode).toBe("search"); - expect((result as SearchArgs).query).toBe("hello"); - }); + it("mode defaults to search when query present", () => { + const result = validateArgs({ query: "hello" }); + expect("error" in result).toBe(false); + if ("error" in result) return; + expect(result.mode).toBe("search"); + expect((result as SearchArgs).query).toBe("hello"); + }); - it("mode defaults to scrape when url present (no query)", () => { - const result = validateArgs({ url: "http://example.com" }); - expect("error" in result).toBe(false); - if ("error" in result) return; - expect(result.mode).toBe("scrape"); - expect((result as ScrapeArgs).url).toBe("http://example.com"); - }); + it("mode defaults to scrape when url present (no query)", () => { + const result = validateArgs({ url: "http://example.com" }); + expect("error" in result).toBe(false); + if ("error" in result) return; + expect(result.mode).toBe("scrape"); + expect((result as ScrapeArgs).url).toBe("http://example.com"); + }); - it("explicit mode overrides defaults", () => { - const result = validateArgs({ query: "hello", url: "http://x", mode: "map" }); - expect("error" in result).toBe(false); - if ("error" in result) return; - expect(result.mode).toBe("map"); - expect((result as MapArgs).url).toBe("http://x"); - }); + it("explicit mode overrides defaults", () => { + const result = validateArgs({ query: "hello", url: "http://x", mode: "map" }); + expect("error" in result).toBe(false); + if ("error" in result) return; + expect(result.mode).toBe("map"); + expect((result as MapArgs).url).toBe("http://x"); + }); - it("search mode requires query", () => { - const result = validateArgs({ mode: "search" }); - expect(result).toHaveProperty("error"); - }); + it("search mode requires query", () => { + const result = validateArgs({ mode: "search" }); + expect(result).toHaveProperty("error"); + }); - it("scrape/crawl/map modes require url", () => { - expect(validateArgs({ mode: "scrape" })).toHaveProperty("error"); - expect(validateArgs({ mode: "crawl" })).toHaveProperty("error"); - expect(validateArgs({ mode: "map" })).toHaveProperty("error"); - }); + it("scrape/crawl/map modes require url", () => { + expect(validateArgs({ mode: "scrape" })).toHaveProperty("error"); + expect(validateArgs({ mode: "crawl" })).toHaveProperty("error"); + expect(validateArgs({ mode: "map" })).toHaveProperty("error"); + }); - it("limit clamped to max 10", () => { - const result = validateArgs({ query: "hello", limit: 50 }); - expect("error" in result).toBe(false); - if ("error" in result) return; - expect((result as SearchArgs).limit).toBe(10); - }); + it("limit clamped to max 10", () => { + const result = validateArgs({ query: "hello", limit: 50 }); + expect("error" in result).toBe(false); + if ("error" in result) return; + expect((result as SearchArgs).limit).toBe(10); + }); - it("limit defaults to 7 (search) / 3 (crawl)", () => { - const search = validateArgs({ query: "hello" }); - expect("error" in search).toBe(false); - if ("error" in search) return; - expect((search as SearchArgs).limit).toBe(7); + it("limit defaults to 7 (search) / 3 (crawl)", () => { + const search = validateArgs({ query: "hello" }); + expect("error" in search).toBe(false); + if ("error" in search) return; + expect((search as SearchArgs).limit).toBe(7); - const crawl = validateArgs({ url: "http://x", mode: "crawl" }); - expect("error" in crawl).toBe(false); - if ("error" in crawl) return; - expect((crawl as CrawlArgs).limit).toBe(3); - }); + const crawl = validateArgs({ url: "http://x", mode: "crawl" }); + expect("error" in crawl).toBe(false); + if ("error" in crawl) return; + expect((crawl as CrawlArgs).limit).toBe(3); + }); - it("format defaults to markdown", () => { - const result = validateArgs({ query: "hello" }); - expect("error" in result).toBe(false); - if ("error" in result) return; - expect(result.format).toBe("markdown"); - }); + it("format defaults to markdown", () => { + const result = validateArgs({ query: "hello" }); + expect("error" in result).toBe(false); + if ("error" in result) return; + expect(result.format).toBe("markdown"); + }); - it("rejects invalid mode", () => { - const result = validateArgs({ mode: "invalid" }); - expect(result).toHaveProperty("error"); - if (!("error" in result)) return; - expect(result.error).toContain("Invalid mode"); - }); + it("rejects invalid mode", () => { + const result = validateArgs({ mode: "invalid" }); + expect(result).toHaveProperty("error"); + if (!("error" in result)) return; + expect(result.error).toContain("Invalid mode"); + }); - it("rejects invalid format", () => { - const result = validateArgs({ url: "http://x", format: "pdf" }); - expect(result).toHaveProperty("error"); - if (!("error" in result)) return; - expect(result.error).toContain("Invalid format"); - }); + it("rejects invalid format", () => { + const result = validateArgs({ url: "http://x", format: "pdf" }); + expect(result).toHaveProperty("error"); + if (!("error" in result)) return; + expect(result.error).toContain("Invalid format"); + }); - it("returns error for null/non-object args", () => { - expect(validateArgs(null)).toHaveProperty("error"); - expect(validateArgs(undefined)).toHaveProperty("error"); - expect(validateArgs("string")).toHaveProperty("error"); - expect(validateArgs(42)).toHaveProperty("error"); - }); + it("returns error for null/non-object args", () => { + expect(validateArgs(null)).toHaveProperty("error"); + expect(validateArgs(undefined)).toHaveProperty("error"); + expect(validateArgs("string")).toHaveProperty("error"); + expect(validateArgs(42)).toHaveProperty("error"); + }); }); diff --git a/packages/tool-web-search/src/validate.ts b/packages/tool-web-search/src/validate.ts index 56bd356..019fd5a 100644 --- a/packages/tool-web-search/src/validate.ts +++ b/packages/tool-web-search/src/validate.ts @@ -17,32 +17,32 @@ export const CRAWL_DEFAULT_LIMIT = 3; export const MAX_LIMIT = 10; interface BaseArgs { - readonly format: Format; + readonly format: Format; } export interface SearchArgs extends BaseArgs { - readonly mode: "search"; - readonly query: string; - readonly limit: number; - readonly scrape: boolean; - readonly lang?: string; - readonly country?: string; + readonly mode: "search"; + readonly query: string; + readonly limit: number; + readonly scrape: boolean; + readonly lang?: string; + readonly country?: string; } export interface ScrapeArgs extends BaseArgs { - readonly mode: "scrape"; - readonly url: string; + readonly mode: "scrape"; + readonly url: string; } export interface CrawlArgs extends BaseArgs { - readonly mode: "crawl"; - readonly url: string; - readonly limit: number; + readonly mode: "crawl"; + readonly url: string; + readonly limit: number; } export interface MapArgs extends BaseArgs { - readonly mode: "map"; - readonly url: string; + readonly mode: "map"; + readonly url: string; } export type ValidatedArgs = SearchArgs | ScrapeArgs | CrawlArgs | MapArgs; @@ -52,60 +52,60 @@ export type ValidationError = { readonly error: string }; type Result = { readonly value: T } | ValidationError; function resolveFormat(raw: unknown): Result { - if (raw === undefined || raw === null) { - return { value: "markdown" }; - } - if (typeof raw === "string" && (FORMATS as readonly string[]).includes(raw)) { - return { value: raw as Format }; - } - return { - error: `Error: Invalid format "${String(raw)}" (must be one of: markdown, text, html).`, - }; + if (raw === undefined || raw === null) { + return { value: "markdown" }; + } + if (typeof raw === "string" && (FORMATS as readonly string[]).includes(raw)) { + return { value: raw as Format }; + } + return { + error: `Error: Invalid format "${String(raw)}" (must be one of: markdown, text, html).`, + }; } function resolveMode(raw: unknown, query: unknown, url: unknown): Result { - if (raw === undefined || raw === null) { - const hasQuery = typeof query === "string" && query.trim().length > 0; - const hasUrl = typeof url === "string" && url.trim().length > 0; - return { value: hasQuery ? "search" : hasUrl ? "scrape" : "search" }; - } - if (typeof raw === "string" && (MODES as readonly string[]).includes(raw)) { - return { value: raw as Mode }; - } - return { - error: `Error: Invalid mode "${String(raw)}" (must be one of: search, scrape, crawl, map).`, - }; + if (raw === undefined || raw === null) { + const hasQuery = typeof query === "string" && query.trim().length > 0; + const hasUrl = typeof url === "string" && url.trim().length > 0; + return { value: hasQuery ? "search" : hasUrl ? "scrape" : "search" }; + } + if (typeof raw === "string" && (MODES as readonly string[]).includes(raw)) { + return { value: raw as Mode }; + } + return { + error: `Error: Invalid mode "${String(raw)}" (must be one of: search, scrape, crawl, map).`, + }; } function optionalString(raw: unknown, name: string): Result { - if (raw === undefined || raw === null) { - return { value: undefined }; - } - if (typeof raw === "string") { - return { value: raw }; - } - return { error: `Error: "${name}" must be a string.` }; + if (raw === undefined || raw === null) { + return { value: undefined }; + } + if (typeof raw === "string") { + return { value: raw }; + } + return { error: `Error: "${name}" must be a string.` }; } function resolveLimit(raw: unknown, defaultLimit: number): Result { - if (raw === undefined || raw === null) { - return { value: defaultLimit }; - } - const n = Number(raw); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: "limit" must be a positive number.' }; - } - return { value: Math.min(Math.floor(n), MAX_LIMIT) }; + if (raw === undefined || raw === null) { + return { value: defaultLimit }; + } + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: "limit" must be a positive number.' }; + } + return { value: Math.min(Math.floor(n), MAX_LIMIT) }; } function resolveBoolean(raw: unknown, name: string): Result { - if (raw === undefined || raw === null) { - return { value: false }; - } - if (typeof raw === "boolean") { - return { value: raw }; - } - return { error: `Error: "${name}" must be a boolean.` }; + if (raw === undefined || raw === null) { + return { value: false }; + } + if (typeof raw === "boolean") { + return { value: raw }; + } + return { error: `Error: "${name}" must be a boolean.` }; } /** @@ -113,100 +113,100 @@ function resolveBoolean(raw: unknown, name: string): Result { * Returns `{ error }` for invalid input — the tool surfaces it verbatim. */ export function validateArgs(args: unknown): ValidatedArgs | ValidationError { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; - - const format = resolveFormat(obj.format); - if ("error" in format) { - return format; - } - - const mode = resolveMode(obj.mode, obj.query, obj.url); - if ("error" in mode) { - return mode; - } - - const query = optionalString(obj.query, "query"); - if ("error" in query) { - return query; - } - - const url = optionalString(obj.url, "url"); - if ("error" in url) { - return url; - } - - switch (mode.value) { - case "search": { - if (query.value === undefined || query.value.trim().length === 0) { - return { error: "Error: query is required for search mode." }; - } - const limit = resolveLimit(obj.limit, SEARCH_DEFAULT_LIMIT); - if ("error" in limit) { - return limit; - } - const scrape = resolveBoolean(obj.scrape, "scrape"); - if ("error" in scrape) { - return scrape; - } - const lang = optionalString(obj.lang, "lang"); - if ("error" in lang) { - return lang; - } - const country = optionalString(obj.country, "country"); - if ("error" in country) { - return country; - } - const result: SearchArgs = { - mode: "search", - query: query.value, - limit: limit.value, - scrape: scrape.value, - format: format.value, - ...(lang.value !== undefined ? { lang: lang.value } : {}), - ...(country.value !== undefined ? { country: country.value } : {}), - }; - return result; - } - case "scrape": { - if (url.value === undefined || url.value.trim().length === 0) { - return { error: "Error: url is required for scrape mode." }; - } - const result: ScrapeArgs = { - mode: "scrape", - url: url.value, - format: format.value, - }; - return result; - } - case "crawl": { - if (url.value === undefined || url.value.trim().length === 0) { - return { error: "Error: url is required for crawl mode." }; - } - const limit = resolveLimit(obj.limit, CRAWL_DEFAULT_LIMIT); - if ("error" in limit) { - return limit; - } - const result: CrawlArgs = { - mode: "crawl", - url: url.value, - limit: limit.value, - format: format.value, - }; - return result; - } - case "map": { - if (url.value === undefined || url.value.trim().length === 0) { - return { error: "Error: url is required for map mode." }; - } - const result: MapArgs = { - mode: "map", - url: url.value, - format: format.value, - }; - return result; - } - } + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; + + const format = resolveFormat(obj.format); + if ("error" in format) { + return format; + } + + const mode = resolveMode(obj.mode, obj.query, obj.url); + if ("error" in mode) { + return mode; + } + + const query = optionalString(obj.query, "query"); + if ("error" in query) { + return query; + } + + const url = optionalString(obj.url, "url"); + if ("error" in url) { + return url; + } + + switch (mode.value) { + case "search": { + if (query.value === undefined || query.value.trim().length === 0) { + return { error: "Error: query is required for search mode." }; + } + const limit = resolveLimit(obj.limit, SEARCH_DEFAULT_LIMIT); + if ("error" in limit) { + return limit; + } + const scrape = resolveBoolean(obj.scrape, "scrape"); + if ("error" in scrape) { + return scrape; + } + const lang = optionalString(obj.lang, "lang"); + if ("error" in lang) { + return lang; + } + const country = optionalString(obj.country, "country"); + if ("error" in country) { + return country; + } + const result: SearchArgs = { + mode: "search", + query: query.value, + limit: limit.value, + scrape: scrape.value, + format: format.value, + ...(lang.value !== undefined ? { lang: lang.value } : {}), + ...(country.value !== undefined ? { country: country.value } : {}), + }; + return result; + } + case "scrape": { + if (url.value === undefined || url.value.trim().length === 0) { + return { error: "Error: url is required for scrape mode." }; + } + const result: ScrapeArgs = { + mode: "scrape", + url: url.value, + format: format.value, + }; + return result; + } + case "crawl": { + if (url.value === undefined || url.value.trim().length === 0) { + return { error: "Error: url is required for crawl mode." }; + } + const limit = resolveLimit(obj.limit, CRAWL_DEFAULT_LIMIT); + if ("error" in limit) { + return limit; + } + const result: CrawlArgs = { + mode: "crawl", + url: url.value, + limit: limit.value, + format: format.value, + }; + return result; + } + case "map": { + if (url.value === undefined || url.value.trim().length === 0) { + return { error: "Error: url is required for map mode." }; + } + const result: MapArgs = { + mode: "map", + url: url.value, + format: format.value, + }; + return result; + } + } } diff --git a/packages/tool-web-search/tsconfig.json b/packages/tool-web-search/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/tool-web-search/tsconfig.json +++ b/packages/tool-web-search/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/tool-write-file/package.json b/packages/tool-write-file/package.json index 63c2ccd..beb7c07 100644 --- a/packages/tool-write-file/package.json +++ b/packages/tool-write-file/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/tool-write-file", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/exec-backend": "workspace:*" - } + "name": "@dispatch/tool-write-file", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" + } } diff --git a/packages/tool-write-file/src/extension.ts b/packages/tool-write-file/src/extension.ts index 0a9a10f..36f7148 100644 --- a/packages/tool-write-file/src/extension.ts +++ b/packages/tool-write-file/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createWriteFileTool } from "./write-file.js"; export const extension: Extension = { - manifest: { - id: "tool-write-file", - name: "Write File Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { fs: true }, - contributes: { tools: ["write_file"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); - }, + manifest: { + id: "tool-write-file", + name: "Write File Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { fs: true }, + contributes: { tools: ["write_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); + }, }; diff --git a/packages/tool-write-file/src/index.ts b/packages/tool-write-file/src/index.ts index 8a2cb36..d81c90a 100644 --- a/packages/tool-write-file/src/index.ts +++ b/packages/tool-write-file/src/index.ts @@ -1,6 +1,6 @@ export { extension } from "./extension.js"; export { - createWriteFileTool, - decideOverwrite, - validateArgs, + createWriteFileTool, + decideOverwrite, + validateArgs, } from "./write-file.js"; diff --git a/packages/tool-write-file/src/write-file.test.ts b/packages/tool-write-file/src/write-file.test.ts index d157eb2..5999291 100644 --- a/packages/tool-write-file/src/write-file.test.ts +++ b/packages/tool-write-file/src/write-file.test.ts @@ -7,17 +7,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createWriteFileTool, decideOverwrite, validateArgs } from "./write-file.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** @@ -26,220 +26,220 @@ function stubCtx(overrides?: Partial): ToolExecuteContext { * real fs edge is exercised, matching the constitution's strict-core rule. */ function makeTool(workdir: string) { - return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); + return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); } let workdir: string; beforeEach(async () => { - workdir = await mkdtemp(join(tmpdir(), "tool-write-file-test-")); + workdir = await mkdtemp(join(tmpdir(), "tool-write-file-test-")); }); afterEach(async () => { - await rm(workdir, { recursive: true, force: true }); + await rm(workdir, { recursive: true, force: true }); }); describe("decideOverwrite", () => { - it("returns create when file absent and overwrite is false", () => { - expect(decideOverwrite(false, false)).toBe("create"); - }); - - it("returns create when file absent and overwrite is false (default)", () => { - expect(decideOverwrite(false, false)).toBe("create"); - }); - - it("returns error when file exists and overwrite is false", () => { - const result = decideOverwrite(true, false); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("already exists"); - } - }); - - it("returns overwrite when file exists and overwrite is true", () => { - expect(decideOverwrite(true, true)).toBe("overwrite"); - }); - - it("returns error when file absent and overwrite is true", () => { - const result = decideOverwrite(false, true); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("does not exist"); - } - }); - - it("covers all four rows of the truth table", () => { - expect(decideOverwrite(false, false)).toBe("create"); - expect(decideOverwrite(true, false)).toEqual( - expect.objectContaining({ error: expect.any(String) }), - ); - expect(decideOverwrite(true, true)).toBe("overwrite"); - expect(decideOverwrite(false, true)).toEqual( - expect.objectContaining({ error: expect.any(String) }), - ); - }); + it("returns create when file absent and overwrite is false", () => { + expect(decideOverwrite(false, false)).toBe("create"); + }); + + it("returns create when file absent and overwrite is false (default)", () => { + expect(decideOverwrite(false, false)).toBe("create"); + }); + + it("returns error when file exists and overwrite is false", () => { + const result = decideOverwrite(true, false); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("already exists"); + } + }); + + it("returns overwrite when file exists and overwrite is true", () => { + expect(decideOverwrite(true, true)).toBe("overwrite"); + }); + + it("returns error when file absent and overwrite is true", () => { + const result = decideOverwrite(false, true); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("does not exist"); + } + }); + + it("covers all four rows of the truth table", () => { + expect(decideOverwrite(false, false)).toBe("create"); + expect(decideOverwrite(true, false)).toEqual( + expect.objectContaining({ error: expect.any(String) }), + ); + expect(decideOverwrite(true, true)).toBe("overwrite"); + expect(decideOverwrite(false, true)).toEqual( + expect.objectContaining({ error: expect.any(String) }), + ); + }); }); describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ path: "foo.txt", content: "hello" }); - expect(result).toEqual({ path: "foo.txt", content: "hello", overwrite: false }); - }); - - it("parses overwrite as true", () => { - const result = validateArgs({ path: "foo.txt", content: "x", overwrite: true }); - expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: true }); - }); - - it("defaults overwrite to false", () => { - const result = validateArgs({ path: "foo.txt", content: "x" }); - expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: false }); - }); - - it("accepts empty string content", () => { - const result = validateArgs({ path: "foo.txt", content: "" }); - expect(result).toEqual({ path: "foo.txt", content: "", overwrite: false }); - }); - - it("returns error for null args", () => { - expect(validateArgs(null)).toHaveProperty("error"); - }); - - it("returns error for missing path", () => { - expect(validateArgs({ content: "x" })).toHaveProperty("error"); - }); - - it("returns error for missing content", () => { - expect(validateArgs({ path: "foo.txt" })).toHaveProperty("error"); - }); - - it("returns error for non-string content", () => { - expect(validateArgs({ path: "foo.txt", content: 123 })).toHaveProperty("error"); - }); - - it("returns error for non-boolean overwrite", () => { - expect(validateArgs({ path: "foo.txt", content: "x", overwrite: "yes" })).toHaveProperty( - "error", - ); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ path: "foo.txt", content: "hello" }); + expect(result).toEqual({ path: "foo.txt", content: "hello", overwrite: false }); + }); + + it("parses overwrite as true", () => { + const result = validateArgs({ path: "foo.txt", content: "x", overwrite: true }); + expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: true }); + }); + + it("defaults overwrite to false", () => { + const result = validateArgs({ path: "foo.txt", content: "x" }); + expect(result).toEqual({ path: "foo.txt", content: "x", overwrite: false }); + }); + + it("accepts empty string content", () => { + const result = validateArgs({ path: "foo.txt", content: "" }); + expect(result).toEqual({ path: "foo.txt", content: "", overwrite: false }); + }); + + it("returns error for null args", () => { + expect(validateArgs(null)).toHaveProperty("error"); + }); + + it("returns error for missing path", () => { + expect(validateArgs({ content: "x" })).toHaveProperty("error"); + }); + + it("returns error for missing content", () => { + expect(validateArgs({ path: "foo.txt" })).toHaveProperty("error"); + }); + + it("returns error for non-string content", () => { + expect(validateArgs({ path: "foo.txt", content: 123 })).toHaveProperty("error"); + }); + + it("returns error for non-boolean overwrite", () => { + expect(validateArgs({ path: "foo.txt", content: "x", overwrite: "yes" })).toHaveProperty( + "error", + ); + }); }); describe("createWriteFileTool", () => { - it("creates a new file when overwrite is unset and the file is absent", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Created"); - const written = await readFile(join(workdir, "new-file.txt"), "utf8"); - expect(written).toBe("hello world"); - }); - - it("errors when the file exists and overwrite is unset", async () => { - await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("already exists"); - expect(result.content).toContain("overwrite"); - const unchanged = await readFile(join(workdir, "existing.txt"), "utf8"); - expect(unchanged).toBe("old content"); - }); - - it("overwrites an existing file when overwrite is true", async () => { - await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "existing.txt", content: "new content", overwrite: true }, - stubCtx(), - ); - - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("Overwrote"); - const written = await readFile(join(workdir, "existing.txt"), "utf8"); - expect(written).toBe("new content"); - }); - - it("errors when overwrite is true but the file is absent", async () => { - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "nonexistent.txt", content: "data", overwrite: true }, - stubCtx(), - ); - - expect(result.isError).toBe(true); - expect(result.content).toContain("does not exist"); - }); - - it("errors when the parent directory does not exist", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); - - expect(result.isError).toBe(true); - expect(result.content).toContain("Error"); - }); - - it("concurrencySafe is false", () => { - const tool = makeTool(workdir); - expect(tool.concurrencySafe).toBe(false); - }); - - it("has correct name and parameters shape", () => { - const tool = makeTool(workdir); - expect(tool.name).toBe("write_file"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["path", "content"]); - expect(tool.parameters.properties?.path?.type).toBe("string"); - expect(tool.parameters.properties?.content?.type).toBe("string"); - expect(tool.parameters.properties?.overwrite?.type).toBe("boolean"); - }); - - it("never throws on bad input (always returns ToolResult)", async () => { - const tool = makeTool(workdir); - const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; - for (const input of inputs) { - const result = await tool.execute(input, stubCtx()); - expect(result).toHaveProperty("content"); - expect(typeof result.content).toBe("string"); - } - }); - - it("respects ctx.cwd over baked workdir", async () => { - const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); - try { - const tool = makeTool(workdir); - const result = await tool.execute( - { path: "ctx-file.txt", content: "from ctx" }, - stubCtx({ cwd: ctxDir }), - ); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(ctxDir, "ctx-file.txt"), "utf8"); - expect(written).toBe("from ctx"); - } finally { - await rm(ctxDir, { recursive: true, force: true }); - } - }); - - it("writes empty content", async () => { - const tool = makeTool(workdir); - const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(workdir, "empty.txt"), "utf8"); - expect(written).toBe(""); - }); - - it("writes content in subdirectory that exists", async () => { - await mkdir(join(workdir, "sub")); - const tool = makeTool(workdir); - const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); - - expect(result.isError).toBeUndefined(); - const written = await readFile(join(workdir, "sub", "file.txt"), "utf8"); - expect(written).toBe("nested"); - }); + it("creates a new file when overwrite is unset and the file is absent", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Created"); + const written = await readFile(join(workdir, "new-file.txt"), "utf8"); + expect(written).toBe("hello world"); + }); + + it("errors when the file exists and overwrite is unset", async () => { + await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("already exists"); + expect(result.content).toContain("overwrite"); + const unchanged = await readFile(join(workdir, "existing.txt"), "utf8"); + expect(unchanged).toBe("old content"); + }); + + it("overwrites an existing file when overwrite is true", async () => { + await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); + + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "existing.txt", content: "new content", overwrite: true }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Overwrote"); + const written = await readFile(join(workdir, "existing.txt"), "utf8"); + expect(written).toBe("new content"); + }); + + it("errors when overwrite is true but the file is absent", async () => { + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "nonexistent.txt", content: "data", overwrite: true }, + stubCtx(), + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("does not exist"); + }); + + it("errors when the parent directory does not exist", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Error"); + }); + + it("concurrencySafe is false", () => { + const tool = makeTool(workdir); + expect(tool.concurrencySafe).toBe(false); + }); + + it("has correct name and parameters shape", () => { + const tool = makeTool(workdir); + expect(tool.name).toBe("write_file"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["path", "content"]); + expect(tool.parameters.properties?.path?.type).toBe("string"); + expect(tool.parameters.properties?.content?.type).toBe("string"); + expect(tool.parameters.properties?.overwrite?.type).toBe("boolean"); + }); + + it("never throws on bad input (always returns ToolResult)", async () => { + const tool = makeTool(workdir); + const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; + for (const input of inputs) { + const result = await tool.execute(input, stubCtx()); + expect(result).toHaveProperty("content"); + expect(typeof result.content).toBe("string"); + } + }); + + it("respects ctx.cwd over baked workdir", async () => { + const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); + try { + const tool = makeTool(workdir); + const result = await tool.execute( + { path: "ctx-file.txt", content: "from ctx" }, + stubCtx({ cwd: ctxDir }), + ); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(ctxDir, "ctx-file.txt"), "utf8"); + expect(written).toBe("from ctx"); + } finally { + await rm(ctxDir, { recursive: true, force: true }); + } + }); + + it("writes empty content", async () => { + const tool = makeTool(workdir); + const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(workdir, "empty.txt"), "utf8"); + expect(written).toBe(""); + }); + + it("writes content in subdirectory that exists", async () => { + await mkdir(join(workdir, "sub")); + const tool = makeTool(workdir); + const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + const written = await readFile(join(workdir, "sub", "file.txt"), "utf8"); + expect(written).toBe("nested"); + }); }); diff --git a/packages/tool-write-file/src/write-file.ts b/packages/tool-write-file/src/write-file.ts index cf761b6..14a5ec4 100644 --- a/packages/tool-write-file/src/write-file.ts +++ b/packages/tool-write-file/src/write-file.ts @@ -3,51 +3,51 @@ import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; interface ValidatedArgs { - readonly path: string; - readonly content: string; - readonly overwrite: boolean; + readonly path: string; + readonly content: string; + readonly overwrite: boolean; } export type OverwriteDecision = "create" | "overwrite" | { readonly error: string }; /** Pure: decide the action based on file existence and the overwrite flag. */ export function decideOverwrite(fileExists: boolean, overwrite: boolean): OverwriteDecision { - if (!fileExists && !overwrite) return "create"; - if (fileExists && !overwrite) { - return { error: "Error: File already exists; set overwrite: true to replace it." }; - } - if (fileExists && overwrite) return "overwrite"; - return { error: "Error: overwrite: true but the file does not exist." }; + if (!fileExists && !overwrite) return "create"; + if (fileExists && !overwrite) { + return { error: "Error: File already exists; set overwrite: true to replace it." }; + } + if (fileExists && overwrite) return "overwrite"; + return { error: "Error: overwrite: true but the file does not exist." }; } /** Pure: validate and coerce args from the model. */ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record; - - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.length === 0) { - return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; - } - - const rawContent = obj.content; - if (typeof rawContent !== "string") { - return { - error: 'Error: Missing or invalid "content" parameter (must be a string).', - }; - } - - let overwrite = false; - if (obj.overwrite !== undefined) { - if (typeof obj.overwrite !== "boolean") { - return { error: 'Error: Invalid "overwrite" parameter (must be a boolean).' }; - } - overwrite = obj.overwrite; - } - - return { path: rawPath, content: rawContent, overwrite }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' }; + } + + const rawContent = obj.content; + if (typeof rawContent !== "string") { + return { + error: 'Error: Missing or invalid "content" parameter (must be a string).', + }; + } + + let overwrite = false; + if (obj.overwrite !== undefined) { + if (typeof obj.overwrite !== "boolean") { + return { error: 'Error: Invalid "overwrite" parameter (must be a boolean).' }; + } + overwrite = obj.overwrite; + } + + return { path: rawPath, content: rawContent, overwrite }; } /** @@ -62,99 +62,99 @@ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: s * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ export function createWriteFileTool(deps: { - readonly resolveBackend: ExecBackendResolver; - readonly workdir?: string; + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; }): ToolContract { - const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; - - return { - name: "write_file", - description: - "Write a whole file to disk. " + - "By default, creates a new file; errors if it already exists. " + - "Set overwrite: true to replace an existing file (errors if the file does not exist). " + - "Parent directories are NOT auto-created — the parent must already exist.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file, relative to the working directory.", - }, - content: { - type: "string", - description: "The full content to write to the file.", - }, - overwrite: { - type: "boolean", - description: - "When false/unset: creates a new file (errors if it already exists). " + - "When true: replaces an existing file (errors if it does not exist).", - default: false, - }, - }, - required: ["path", "content"], - }, - concurrencySafe: false, - async execute(args: unknown, ctx): Promise { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { path: relPath, content, overwrite } = validated; - - const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; - if (effectiveBase === undefined) { - return { - content: - "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", - isError: true, - }; - } - const resolvedPath = resolve(effectiveBase, relPath); - - const backend: ExecBackend = deps.resolveBackend(ctx.computerId); - - // Check existence. `backend.exists` never throws — it returns false - // when the path is missing — so the old try/catch around `access` - // collapses to a single boolean read. - const fileExists = await backend.exists(resolvedPath); - - // Pure decision. - const decision = decideOverwrite(fileExists, overwrite); - if (typeof decision === "object") { - return { content: decision.error, isError: true }; - } - - // Verify it's not a directory. `backend.stat` returns a - // `{ isFile, isDirectory }` result; only reached when the file - // exists, so an ENOENT here is a lost race left to propagate - // (same as the prior uncaught `stat` call). - if (fileExists) { - const pathStat = await backend.stat(resolvedPath); - if (pathStat.isDirectory) { - return { - content: `Error: "${relPath}" is a directory, not a file.`, - isError: true, - }; - } - } - - // Write the file. LocalExecBackend throws node:fs-style errors - // carrying a `.code` (e.g. ENOENT when the parent dir is missing); - // the catch surfaces the message verbatim. - try { - await backend.writeFile(resolvedPath, content); - } catch (err: unknown) { - return { - content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - const action = decision === "create" ? "Created" : "Overwrote"; - return { content: `${action} "${relPath}" (${content.length} bytes).` }; - }, - }; + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; + + return { + name: "write_file", + description: + "Write a whole file to disk. " + + "By default, creates a new file; errors if it already exists. " + + "Set overwrite: true to replace an existing file (errors if the file does not exist). " + + "Parent directories are NOT auto-created — the parent must already exist.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file, relative to the working directory.", + }, + content: { + type: "string", + description: "The full content to write to the file.", + }, + overwrite: { + type: "boolean", + description: + "When false/unset: creates a new file (errors if it already exists). " + + "When true: replaces an existing file (errors if it does not exist).", + default: false, + }, + }, + required: ["path", "content"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx): Promise { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { path: relPath, content, overwrite } = validated; + + const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } + const resolvedPath = resolve(effectiveBase, relPath); + + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Check existence. `backend.exists` never throws — it returns false + // when the path is missing — so the old try/catch around `access` + // collapses to a single boolean read. + const fileExists = await backend.exists(resolvedPath); + + // Pure decision. + const decision = decideOverwrite(fileExists, overwrite); + if (typeof decision === "object") { + return { content: decision.error, isError: true }; + } + + // Verify it's not a directory. `backend.stat` returns a + // `{ isFile, isDirectory }` result; only reached when the file + // exists, so an ENOENT here is a lost race left to propagate + // (same as the prior uncaught `stat` call). + if (fileExists) { + const pathStat = await backend.stat(resolvedPath); + if (pathStat.isDirectory) { + return { + content: `Error: "${relPath}" is a directory, not a file.`, + isError: true, + }; + } + } + + // Write the file. LocalExecBackend throws node:fs-style errors + // carrying a `.code` (e.g. ENOENT when the parent dir is missing); + // the catch surfaces the message verbatim. + try { + await backend.writeFile(resolvedPath, content); + } catch (err: unknown) { + return { + content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + const action = decision === "create" ? "Created" : "Overwrote"; + return { content: `${action} "${relPath}" (${content.length} bytes).` }; + }, + }; } diff --git a/packages/tool-write-file/tsconfig.json b/packages/tool-write-file/tsconfig.json index 30cdc4d..b790281 100644 --- a/packages/tool-write-file/tsconfig.json +++ b/packages/tool-write-file/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/tool-youtube-transcript/package.json b/packages/tool-youtube-transcript/package.json index efcefda..2e2e4d7 100644 --- a/packages/tool-youtube-transcript/package.json +++ b/packages/tool-youtube-transcript/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/tool-youtube-transcript", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/tool-youtube-transcript", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/tool-youtube-transcript/src/client.test.ts b/packages/tool-youtube-transcript/src/client.test.ts index a33f44a..1a8b7b8 100644 --- a/packages/tool-youtube-transcript/src/client.test.ts +++ b/packages/tool-youtube-transcript/src/client.test.ts @@ -2,133 +2,133 @@ import { describe, expect, it } from "vitest"; import { createTranscriptClient, type FetchLike } from "./client.js"; function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } interface CapturedCall { - url: string; - method?: string | undefined; + url: string; + method?: string | undefined; } /** Builds a fake fetch that returns scripted responses in order, capturing each call. */ function makeFetch(responses: Response[]): { fetchFn: FetchLike; calls: CapturedCall[] } { - const calls: CapturedCall[] = []; - let i = 0; - const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calls.push({ url, method: init?.method }); - return responses[i++] ?? jsonResponse({}); - }) as unknown as FetchLike; - return { fetchFn, calls }; + const calls: CapturedCall[] = []; + let i = 0; + const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push({ url, method: init?.method }); + return responses[i++] ?? jsonResponse({}); + }) as unknown as FetchLike; + return { fetchFn, calls }; } const BASE = "http://test-transcriber.local"; const signal = (): AbortSignal => new AbortController().signal; describe("createTranscriptClient.getTranscript", () => { - it("sends GET /api/transcript?url=...", async () => { - const { fetchFn, calls } = makeFetch([ - jsonResponse({ - status: "completed", - video_id: "v1", - full_text: "", - segments: [], - }), - ]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - await client.getTranscript("https://youtu.be/v1", signal()); + it("sends GET /api/transcript?url=...", async () => { + const { fetchFn, calls } = makeFetch([ + jsonResponse({ + status: "completed", + video_id: "v1", + full_text: "", + segments: [], + }), + ]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + await client.getTranscript("https://youtu.be/v1", signal()); - const call = calls[0]; - if (!call) throw new Error("no call captured"); - expect(call.url).toBe( - `${BASE}/api/transcript?url=${encodeURIComponent("https://youtu.be/v1")}`, - ); - expect(call.method).toBe("GET"); - }); + const call = calls[0]; + if (!call) throw new Error("no call captured"); + expect(call.url).toBe( + `${BASE}/api/transcript?url=${encodeURIComponent("https://youtu.be/v1")}`, + ); + expect(call.method).toBe("GET"); + }); - it("returns completed response", async () => { - const body = { - status: "completed" as const, - video_id: "v1", - full_text: "hi", - segments: [{ text: "hi", start: 0, duration: 1 }], - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns completed response", async () => { + const body = { + status: "completed" as const, + video_id: "v1", + full_text: "hi", + segments: [{ text: "hi", start: 0, duration: 1 }], + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("returns queued response", async () => { - const body = { - status: "queued" as const, - video_id: "v1", - position: 2, - estimated_seconds: 60, - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns queued response", async () => { + const body = { + status: "queued" as const, + video_id: "v1", + position: 2, + estimated_seconds: 60, + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("returns failed response", async () => { - const body = { - status: "failed" as const, - video_id: "v1", - error: "boom", - error_type: "DownloadError", - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns failed response", async () => { + const body = { + status: "failed" as const, + video_id: "v1", + error: "boom", + error_type: "DownloadError", + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("throws on HTTP error", async () => { - const { fetchFn } = makeFetch([ - new Response("not found", { status: 404, statusText: "Not Found" }), - ]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow("HTTP 404"); - }); + it("throws on HTTP error", async () => { + const { fetchFn } = makeFetch([ + new Response("not found", { status: 404, statusText: "Not Found" }), + ]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow("HTTP 404"); + }); - it("throws on timeout", async () => { - const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => - new Promise((_resolve, reject) => { - const sig = init?.signal; - if (!sig) return; - sig.addEventListener("abort", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - reject(err); - }); - })) as unknown as FetchLike; - const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 10 }); - await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow( - "timed out", - ); - }); + it("throws on timeout", async () => { + const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + const sig = init?.signal; + if (!sig) return; + sig.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + })) as unknown as FetchLike; + const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 10 }); + await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow( + "timed out", + ); + }); - it("respects abort signal", async () => { - const controller = new AbortController(); - const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => - new Promise((_resolve, reject) => { - const sig = init?.signal; - if (!sig) return; - sig.addEventListener("abort", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - reject(err); - }); - })) as unknown as FetchLike; - const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 30_000 }); - const promise = client.getTranscript("https://youtu.be/v1", controller.signal); - controller.abort(); - await expect(promise).rejects.toThrow("aborted"); - }); + it("respects abort signal", async () => { + const controller = new AbortController(); + const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + const sig = init?.signal; + if (!sig) return; + sig.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + })) as unknown as FetchLike; + const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 30_000 }); + const promise = client.getTranscript("https://youtu.be/v1", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow("aborted"); + }); }); diff --git a/packages/tool-youtube-transcript/src/client.ts b/packages/tool-youtube-transcript/src/client.ts index a088d7d..576eb3a 100644 --- a/packages/tool-youtube-transcript/src/client.ts +++ b/packages/tool-youtube-transcript/src/client.ts @@ -18,13 +18,13 @@ export const DEFAULT_BASE_URL = "http://100.102.55.49:41090"; export const DEFAULT_TIMEOUT_MS = 30_000; export interface TranscriptClient { - readonly getTranscript: (url: string, signal: AbortSignal) => Promise; + readonly getTranscript: (url: string, signal: AbortSignal) => Promise; } export interface TranscriptClientDeps { - readonly baseUrl: string; - readonly fetchFn: FetchLike; - readonly timeoutMs?: number; + readonly baseUrl: string; + readonly fetchFn: FetchLike; + readonly timeoutMs?: number; } /** @@ -34,47 +34,47 @@ export interface TranscriptClientDeps { * is combined with the caller's cancellation signal via `AbortSignal.any`. */ export function createTranscriptClient(deps: TranscriptClientDeps): TranscriptClient { - const baseUrl = deps.baseUrl; - const fetchFn = deps.fetchFn; - const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const baseUrl = deps.baseUrl; + const fetchFn = deps.fetchFn; + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; - return { - async getTranscript(url: string, signal: AbortSignal): Promise { - const endpoint = `${baseUrl}/api/transcript?url=${encodeURIComponent(url)}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const combined = AbortSignal.any([signal, controller.signal]); - try { - let response: Response; - try { - response = await fetchFn(endpoint, { - method: "GET", - headers: { Accept: "application/json" }, - signal: combined, - }); - } catch (err) { - if (signal.aborted) { - throw new Error("Request aborted."); - } - if (controller.signal.aborted) { - throw new Error(`Transcriber request timed out after ${timeoutMs / 1000} seconds.`); - } - throw err; - } - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, - ); - } - try { - return (await response.json()) as TranscriptResponse; - } catch { - throw new Error("Failed to parse transcriber response as JSON"); - } - } finally { - clearTimeout(timeout); - } - }, - }; + return { + async getTranscript(url: string, signal: AbortSignal): Promise { + const endpoint = `${baseUrl}/api/transcript?url=${encodeURIComponent(url)}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const combined = AbortSignal.any([signal, controller.signal]); + try { + let response: Response; + try { + response = await fetchFn(endpoint, { + method: "GET", + headers: { Accept: "application/json" }, + signal: combined, + }); + } catch (err) { + if (signal.aborted) { + throw new Error("Request aborted."); + } + if (controller.signal.aborted) { + throw new Error(`Transcriber request timed out after ${timeoutMs / 1000} seconds.`); + } + throw err; + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, + ); + } + try { + return (await response.json()) as TranscriptResponse; + } catch { + throw new Error("Failed to parse transcriber response as JSON"); + } + } finally { + clearTimeout(timeout); + } + }, + }; } diff --git a/packages/tool-youtube-transcript/src/extension.test.ts b/packages/tool-youtube-transcript/src/extension.test.ts index 70cf227..7b5c23b 100644 --- a/packages/tool-youtube-transcript/src/extension.test.ts +++ b/packages/tool-youtube-transcript/src/extension.test.ts @@ -3,116 +3,116 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { activate, extension, manifest } from "./extension.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } function makeFakeHost(): { host: HostAPI; defineTool: ReturnType } { - const defineTool = vi.fn(); - const host = { - defineTool, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - span: vi.fn(() => ({ end: vi.fn() })), - }, - } as unknown as HostAPI; - return { host, defineTool }; + const defineTool = vi.fn(); + const host = { + defineTool, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + span: vi.fn(() => ({ end: vi.fn() })), + }, + } as unknown as HostAPI; + return { host, defineTool }; } const ORIG_FETCH = globalThis.fetch; const ORIG_ENV = process.env.YOUTUBE_TRANSCRIBER_URL; function restoreEnv(): void { - if (ORIG_ENV === undefined) { - delete process.env.YOUTUBE_TRANSCRIBER_URL; - } else { - process.env.YOUTUBE_TRANSCRIBER_URL = ORIG_ENV; - } + if (ORIG_ENV === undefined) { + delete process.env.YOUTUBE_TRANSCRIBER_URL; + } else { + process.env.YOUTUBE_TRANSCRIBER_URL = ORIG_ENV; + } } afterEach(() => { - globalThis.fetch = ORIG_FETCH; - restoreEnv(); + globalThis.fetch = ORIG_FETCH; + restoreEnv(); }); function stubFetchCapture(): { calls: Array<{ url: string }> } { - const calls: Array<{ url: string }> = []; - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - calls.push({ url: String(input) }); - return new Response( - JSON.stringify({ - status: "completed", - video_id: "v", - full_text: "", - segments: [], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }) as unknown as typeof globalThis.fetch; - return { calls }; + const calls: Array<{ url: string }> = []; + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + calls.push({ url: String(input) }); + return new Response( + JSON.stringify({ + status: "completed", + video_id: "v", + full_text: "", + segments: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof globalThis.fetch; + return { calls }; } describe("tool-youtube-transcript activation", () => { - it("registers the 'youtube_transcript' tool (defineTool called)", () => { - const { host, defineTool } = makeFakeHost(); - activate(host); - expect(defineTool).toHaveBeenCalledTimes(1); - const registered = defineTool.mock.calls[0]?.[0]; - if (!registered) throw new Error("no tool registered"); - expect(registered.name).toBe("youtube_transcript"); - expect(registered.concurrencySafe).toBe(true); - }); + it("registers the 'youtube_transcript' tool (defineTool called)", () => { + const { host, defineTool } = makeFakeHost(); + activate(host); + expect(defineTool).toHaveBeenCalledTimes(1); + const registered = defineTool.mock.calls[0]?.[0]; + if (!registered) throw new Error("no tool registered"); + expect(registered.name).toBe("youtube_transcript"); + expect(registered.concurrencySafe).toBe(true); + }); - it("uses YOUTUBE_TRANSCRIBER_URL from env", async () => { - process.env.YOUTUBE_TRANSCRIBER_URL = "http://env-transcriber.local"; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses YOUTUBE_TRANSCRIBER_URL from env", async () => { + process.env.YOUTUBE_TRANSCRIBER_URL = "http://env-transcriber.local"; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("http://env-transcriber.local/api/transcript?url="); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("http://env-transcriber.local/api/transcript?url="); + }); - it("uses default base URL when env unset", async () => { - delete process.env.YOUTUBE_TRANSCRIBER_URL; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses default base URL when env unset", async () => { + delete process.env.YOUTUBE_TRANSCRIBER_URL; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("100.102.55.49:41090/api/transcript?url="); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("100.102.55.49:41090/api/transcript?url="); + }); }); describe("tool-youtube-transcript manifest", () => { - it("declares network capability + youtube_transcript contribution", () => { - expect(manifest.id).toBe("tool-youtube-transcript"); - expect(manifest.capabilities).toEqual({ network: true }); - expect(manifest.contributes).toEqual({ tools: ["youtube_transcript"] }); - expect(manifest.trust).toBe("bundled"); - expect(manifest.activation).toBe("eager"); - }); + it("declares network capability + youtube_transcript contribution", () => { + expect(manifest.id).toBe("tool-youtube-transcript"); + expect(manifest.capabilities).toEqual({ network: true }); + expect(manifest.contributes).toEqual({ tools: ["youtube_transcript"] }); + expect(manifest.trust).toBe("bundled"); + expect(manifest.activation).toBe("eager"); + }); - it("extension bundles the manifest + activate", () => { - expect(extension.manifest).toBe(manifest); - expect(typeof extension.activate).toBe("function"); - }); + it("extension bundles the manifest + activate", () => { + expect(extension.manifest).toBe(manifest); + expect(typeof extension.activate).toBe("function"); + }); }); diff --git a/packages/tool-youtube-transcript/src/extension.ts b/packages/tool-youtube-transcript/src/extension.ts index 0669fa5..7c75aa4 100644 --- a/packages/tool-youtube-transcript/src/extension.ts +++ b/packages/tool-youtube-transcript/src/extension.ts @@ -13,20 +13,20 @@ import { createTranscriptClient, DEFAULT_BASE_URL } from "./client.js"; import { createYoutubeTranscriptTool } from "./tool.js"; export const manifest: Manifest = { - id: "tool-youtube-transcript", - name: "YouTube Transcript Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { network: true }, - contributes: { tools: ["youtube_transcript"] }, + id: "tool-youtube-transcript", + name: "YouTube Transcript Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { tools: ["youtube_transcript"] }, }; export function activate(host: HostAPI): void { - const baseUrl = process.env.YOUTUBE_TRANSCRIBER_URL ?? DEFAULT_BASE_URL; - const client = createTranscriptClient({ baseUrl, fetchFn: globalThis.fetch }); - host.defineTool(createYoutubeTranscriptTool({ client })); + const baseUrl = process.env.YOUTUBE_TRANSCRIBER_URL ?? DEFAULT_BASE_URL; + const client = createTranscriptClient({ baseUrl, fetchFn: globalThis.fetch }); + host.defineTool(createYoutubeTranscriptTool({ client })); } export const extension: Extension = { manifest, activate }; diff --git a/packages/tool-youtube-transcript/src/format.test.ts b/packages/tool-youtube-transcript/src/format.test.ts index 79832da..c615839 100644 --- a/packages/tool-youtube-transcript/src/format.test.ts +++ b/packages/tool-youtube-transcript/src/format.test.ts @@ -1,126 +1,126 @@ import { describe, expect, it } from "vitest"; import { - type CompletedResponse, - type FailedResponse, - formatCompleted, - formatFailed, - formatQueued, - formatTimestamp, - type QueuedResponse, - truncateOutput, + type CompletedResponse, + type FailedResponse, + formatCompleted, + formatFailed, + formatQueued, + formatTimestamp, + type QueuedResponse, + truncateOutput, } from "./format.js"; describe("formatTimestamp", () => { - it("formats seconds as m:ss", () => { - expect(formatTimestamp(65)).toBe("1:05"); - expect(formatTimestamp(723)).toBe("12:03"); - }); + it("formats seconds as m:ss", () => { + expect(formatTimestamp(65)).toBe("1:05"); + expect(formatTimestamp(723)).toBe("12:03"); + }); - it("handles zero", () => { - expect(formatTimestamp(0)).toBe("0:00"); - }); + it("handles zero", () => { + expect(formatTimestamp(0)).toBe("0:00"); + }); - it("handles minutes over 60", () => { - // 3700s = 61m40s; 4530s = 75m30s. - expect(formatTimestamp(3700)).toBe("61:40"); - expect(formatTimestamp(4530)).toBe("75:30"); - }); + it("handles minutes over 60", () => { + // 3700s = 61m40s; 4530s = 75m30s. + expect(formatTimestamp(3700)).toBe("61:40"); + expect(formatTimestamp(4530)).toBe("75:30"); + }); }); describe("formatCompleted", () => { - it("formats markdown with full text + segments", () => { - const data: CompletedResponse = { - status: "completed", - video_id: "vid123", - full_text: "Hello world.", - segments: [ - { text: "Hello world.", start: 0, duration: 2.5 }, - { text: "Second line.", start: 65, duration: 1.0 }, - ], - }; - const out = formatCompleted("https://youtu.be/vid123", data); - const expected = [ - "## Transcript for https://youtu.be/vid123", - "**Video ID:** vid123", - "", - "### Full text", - "", - "Hello world.", - "", - "### Timestamped segments", - "", - "[0:00] Hello world.", - "[1:05] Second line.", - ].join("\n"); - expect(out).toBe(expected); - }); + it("formats markdown with full text + segments", () => { + const data: CompletedResponse = { + status: "completed", + video_id: "vid123", + full_text: "Hello world.", + segments: [ + { text: "Hello world.", start: 0, duration: 2.5 }, + { text: "Second line.", start: 65, duration: 1.0 }, + ], + }; + const out = formatCompleted("https://youtu.be/vid123", data); + const expected = [ + "## Transcript for https://youtu.be/vid123", + "**Video ID:** vid123", + "", + "### Full text", + "", + "Hello world.", + "", + "### Timestamped segments", + "", + "[0:00] Hello world.", + "[1:05] Second line.", + ].join("\n"); + expect(out).toBe(expected); + }); }); describe("formatQueued", () => { - it("returns status + position + estimated time", () => { - const data: QueuedResponse = { - status: "queued", - video_id: "vid456", - position: 3, - estimated_seconds: 120, - }; - const now = () => 1_000_000_000_000; - const out = formatQueued("https://youtu.be/vid456", data, now); - const expectedTime = new Date(1_000_000_000_000 + 120_000).toISOString(); - const expected = - `Transcript not yet available (status: queued, queue position: 3).\n` + - `Estimated available at: ${expectedTime} (in ~120s).\n` + - `URL: https://youtu.be/vid456`; - expect(out).toBe(expected); - }); + it("returns status + position + estimated time", () => { + const data: QueuedResponse = { + status: "queued", + video_id: "vid456", + position: 3, + estimated_seconds: 120, + }; + const now = () => 1_000_000_000_000; + const out = formatQueued("https://youtu.be/vid456", data, now); + const expectedTime = new Date(1_000_000_000_000 + 120_000).toISOString(); + const expected = + `Transcript not yet available (status: queued, queue position: 3).\n` + + `Estimated available at: ${expectedTime} (in ~120s).\n` + + `URL: https://youtu.be/vid456`; + expect(out).toBe(expected); + }); - it("includes the processing status", () => { - const data: QueuedResponse = { - status: "processing", - video_id: "vid457", - position: 0, - estimated_seconds: 45.5, - }; - const out = formatQueued("https://youtu.be/vid457", data, () => 0); - expect(out).toContain("status: processing"); - expect(out).toContain("queue position: 0"); - expect(out).toContain("(in ~46s)"); - expect(out).toContain("https://youtu.be/vid457"); - }); + it("includes the processing status", () => { + const data: QueuedResponse = { + status: "processing", + video_id: "vid457", + position: 0, + estimated_seconds: 45.5, + }; + const out = formatQueued("https://youtu.be/vid457", data, () => 0); + expect(out).toContain("status: processing"); + expect(out).toContain("queue position: 0"); + expect(out).toContain("(in ~46s)"); + expect(out).toContain("https://youtu.be/vid457"); + }); }); describe("formatFailed", () => { - it("returns error type + details", () => { - const data: FailedResponse = { - status: "failed", - video_id: "vid789", - error: "Video unavailable", - error_type: "NotFoundError", - }; - expect(formatFailed(data)).toBe( - "Transcript fetch failed. Error type: NotFoundError. Details: Video unavailable", - ); - }); + it("returns error type + details", () => { + const data: FailedResponse = { + status: "failed", + video_id: "vid789", + error: "Video unavailable", + error_type: "NotFoundError", + }; + expect(formatFailed(data)).toBe( + "Transcript fetch failed. Error type: NotFoundError. Details: Video unavailable", + ); + }); }); describe("truncateOutput", () => { - it("truncates with notice when over cap", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50); - expect(result).toContain("a".repeat(50)); - expect(result).toContain("[Output truncated: exceeded 50 characters]"); - expect(result.length).toBeLessThan(output.length + 100); - }); + it("truncates with notice when over cap", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); - it("returns as-is when under cap", () => { - expect(truncateOutput("short", 100)).toBe("short"); - expect(truncateOutput("exact", 5)).toBe("exact"); - }); + it("returns as-is when under cap", () => { + expect(truncateOutput("short", 100)).toBe("short"); + expect(truncateOutput("exact", 5)).toBe("exact"); + }); - it("includes save path in notice when provided", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50, "/tmp/dispatch/vid123.txt"); - expect(result).toContain("/tmp/dispatch/vid123.txt"); - expect(result).toContain("use read_file to access it"); - }); + it("includes save path in notice when provided", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50, "/tmp/dispatch/vid123.txt"); + expect(result).toContain("/tmp/dispatch/vid123.txt"); + expect(result).toContain("use read_file to access it"); + }); }); diff --git a/packages/tool-youtube-transcript/src/format.ts b/packages/tool-youtube-transcript/src/format.ts index 0f3ecc3..23bd39f 100644 --- a/packages/tool-youtube-transcript/src/format.ts +++ b/packages/tool-youtube-transcript/src/format.ts @@ -14,33 +14,33 @@ /** A single timestamped segment from a completed transcript. */ export interface TranscriptSegment { - readonly text: string; - readonly start: number; - readonly duration: number; + readonly text: string; + readonly start: number; + readonly duration: number; } /** `status: "completed"` response from the transcriber service. */ export interface CompletedResponse { - readonly status: "completed"; - readonly video_id: string; - readonly full_text: string; - readonly segments: readonly TranscriptSegment[]; + readonly status: "completed"; + readonly video_id: string; + readonly full_text: string; + readonly segments: readonly TranscriptSegment[]; } /** `status: "queued" | "processing"` response from the transcriber service. */ export interface QueuedResponse { - readonly status: "queued" | "processing"; - readonly video_id: string; - readonly position: number; - readonly estimated_seconds: number; + readonly status: "queued" | "processing"; + readonly video_id: string; + readonly position: number; + readonly estimated_seconds: number; } /** `status: "failed"` response from the transcriber service. */ export interface FailedResponse { - readonly status: "failed"; - readonly video_id: string; - readonly error: string; - readonly error_type: string; + readonly status: "failed"; + readonly video_id: string; + readonly error: string; + readonly error_type: string; } /** Discriminated union of all transcriber response shapes. */ @@ -51,9 +51,9 @@ export type TranscriptResponse = CompletedResponse | QueuedResponse | FailedResp * Minutes are not capped — durations over an hour render as `61:40` etc. */ export function formatTimestamp(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, "0")}`; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; } /** @@ -61,20 +61,20 @@ export function formatTimestamp(seconds: number): string { * timestamped segment lines `[m:ss] text`. Mirrors the opencode tool's layout. */ export function formatCompleted(url: string, data: CompletedResponse): string { - const lines: string[] = []; - lines.push(`## Transcript for ${url}`); - lines.push(`**Video ID:** ${data.video_id}`); - lines.push(""); - lines.push("### Full text"); - lines.push(""); - lines.push(data.full_text); - lines.push(""); - lines.push("### Timestamped segments"); - lines.push(""); - for (const segment of data.segments) { - lines.push(`[${formatTimestamp(segment.start)}] ${segment.text}`); - } - return lines.join("\n"); + const lines: string[] = []; + lines.push(`## Transcript for ${url}`); + lines.push(`**Video ID:** ${data.video_id}`); + lines.push(""); + lines.push("### Full text"); + lines.push(""); + lines.push(data.full_text); + lines.push(""); + lines.push("### Timestamped segments"); + lines.push(""); + for (const segment of data.segments) { + lines.push(`[${formatTimestamp(segment.start)}] ${segment.text}`); + } + return lines.join("\n"); } /** @@ -82,18 +82,18 @@ export function formatCompleted(url: string, data: CompletedResponse): string { * estimated available-at time (ISO, derived from the injected `now`). */ export function formatQueued(url: string, data: QueuedResponse, now: () => number): string { - const availableAt = new Date(now() + data.estimated_seconds * 1000); - const timeStr = availableAt.toISOString(); - return ( - `Transcript not yet available (status: ${data.status}, queue position: ${data.position}).\n` + - `Estimated available at: ${timeStr} (in ~${Math.ceil(data.estimated_seconds)}s).\n` + - `URL: ${url}` - ); + const availableAt = new Date(now() + data.estimated_seconds * 1000); + const timeStr = availableAt.toISOString(); + return ( + `Transcript not yet available (status: ${data.status}, queue position: ${data.position}).\n` + + `Estimated available at: ${timeStr} (in ~${Math.ceil(data.estimated_seconds)}s).\n` + + `URL: ${url}` + ); } /** Format a failed response: error type + details. Mirrors the opencode tool. */ export function formatFailed(data: FailedResponse): string { - return `Transcript fetch failed. Error type: ${data.error_type}. Details: ${data.error}`; + return `Transcript fetch failed. Error type: ${data.error_type}. Details: ${data.error}`; } /** @@ -102,13 +102,13 @@ export function formatFailed(data: FailedResponse): string { * Duplication across features is the intended trade (isolation over DRY). */ export function truncateOutput(output: string, cap: number, savePath?: string): string { - if (output.length <= cap) { - return output; - } - const truncated = output.slice(0, cap); - const notice = - savePath !== undefined - ? `\n\n[Output truncated: exceeded ${cap} characters. Full transcript saved to ${savePath} — use read_file to access it.]` - : `\n\n[Output truncated: exceeded ${cap} characters]`; - return `${truncated}${notice}`; + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + const notice = + savePath !== undefined + ? `\n\n[Output truncated: exceeded ${cap} characters. Full transcript saved to ${savePath} — use read_file to access it.]` + : `\n\n[Output truncated: exceeded ${cap} characters]`; + return `${truncated}${notice}`; } diff --git a/packages/tool-youtube-transcript/src/index.ts b/packages/tool-youtube-transcript/src/index.ts index 4fec6e4..859ba61 100644 --- a/packages/tool-youtube-transcript/src/index.ts +++ b/packages/tool-youtube-transcript/src/index.ts @@ -1,23 +1,23 @@ export { - createTranscriptClient, - DEFAULT_BASE_URL, - DEFAULT_TIMEOUT_MS, - type FetchLike, - type TranscriptClient, - type TranscriptClientDeps, + createTranscriptClient, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + type FetchLike, + type TranscriptClient, + type TranscriptClientDeps, } from "./client.js"; export { activate, extension, manifest } from "./extension.js"; export { - type CompletedResponse, - type FailedResponse, - formatCompleted, - formatFailed, - formatQueued, - formatTimestamp, - type QueuedResponse, - type TranscriptResponse, - type TranscriptSegment, - truncateOutput, + type CompletedResponse, + type FailedResponse, + formatCompleted, + formatFailed, + formatQueued, + formatTimestamp, + type QueuedResponse, + type TranscriptResponse, + type TranscriptSegment, + truncateOutput, } from "./format.js"; export { createYoutubeTranscriptTool, type YoutubeTranscriptToolDeps } from "./tool.js"; export { type ValidationError, validateUrl } from "./validate.js"; diff --git a/packages/tool-youtube-transcript/src/tool.test.ts b/packages/tool-youtube-transcript/src/tool.test.ts index 7cdfd0e..1f52d22 100644 --- a/packages/tool-youtube-transcript/src/tool.test.ts +++ b/packages/tool-youtube-transcript/src/tool.test.ts @@ -5,156 +5,156 @@ import type { TranscriptResponse } from "./format.js"; import { createYoutubeTranscriptTool } from "./tool.js"; function stubCtx(overrides?: Partial): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } function makeStubClient( - responder: (url: string, signal: AbortSignal) => Promise, + responder: (url: string, signal: AbortSignal) => Promise, ): TranscriptClient { - return { getTranscript: (url, signal) => responder(url, signal) }; + return { getTranscript: (url, signal) => responder(url, signal) }; } describe("youtube_transcript", () => { - it("returns formatted transcript on completed", async () => { - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid1", - full_text: "Hello world.", - segments: [{ text: "Hello world.", start: 0, duration: 2 }], - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("## Transcript for https://youtu.be/vid1"); - expect(result.content).toContain("**Video ID:** vid1"); - expect(result.content).toContain("Hello world."); - expect(result.content).toContain("[0:00] Hello world."); - }); + it("returns formatted transcript on completed", async () => { + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid1", + full_text: "Hello world.", + segments: [{ text: "Hello world.", start: 0, duration: 2 }], + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("## Transcript for https://youtu.be/vid1"); + expect(result.content).toContain("**Video ID:** vid1"); + expect(result.content).toContain("Hello world."); + expect(result.content).toContain("[0:00] Hello world."); + }); - it("returns queued message with status and ETA", async () => { - const client = makeStubClient(async () => ({ - status: "queued", - video_id: "vid2", - position: 1, - estimated_seconds: 30, - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid2" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("status: queued"); - expect(result.content).toContain("queue position: 1"); - expect(result.content).toContain("in ~30s"); - expect(result.content).toContain("https://youtu.be/vid2"); - }); + it("returns queued message with status and ETA", async () => { + const client = makeStubClient(async () => ({ + status: "queued", + video_id: "vid2", + position: 1, + estimated_seconds: 30, + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid2" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("status: queued"); + expect(result.content).toContain("queue position: 1"); + expect(result.content).toContain("in ~30s"); + expect(result.content).toContain("https://youtu.be/vid2"); + }); - it("returns failed message", async () => { - const client = makeStubClient(async () => ({ - status: "failed", - video_id: "vid3", - error: "Video unavailable", - error_type: "NotFoundError", - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid3" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("Error type: NotFoundError"); - expect(result.content).toContain("Details: Video unavailable"); - }); + it("returns failed message", async () => { + const client = makeStubClient(async () => ({ + status: "failed", + video_id: "vid3", + error: "Video unavailable", + error_type: "NotFoundError", + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid3" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("Error type: NotFoundError"); + expect(result.content).toContain("Details: Video unavailable"); + }); - it("validation error returns isError", async () => { - const client = makeStubClient(async () => { - throw new Error("should not be called"); - }); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("url"); - }); + it("validation error returns isError", async () => { + const client = makeStubClient(async () => { + throw new Error("should not be called"); + }); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("url"); + }); - it("uses conversationId from ctx (not required but passed through)", async () => { - const records: LogRecord[] = []; - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid4", - full_text: "text", - segments: [], - })); - const tool = createYoutubeTranscriptTool({ client }); - const ctx = stubCtx({ - conversationId: "conv-xyz", - log: createLogger( - { extensionId: "test", conversationId: "conv-xyz" }, - { - emit: (r) => { - records.push(r); - }, - }, - { now: () => 0, newId: () => "id" }, - ), - }); - const result = await tool.execute({ url: "https://youtu.be/vid4" }, ctx); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("## Transcript for"); - // The execute span flows through ctx.log, which carries conversationId. - const spanOpen = records.find((r) => r.kind === "span-open"); - expect(spanOpen).toBeDefined(); - expect(spanOpen?.conversationId).toBe("conv-xyz"); - }); + it("uses conversationId from ctx (not required but passed through)", async () => { + const records: LogRecord[] = []; + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid4", + full_text: "text", + segments: [], + })); + const tool = createYoutubeTranscriptTool({ client }); + const ctx = stubCtx({ + conversationId: "conv-xyz", + log: createLogger( + { extensionId: "test", conversationId: "conv-xyz" }, + { + emit: (r) => { + records.push(r); + }, + }, + { now: () => 0, newId: () => "id" }, + ), + }); + const result = await tool.execute({ url: "https://youtu.be/vid4" }, ctx); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("## Transcript for"); + // The execute span flows through ctx.log, which carries conversationId. + const spanOpen = records.find((r) => r.kind === "span-open"); + expect(spanOpen).toBeDefined(); + expect(spanOpen?.conversationId).toBe("conv-xyz"); + }); - it("writes full transcript to /tmp/dispatch/youtube-transcribe/{video_id}.txt when truncated", async () => { - const longText = "x".repeat(60_000); - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid5", - full_text: longText, - segments: [], - })); - let writtenPath = ""; - let writtenContent = ""; - const tool = createYoutubeTranscriptTool({ - client, - outputCap: 1000, - writeFile: (path, content) => { - writtenPath = path; - writtenContent = content; - }, - }); - const result = await tool.execute({ url: "https://youtu.be/vid5" }, stubCtx()); - expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid5.txt"); - expect(writtenContent).toContain("x".repeat(60_000)); - expect(result.content).toContain("/tmp/dispatch/youtube-transcribe/vid5.txt"); - expect(result.content).toContain("use read_file to access it"); - expect(result.content.length).toBeLessThan(writtenContent.length); - }); + it("writes full transcript to /tmp/dispatch/youtube-transcribe/{video_id}.txt when truncated", async () => { + const longText = "x".repeat(60_000); + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid5", + full_text: longText, + segments: [], + })); + let writtenPath = ""; + let writtenContent = ""; + const tool = createYoutubeTranscriptTool({ + client, + outputCap: 1000, + writeFile: (path, content) => { + writtenPath = path; + writtenContent = content; + }, + }); + const result = await tool.execute({ url: "https://youtu.be/vid5" }, stubCtx()); + expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid5.txt"); + expect(writtenContent).toContain("x".repeat(60_000)); + expect(result.content).toContain("/tmp/dispatch/youtube-transcribe/vid5.txt"); + expect(result.content).toContain("use read_file to access it"); + expect(result.content.length).toBeLessThan(writtenContent.length); + }); - it("writes transcript to file even when not truncated", async () => { - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid6", - full_text: "short transcript", - segments: [{ text: "short transcript", start: 0, duration: 2 }], - })); - let writtenPath = ""; - let writtenContent = ""; - const tool = createYoutubeTranscriptTool({ - client, - writeFile: (path, content) => { - writtenPath = path; - writtenContent = content; - }, - }); - const result = await tool.execute({ url: "https://youtu.be/vid6" }, stubCtx()); - expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid6.txt"); - expect(writtenContent).toBe(result.content); - }); + it("writes transcript to file even when not truncated", async () => { + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid6", + full_text: "short transcript", + segments: [{ text: "short transcript", start: 0, duration: 2 }], + })); + let writtenPath = ""; + let writtenContent = ""; + const tool = createYoutubeTranscriptTool({ + client, + writeFile: (path, content) => { + writtenPath = path; + writtenContent = content; + }, + }); + const result = await tool.execute({ url: "https://youtu.be/vid6" }, stubCtx()); + expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid6.txt"); + expect(writtenContent).toBe(result.content); + }); }); diff --git a/packages/tool-youtube-transcript/src/tool.ts b/packages/tool-youtube-transcript/src/tool.ts index a11f739..b24a6c5 100644 --- a/packages/tool-youtube-transcript/src/tool.ts +++ b/packages/tool-youtube-transcript/src/tool.ts @@ -11,11 +11,11 @@ import { mkdirSync, writeFileSync } from "node:fs"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import type { TranscriptClient } from "./client.js"; import { - formatCompleted, - formatFailed, - formatQueued, - type TranscriptResponse, - truncateOutput, + formatCompleted, + formatFailed, + formatQueued, + type TranscriptResponse, + truncateOutput, } from "./format.js"; import { validateUrl } from "./validate.js"; @@ -23,21 +23,21 @@ const OUTPUT_CAP = 50_000; const FULL_OUTPUT_DIR = "/tmp/dispatch/youtube-transcribe"; export interface YoutubeTranscriptToolDeps { - readonly client: TranscriptClient; - readonly outputCap?: number; - /** Injected file writer (defaults to real fs write). */ - readonly writeFile?: (path: string, content: string) => void; + readonly client: TranscriptClient; + readonly outputCap?: number; + /** Injected file writer (defaults to real fs write). */ + readonly writeFile?: (path: string, content: string) => void; } const DESCRIPTION = - "Fetch the transcript/subtitles for a YouTube video from the local transcriber " + - "service. If the transcript has not been downloaded before, the video will be " + - "queued for processing and the tool will return the estimated time when the " + - "transcript will be available. Once available, the tool returns the transcript " + - "text and timestamped segments (truncated if very long). The full transcript " + - "is always saved to /tmp/dispatch/youtube-transcribe/{video_id}.txt — use " + - "read_file to access it. Accepted URL formats: " + - "youtube.com/watch?v=, youtu.be/, youtube.com/embed/, youtube.com/shorts/"; + "Fetch the transcript/subtitles for a YouTube video from the local transcriber " + + "service. If the transcript has not been downloaded before, the video will be " + + "queued for processing and the tool will return the estimated time when the " + + "transcript will be available. Once available, the tool returns the transcript " + + "text and timestamped segments (truncated if very long). The full transcript " + + "is always saved to /tmp/dispatch/youtube-transcribe/{video_id}.txt — use " + + "read_file to access it. Accepted URL formats: " + + "youtube.com/watch?v=, youtu.be/, youtube.com/embed/, youtube.com/shorts/"; /** * Create the `youtube_transcript` tool. `concurrencySafe: true` — transcript @@ -45,73 +45,73 @@ const DESCRIPTION = * capability is declared on the extension manifest (not the tool contract). */ export function createYoutubeTranscriptTool(deps: YoutubeTranscriptToolDeps): ToolContract { - const client = deps.client; - const cap = deps.outputCap ?? OUTPUT_CAP; - const writeFile = - deps.writeFile ?? - ((path, content) => { - mkdirSync(FULL_OUTPUT_DIR, { recursive: true }); - writeFileSync(path, content, "utf-8"); - }); + const client = deps.client; + const cap = deps.outputCap ?? OUTPUT_CAP; + const writeFile = + deps.writeFile ?? + ((path, content) => { + mkdirSync(FULL_OUTPUT_DIR, { recursive: true }); + writeFileSync(path, content, "utf-8"); + }); - return { - name: "youtube_transcript", - description: DESCRIPTION, - parameters: { - type: "object", - properties: { - url: { - type: "string", - description: - "YouTube video URL (e.g. https://www.youtube.com/watch?v=... or https://youtu.be/...)", - }, - }, - required: ["url"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise { - const validated = validateUrl(args); - if (typeof validated !== "string") { - return { content: validated.error, isError: true }; - } - const url = validated; - const span = ctx.log.span("youtube_transcript.execute", { url }); - try { - const data: TranscriptResponse = await client.getTranscript(url, ctx.signal); - let output: string; - let videoId: string | undefined; - // Check the single-literal discriminants ("completed"/"failed") first, - // so the final else narrows to QueuedResponse — whose `status` is itself - // a `"queued" | "processing"` union TS cannot negatively narrow. - if (data.status === "completed") { - output = formatCompleted(url, data); - videoId = data.video_id; - } else if (data.status === "failed") { - output = formatFailed(data); - } else { - output = formatQueued(url, data, Date.now); - } - span.end(); + return { + name: "youtube_transcript", + description: DESCRIPTION, + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: + "YouTube video URL (e.g. https://www.youtube.com/watch?v=... or https://youtu.be/...)", + }, + }, + required: ["url"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise { + const validated = validateUrl(args); + if (typeof validated !== "string") { + return { content: validated.error, isError: true }; + } + const url = validated; + const span = ctx.log.span("youtube_transcript.execute", { url }); + try { + const data: TranscriptResponse = await client.getTranscript(url, ctx.signal); + let output: string; + let videoId: string | undefined; + // Check the single-literal discriminants ("completed"/"failed") first, + // so the final else narrows to QueuedResponse — whose `status` is itself + // a `"queued" | "processing"` union TS cannot negatively narrow. + if (data.status === "completed") { + output = formatCompleted(url, data); + videoId = data.video_id; + } else if (data.status === "failed") { + output = formatFailed(data); + } else { + output = formatQueued(url, data, Date.now); + } + span.end(); - if (videoId !== undefined) { - const filePath = `${FULL_OUTPUT_DIR}/${videoId}.txt`; - try { - writeFile(filePath, output); - } catch { - // File write failed — continue with truncated output only. - } - if (output.length > cap) { - return { content: truncateOutput(output, cap, filePath) }; - } - } - return { content: truncateOutput(output, cap) }; - } catch (err: unknown) { - span.end({ err }); - return { - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - }, - }; + if (videoId !== undefined) { + const filePath = `${FULL_OUTPUT_DIR}/${videoId}.txt`; + try { + writeFile(filePath, output); + } catch { + // File write failed — continue with truncated output only. + } + if (output.length > cap) { + return { content: truncateOutput(output, cap, filePath) }; + } + } + return { content: truncateOutput(output, cap) }; + } catch (err: unknown) { + span.end({ err }); + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; } diff --git a/packages/tool-youtube-transcript/src/validate.test.ts b/packages/tool-youtube-transcript/src/validate.test.ts index 3181bb1..7bd70d9 100644 --- a/packages/tool-youtube-transcript/src/validate.test.ts +++ b/packages/tool-youtube-transcript/src/validate.test.ts @@ -2,34 +2,34 @@ import { describe, expect, it } from "vitest"; import { validateUrl } from "./validate.js"; describe("validateUrl", () => { - it("accepts valid URL", () => { - const result = validateUrl({ url: "https://www.youtube.com/watch?v=abc123" }); - expect(result).toBe("https://www.youtube.com/watch?v=abc123"); - }); + it("accepts valid URL", () => { + const result = validateUrl({ url: "https://www.youtube.com/watch?v=abc123" }); + expect(result).toBe("https://www.youtube.com/watch?v=abc123"); + }); - it("rejects missing url", () => { - const result = validateUrl({ query: "no url here" }); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("url"); - } - }); + it("rejects missing url", () => { + const result = validateUrl({ query: "no url here" }); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("url"); + } + }); - it("rejects empty url", () => { - const empty = validateUrl({ url: "" }); - expect(typeof empty).toBe("object"); - if (typeof empty === "object") { - expect(empty.error).toContain("url"); - } - const whitespace = validateUrl({ url: " " }); - expect(typeof whitespace).toBe("object"); - }); + it("rejects empty url", () => { + const empty = validateUrl({ url: "" }); + expect(typeof empty).toBe("object"); + if (typeof empty === "object") { + expect(empty.error).toContain("url"); + } + const whitespace = validateUrl({ url: " " }); + expect(typeof whitespace).toBe("object"); + }); - it("rejects null/non-object args", () => { - expect(typeof validateUrl(null)).toBe("object"); - expect(typeof validateUrl(undefined)).toBe("object"); - expect(typeof validateUrl("string")).toBe("object"); - expect(typeof validateUrl(42)).toBe("object"); - expect(typeof validateUrl(true)).toBe("object"); - }); + it("rejects null/non-object args", () => { + expect(typeof validateUrl(null)).toBe("object"); + expect(typeof validateUrl(undefined)).toBe("object"); + expect(typeof validateUrl("string")).toBe("object"); + expect(typeof validateUrl(42)).toBe("object"); + expect(typeof validateUrl(true)).toBe("object"); + }); }); diff --git a/packages/tool-youtube-transcript/src/validate.ts b/packages/tool-youtube-transcript/src/validate.ts index 3a9d919..040dc55 100644 --- a/packages/tool-youtube-transcript/src/validate.ts +++ b/packages/tool-youtube-transcript/src/validate.ts @@ -15,16 +15,16 @@ export type ValidationError = { readonly error: string }; * input — the tool surfaces the message verbatim. */ export function validateUrl(args: unknown): string | ValidationError { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object with a 'url' string." }; - } - const obj = args as Record; - const raw = obj.url; - if (typeof raw !== "string") { - return { error: "Error: 'url' is required and must be a string." }; - } - if (raw.trim().length === 0) { - return { error: "Error: 'url' must not be empty." }; - } - return raw; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object with a 'url' string." }; + } + const obj = args as Record; + const raw = obj.url; + if (typeof raw !== "string") { + return { error: "Error: 'url' is required and must be a string." }; + } + if (raw.trim().length === 0) { + return { error: "Error: 'url' must not be empty." }; + } + return raw; } diff --git a/packages/tool-youtube-transcript/tsconfig.json b/packages/tool-youtube-transcript/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/tool-youtube-transcript/tsconfig.json +++ b/packages/tool-youtube-transcript/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/trace-replay/package.json b/packages/trace-replay/package.json index 9717cd4..5318363 100644 --- a/packages/trace-replay/package.json +++ b/packages/trace-replay/package.json @@ -1,8 +1,8 @@ { - "name": "@dispatch/trace-replay", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts" + "name": "@dispatch/trace-replay", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts" } diff --git a/packages/trace-replay/src/fixture.test.ts b/packages/trace-replay/src/fixture.test.ts index cca6585..47ce08b 100644 --- a/packages/trace-replay/src/fixture.test.ts +++ b/packages/trace-replay/src/fixture.test.ts @@ -6,161 +6,161 @@ import { loadFixture, parseFixture, saveFixture, serializeFixture } from "./fixt import type { HttpExchangeFixture } from "./types.js"; const fixture: HttpExchangeFixture = { - request: { - method: "POST", - url: "https://api.example.com/v1/chat", - headers: { "content-type": "application/json" }, - body: '{"model":"gpt-4"}', - }, - response: { - status: 200, - statusText: "OK", - headers: { "content-type": "application/json" }, - body: '{"choices":[]}', - }, - meta: { model: "gpt-4", capturedAt: 1700000000000, label: null }, + request: { + method: "POST", + url: "https://api.example.com/v1/chat", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-4"}', + }, + response: { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + body: '{"choices":[]}', + }, + meta: { model: "gpt-4", capturedAt: 1700000000000, label: null }, }; const minimalFixture: HttpExchangeFixture = { - request: { method: "GET", url: "https://x", headers: {}, body: null }, - response: { status: 204, headers: {}, body: "" }, + request: { method: "GET", url: "https://x", headers: {}, body: null }, + response: { status: 204, headers: {}, body: "" }, }; describe("serializeFixture", () => { - it("produces valid JSON", () => { - const json = serializeFixture(fixture); - expect(() => JSON.parse(json)).not.toThrow(); - }); - - it("round-trips through parseFixture", () => { - const json = serializeFixture(fixture); - const parsed = parseFixture(json); - expect(parsed).toEqual(fixture); - }); - - it("round-trips minimal fixture", () => { - const json = serializeFixture(minimalFixture); - const parsed = parseFixture(json); - expect(parsed).toEqual(minimalFixture); - }); - - it("produces pretty-printed JSON with trailing newline", () => { - const json = serializeFixture(fixture); - expect(json).toContain("\n"); - expect(json.endsWith("\n")).toBe(true); - }); + it("produces valid JSON", () => { + const json = serializeFixture(fixture); + expect(() => JSON.parse(json)).not.toThrow(); + }); + + it("round-trips through parseFixture", () => { + const json = serializeFixture(fixture); + const parsed = parseFixture(json); + expect(parsed).toEqual(fixture); + }); + + it("round-trips minimal fixture", () => { + const json = serializeFixture(minimalFixture); + const parsed = parseFixture(json); + expect(parsed).toEqual(minimalFixture); + }); + + it("produces pretty-printed JSON with trailing newline", () => { + const json = serializeFixture(fixture); + expect(json).toContain("\n"); + expect(json.endsWith("\n")).toBe(true); + }); }); describe("parseFixture", () => { - it("throws on invalid JSON", () => { - expect(() => parseFixture("not json")).toThrow("Invalid JSON"); - }); - - it("throws on non-object", () => { - expect(() => parseFixture('"hello"')).toThrow("Fixture must be an object"); - }); - - it("throws on null", () => { - expect(() => parseFixture("null")).toThrow("Fixture must be an object"); - }); - - it("throws on missing request", () => { - expect(() => parseFixture('{"response":{"status":200,"headers":{},"body":""}}')).toThrow( - "request", - ); - }); - - it("throws on missing response", () => { - expect(() => - parseFixture('{"request":{"method":"GET","url":"x","headers":{},"body":null}}'), - ).toThrow("response"); - }); - - it("throws on non-string request.method", () => { - const bad = { - request: { method: 123, url: "x", headers: {}, body: null }, - response: { status: 200, headers: {}, body: "" }, - }; - expect(() => parseFixture(JSON.stringify(bad))).toThrow("request.method must be a string"); - }); - - it("throws on non-number response.status", () => { - const bad = { - request: { method: "GET", url: "x", headers: {}, body: null }, - response: { status: "200", headers: {}, body: "" }, - }; - expect(() => parseFixture(JSON.stringify(bad))).toThrow("response.status must be a number"); - }); - - it("throws on non-string response.body", () => { - const bad = { - request: { method: "GET", url: "x", headers: {}, body: null }, - response: { status: 200, headers: {}, body: 123 }, - }; - expect(() => parseFixture(JSON.stringify(bad))).toThrow("response.body must be a string"); - }); - - it("throws on non-string header value", () => { - const bad = { - request: { method: "GET", url: "x", headers: { bad: 123 }, body: null }, - response: { status: 200, headers: {}, body: "" }, - }; - expect(() => parseFixture(JSON.stringify(bad))).toThrow("headers.bad must be a string"); - }); - - it("throws on invalid meta value", () => { - const bad = { - request: { method: "GET", url: "x", headers: {}, body: null }, - response: { status: 200, headers: {}, body: "" }, - meta: { bad: {} }, - }; - expect(() => parseFixture(JSON.stringify(bad))).toThrow("meta.bad must be"); - }); - - it("accepts valid meta with null/string/number/boolean", () => { - const withMeta = { - ...minimalFixture, - meta: { a: "s", b: 1, c: true, d: null }, - }; - const parsed = parseFixture(JSON.stringify(withMeta)); - expect(parsed.meta).toEqual({ a: "s", b: 1, c: true, d: null }); - }); - - it("accepts fixture without meta", () => { - const parsed = parseFixture(JSON.stringify(minimalFixture)); - expect(parsed.meta).toBeUndefined(); - }); + it("throws on invalid JSON", () => { + expect(() => parseFixture("not json")).toThrow("Invalid JSON"); + }); + + it("throws on non-object", () => { + expect(() => parseFixture('"hello"')).toThrow("Fixture must be an object"); + }); + + it("throws on null", () => { + expect(() => parseFixture("null")).toThrow("Fixture must be an object"); + }); + + it("throws on missing request", () => { + expect(() => parseFixture('{"response":{"status":200,"headers":{},"body":""}}')).toThrow( + "request", + ); + }); + + it("throws on missing response", () => { + expect(() => + parseFixture('{"request":{"method":"GET","url":"x","headers":{},"body":null}}'), + ).toThrow("response"); + }); + + it("throws on non-string request.method", () => { + const bad = { + request: { method: 123, url: "x", headers: {}, body: null }, + response: { status: 200, headers: {}, body: "" }, + }; + expect(() => parseFixture(JSON.stringify(bad))).toThrow("request.method must be a string"); + }); + + it("throws on non-number response.status", () => { + const bad = { + request: { method: "GET", url: "x", headers: {}, body: null }, + response: { status: "200", headers: {}, body: "" }, + }; + expect(() => parseFixture(JSON.stringify(bad))).toThrow("response.status must be a number"); + }); + + it("throws on non-string response.body", () => { + const bad = { + request: { method: "GET", url: "x", headers: {}, body: null }, + response: { status: 200, headers: {}, body: 123 }, + }; + expect(() => parseFixture(JSON.stringify(bad))).toThrow("response.body must be a string"); + }); + + it("throws on non-string header value", () => { + const bad = { + request: { method: "GET", url: "x", headers: { bad: 123 }, body: null }, + response: { status: 200, headers: {}, body: "" }, + }; + expect(() => parseFixture(JSON.stringify(bad))).toThrow("headers.bad must be a string"); + }); + + it("throws on invalid meta value", () => { + const bad = { + request: { method: "GET", url: "x", headers: {}, body: null }, + response: { status: 200, headers: {}, body: "" }, + meta: { bad: {} }, + }; + expect(() => parseFixture(JSON.stringify(bad))).toThrow("meta.bad must be"); + }); + + it("accepts valid meta with null/string/number/boolean", () => { + const withMeta = { + ...minimalFixture, + meta: { a: "s", b: 1, c: true, d: null }, + }; + const parsed = parseFixture(JSON.stringify(withMeta)); + expect(parsed.meta).toEqual({ a: "s", b: 1, c: true, d: null }); + }); + + it("accepts fixture without meta", () => { + const parsed = parseFixture(JSON.stringify(minimalFixture)); + expect(parsed.meta).toBeUndefined(); + }); }); let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), "trace-replay-test-")); + tmpDir = await mkdtemp(join(tmpdir(), "trace-replay-test-")); }); afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); + await rm(tmpDir, { recursive: true, force: true }); }); describe("saveFixture / loadFixture", () => { - it("writes and reads back a fixture (real fs)", () => { - const path = join(tmpDir, "test-fixture.json"); - saveFixture(path, fixture); - const loaded = loadFixture(path); - expect(loaded).toEqual(fixture); - }); - - it("writes and reads back a minimal fixture", () => { - const path = join(tmpDir, "minimal.json"); - saveFixture(path, minimalFixture); - const loaded = loadFixture(path); - expect(loaded).toEqual(minimalFixture); - }); - - it("loadFixture throws on malformed file", () => { - const path = join(tmpDir, "bad.json"); - const { writeFileSync } = require("node:fs") as typeof import("node:fs"); - writeFileSync(path, "not json", "utf-8"); - expect(() => loadFixture(path)).toThrow("Invalid JSON"); - }); + it("writes and reads back a fixture (real fs)", () => { + const path = join(tmpDir, "test-fixture.json"); + saveFixture(path, fixture); + const loaded = loadFixture(path); + expect(loaded).toEqual(fixture); + }); + + it("writes and reads back a minimal fixture", () => { + const path = join(tmpDir, "minimal.json"); + saveFixture(path, minimalFixture); + const loaded = loadFixture(path); + expect(loaded).toEqual(minimalFixture); + }); + + it("loadFixture throws on malformed file", () => { + const path = join(tmpDir, "bad.json"); + const { writeFileSync } = require("node:fs") as typeof import("node:fs"); + writeFileSync(path, "not json", "utf-8"); + expect(() => loadFixture(path)).toThrow("Invalid JSON"); + }); }); diff --git a/packages/trace-replay/src/fixture.ts b/packages/trace-replay/src/fixture.ts index 423185d..cd3345e 100644 --- a/packages/trace-replay/src/fixture.ts +++ b/packages/trace-replay/src/fixture.ts @@ -2,76 +2,76 @@ import { readFileSync, writeFileSync } from "node:fs"; import type { HttpExchangeFixture } from "./types.js"; export function serializeFixture(fx: HttpExchangeFixture): string { - return `${JSON.stringify(fx, null, 2)}\n`; + return `${JSON.stringify(fx, null, 2)}\n`; } export function parseFixture(text: string): HttpExchangeFixture { - let raw: unknown; - try { - raw = JSON.parse(text); - } catch { - throw new Error("Invalid JSON"); - } - assertFixture(raw); - return raw; + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new Error("Invalid JSON"); + } + assertFixture(raw); + return raw; } function assertFixture(raw: unknown): asserts raw is HttpExchangeFixture { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new Error("Fixture must be an object"); - } - const obj = raw as Record; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Fixture must be an object"); + } + const obj = raw as Record; - if (obj.request === null || typeof obj.request !== "object" || Array.isArray(obj.request)) { - throw new Error("Fixture.request must be an object"); - } - const req = obj.request as Record; - if (typeof req.method !== "string") throw new Error("Fixture.request.method must be a string"); - if (typeof req.url !== "string") throw new Error("Fixture.request.url must be a string"); - assertStringRecord(req.headers, "Fixture.request.headers"); - if (req.body !== null && typeof req.body !== "string") { - throw new Error("Fixture.request.body must be a string or null"); - } + if (obj.request === null || typeof obj.request !== "object" || Array.isArray(obj.request)) { + throw new Error("Fixture.request must be an object"); + } + const req = obj.request as Record; + if (typeof req.method !== "string") throw new Error("Fixture.request.method must be a string"); + if (typeof req.url !== "string") throw new Error("Fixture.request.url must be a string"); + assertStringRecord(req.headers, "Fixture.request.headers"); + if (req.body !== null && typeof req.body !== "string") { + throw new Error("Fixture.request.body must be a string or null"); + } - if (obj.response === null || typeof obj.response !== "object" || Array.isArray(obj.response)) { - throw new Error("Fixture.response must be an object"); - } - const res = obj.response as Record; - if (typeof res.status !== "number") throw new Error("Fixture.response.status must be a number"); - if (res.statusText !== undefined && typeof res.statusText !== "string") { - throw new Error("Fixture.response.statusText must be a string if present"); - } - assertStringRecord(res.headers, "Fixture.response.headers"); - if (typeof res.body !== "string") throw new Error("Fixture.response.body must be a string"); + if (obj.response === null || typeof obj.response !== "object" || Array.isArray(obj.response)) { + throw new Error("Fixture.response must be an object"); + } + const res = obj.response as Record; + if (typeof res.status !== "number") throw new Error("Fixture.response.status must be a number"); + if (res.statusText !== undefined && typeof res.statusText !== "string") { + throw new Error("Fixture.response.statusText must be a string if present"); + } + assertStringRecord(res.headers, "Fixture.response.headers"); + if (typeof res.body !== "string") throw new Error("Fixture.response.body must be a string"); - if (obj.meta !== undefined) { - if (obj.meta === null || typeof obj.meta !== "object" || Array.isArray(obj.meta)) { - throw new Error("Fixture.meta must be an object if present"); - } - const meta = obj.meta as Record; - for (const [k, v] of Object.entries(meta)) { - if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") { - throw new Error(`Fixture.meta.${k} must be a string, number, boolean, or null`); - } - } - } + if (obj.meta !== undefined) { + if (obj.meta === null || typeof obj.meta !== "object" || Array.isArray(obj.meta)) { + throw new Error("Fixture.meta must be an object if present"); + } + const meta = obj.meta as Record; + for (const [k, v] of Object.entries(meta)) { + if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") { + throw new Error(`Fixture.meta.${k} must be a string, number, boolean, or null`); + } + } + } } function assertStringRecord(val: unknown, label: string): asserts val is Record { - if (val === null || typeof val !== "object" || Array.isArray(val)) { - throw new Error(`${label} must be an object`); - } - for (const [k, v] of Object.entries(val as Record)) { - if (typeof v !== "string") { - throw new Error(`${label}.${k} must be a string`); - } - } + if (val === null || typeof val !== "object" || Array.isArray(val)) { + throw new Error(`${label} must be an object`); + } + for (const [k, v] of Object.entries(val as Record)) { + if (typeof v !== "string") { + throw new Error(`${label}.${k} must be a string`); + } + } } export function saveFixture(path: string, fx: HttpExchangeFixture): void { - writeFileSync(path, serializeFixture(fx), "utf-8"); + writeFileSync(path, serializeFixture(fx), "utf-8"); } export function loadFixture(path: string): HttpExchangeFixture { - return parseFixture(readFileSync(path, "utf-8")); + return parseFixture(readFileSync(path, "utf-8")); } diff --git a/packages/trace-replay/src/record.test.ts b/packages/trace-replay/src/record.test.ts index af9dbd0..38261f4 100644 --- a/packages/trace-replay/src/record.test.ts +++ b/packages/trace-replay/src/record.test.ts @@ -3,110 +3,110 @@ import { recordFetch } from "./record.js"; import type { FetchLike, HttpExchangeFixture } from "./types.js"; function fakeResponse(body: string, init?: ResponseInit): Response { - return new Response(body, { - status: 200, - statusText: "OK", - headers: { "content-type": "application/json" }, - ...init, - }); + return new Response(body, { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + ...init, + }); } describe("recordFetch", () => { - it("onExchange receives fixture matching request+response", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse('{"ok":true}'); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - await recorded("https://api.example.com/v1/chat", { - method: "POST", - headers: { "content-type": "application/json" }, - body: '{"prompt":"hi"}', - }); - - expect(fixtures).toHaveLength(1); - const fx = fixtures[0] as HttpExchangeFixture; - expect(fx.request.method).toBe("POST"); - expect(fx.request.url).toBe("https://api.example.com/v1/chat"); - expect(fx.request.headers["content-type"]).toBe("application/json"); - expect(fx.request.body).toBe('{"prompt":"hi"}'); - expect(fx.response.status).toBe(200); - expect(fx.response.statusText).toBe("OK"); - expect(fx.response.headers["content-type"]).toBe("application/json"); - expect(fx.response.body).toBe('{"ok":true}'); - }); - - it("response returned to caller is still fully readable after recording", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse("full body content"); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - const res = await recorded("https://x"); - expect(await res.text()).toBe("full body content"); - }); - - it("response body can be read even after fixture is captured", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse("stream data"); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - const res = await recorded("https://x"); - expect(fixtures[0]?.response.body).toBe("stream data"); - expect(await res.text()).toBe("stream data"); - }); - - it("captures Request object input with method", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse("ok"); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - const req = new Request("https://from-req.com/path", { method: "PUT" }); - await recorded(req); - - expect(fixtures[0]?.request.url).toBe("https://from-req.com/path"); - expect(fixtures[0]?.request.method).toBe("PUT"); - }); - - it("defaults method to GET", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse("ok"); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - await recorded("https://x"); - expect(fixtures[0]?.request.method).toBe("GET"); - }); - - it("captures null request body", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => fakeResponse("ok"); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - await recorded("https://x", { method: "GET" }); - expect(fixtures[0]?.request.body).toBeNull(); - }); - - it("captures response error status", async () => { - const fixtures: HttpExchangeFixture[] = []; - const fakeFetch: FetchLike = async () => - new Response('{"error":"bad request"}', { - status: 400, - statusText: "Bad Request", - }); - const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); - - const res = await recorded("https://x"); - expect(fixtures[0]?.response.status).toBe(400); - expect(res.status).toBe(400); - }); - - it("passes through to real fetch", async () => { - let called = false; - const fakeFetch: FetchLike = async () => { - called = true; - return new Response("from fake", { status: 201 }); - }; - const recorded = recordFetch(fakeFetch, () => {}); - const res = await recorded("https://x"); - expect(called).toBe(true); - expect(res.status).toBe(201); - }); + it("onExchange receives fixture matching request+response", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse('{"ok":true}'); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + await recorded("https://api.example.com/v1/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"prompt":"hi"}', + }); + + expect(fixtures).toHaveLength(1); + const fx = fixtures[0] as HttpExchangeFixture; + expect(fx.request.method).toBe("POST"); + expect(fx.request.url).toBe("https://api.example.com/v1/chat"); + expect(fx.request.headers["content-type"]).toBe("application/json"); + expect(fx.request.body).toBe('{"prompt":"hi"}'); + expect(fx.response.status).toBe(200); + expect(fx.response.statusText).toBe("OK"); + expect(fx.response.headers["content-type"]).toBe("application/json"); + expect(fx.response.body).toBe('{"ok":true}'); + }); + + it("response returned to caller is still fully readable after recording", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse("full body content"); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + const res = await recorded("https://x"); + expect(await res.text()).toBe("full body content"); + }); + + it("response body can be read even after fixture is captured", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse("stream data"); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + const res = await recorded("https://x"); + expect(fixtures[0]?.response.body).toBe("stream data"); + expect(await res.text()).toBe("stream data"); + }); + + it("captures Request object input with method", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse("ok"); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + const req = new Request("https://from-req.com/path", { method: "PUT" }); + await recorded(req); + + expect(fixtures[0]?.request.url).toBe("https://from-req.com/path"); + expect(fixtures[0]?.request.method).toBe("PUT"); + }); + + it("defaults method to GET", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse("ok"); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + await recorded("https://x"); + expect(fixtures[0]?.request.method).toBe("GET"); + }); + + it("captures null request body", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => fakeResponse("ok"); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + await recorded("https://x", { method: "GET" }); + expect(fixtures[0]?.request.body).toBeNull(); + }); + + it("captures response error status", async () => { + const fixtures: HttpExchangeFixture[] = []; + const fakeFetch: FetchLike = async () => + new Response('{"error":"bad request"}', { + status: 400, + statusText: "Bad Request", + }); + const recorded = recordFetch(fakeFetch, (fx) => fixtures.push(fx)); + + const res = await recorded("https://x"); + expect(fixtures[0]?.response.status).toBe(400); + expect(res.status).toBe(400); + }); + + it("passes through to real fetch", async () => { + let called = false; + const fakeFetch: FetchLike = async () => { + called = true; + return new Response("from fake", { status: 201 }); + }; + const recorded = recordFetch(fakeFetch, () => {}); + const res = await recorded("https://x"); + expect(called).toBe(true); + expect(res.status).toBe(201); + }); }); diff --git a/packages/trace-replay/src/record.ts b/packages/trace-replay/src/record.ts index f145457..c469ede 100644 --- a/packages/trace-replay/src/record.ts +++ b/packages/trace-replay/src/record.ts @@ -3,111 +3,111 @@ import type { FetchLike, HttpExchangeFixture } from "./types.js"; export type { FetchLike } from "./types.js"; export function recordFetch( - realFetch: FetchLike, - onExchange: (fx: HttpExchangeFixture) => void, + realFetch: FetchLike, + onExchange: (fx: HttpExchangeFixture) => void, ): FetchLike { - return async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = extractMethod(input, init); - const headers = normalizeHeaders( - init?.headers as Record | Headers | [string, string][] | undefined, - ); - const reqBody = - init?.body != null - ? await readBody( - init.body as - | string - | Blob - | ArrayBuffer - | ArrayBufferView - | URLSearchParams - | ReadableStream, - ) - : null; + return async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = extractMethod(input, init); + const headers = normalizeHeaders( + init?.headers as Record | Headers | [string, string][] | undefined, + ); + const reqBody = + init?.body != null + ? await readBody( + init.body as + | string + | Blob + | ArrayBuffer + | ArrayBufferView + | URLSearchParams + | ReadableStream, + ) + : null; - const response = await realFetch(input, init); - const responseClone = response.clone(); - const responseBody = await responseClone.text(); + const response = await realFetch(input, init); + const responseClone = response.clone(); + const responseBody = await responseClone.text(); - const fixture: HttpExchangeFixture = { - request: { method, url, headers, body: reqBody }, - response: { - status: response.status, - statusText: response.statusText, - headers: normalizeResponseHeaders(response.headers), - body: responseBody, - }, - }; + const fixture: HttpExchangeFixture = { + request: { method, url, headers, body: reqBody }, + response: { + status: response.status, + statusText: response.statusText, + headers: normalizeResponseHeaders(response.headers), + body: responseBody, + }, + }; - onExchange(fixture); - return response; - }; + onExchange(fixture); + return response; + }; } function extractMethod(input: string | URL | Request, init: RequestInit | undefined): string { - if (init?.method) return init.method; - if (typeof input !== "string" && !(input instanceof URL) && "method" in input) { - return input.method; - } - return "GET"; + if (init?.method) return init.method; + if (typeof input !== "string" && !(input instanceof URL) && "method" in input) { + return input.method; + } + return "GET"; } function normalizeHeaders( - raw: Headers | Record | [string, string][] | undefined, + raw: Headers | Record | [string, string][] | undefined, ): Record { - if (!raw) return {}; - if (raw instanceof Headers) { - const out: Record = {}; - raw.forEach((v, k) => { - out[k] = v; - }); - return out; - } - if (Array.isArray(raw)) { - const out: Record = {}; - for (const [k, v] of raw) out[k] = v; - return out; - } - return { ...raw }; + if (!raw) return {}; + if (raw instanceof Headers) { + const out: Record = {}; + raw.forEach((v, k) => { + out[k] = v; + }); + return out; + } + if (Array.isArray(raw)) { + const out: Record = {}; + for (const [k, v] of raw) out[k] = v; + return out; + } + return { ...raw }; } function normalizeResponseHeaders(headers: Headers): Record { - const out: Record = {}; - headers.forEach((v, k) => { - out[k] = v; - }); - return out; + const out: Record = {}; + headers.forEach((v, k) => { + out[k] = v; + }); + return out; } async function readBody( - body: string | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream, + body: string | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream, ): Promise { - if (typeof body === "string") return body; - if (body instanceof Blob) return body.text(); - if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); - if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body.buffer); - if (body instanceof URLSearchParams) return body.toString(); - if (body instanceof ReadableStream) { - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - return new TextDecoder().decode(concatUint8Arrays(chunks)); - } - return null; + if (typeof body === "string") return body; + if (body instanceof Blob) return body.text(); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); + if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body.buffer); + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof ReadableStream) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + return new TextDecoder().decode(concatUint8Arrays(chunks)); + } + return null; } function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { - let totalLen = 0; - for (const a of arrays) totalLen += a.byteLength; - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.byteLength; - } - return out; + let totalLen = 0; + for (const a of arrays) totalLen += a.byteLength; + const out = new Uint8Array(totalLen); + let offset = 0; + for (const a of arrays) { + out.set(a, offset); + offset += a.byteLength; + } + return out; } diff --git a/packages/trace-replay/src/replay.test.ts b/packages/trace-replay/src/replay.test.ts index 6745523..10180a0 100644 --- a/packages/trace-replay/src/replay.test.ts +++ b/packages/trace-replay/src/replay.test.ts @@ -3,134 +3,134 @@ import { replayFetch } from "./replay.js"; import type { HttpExchangeFixture } from "./types.js"; const fixture: HttpExchangeFixture = { - request: { - method: "POST", - url: "https://api.example.com/v1/chat", - headers: { "content-type": "application/json", authorization: "Bearer sk-abc" }, - body: '{"model":"gpt-4"}', - }, - response: { - status: 200, - statusText: "OK", - headers: { "content-type": "application/json", "x-request-id": "req-123" }, - body: '{"choices":[{"text":"hello"}]}', - }, - meta: { model: "gpt-4", capturedAt: 1700000000000 }, + request: { + method: "POST", + url: "https://api.example.com/v1/chat", + headers: { "content-type": "application/json", authorization: "Bearer sk-abc" }, + body: '{"model":"gpt-4"}', + }, + response: { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json", "x-request-id": "req-123" }, + body: '{"choices":[{"text":"hello"}]}', + }, + meta: { model: "gpt-4", capturedAt: 1700000000000 }, }; describe("replayFetch", () => { - it("returns a Response with fixture status/statusText/headers", async () => { - const { fetch } = replayFetch(fixture); - const res = await fetch("https://api.example.com/v1/chat", { method: "POST" }); - expect(res.status).toBe(200); - expect(res.statusText).toBe("OK"); - expect(res.headers.get("content-type")).toBe("application/json"); - expect(res.headers.get("x-request-id")).toBe("req-123"); - }); + it("returns a Response with fixture status/statusText/headers", async () => { + const { fetch } = replayFetch(fixture); + const res = await fetch("https://api.example.com/v1/chat", { method: "POST" }); + expect(res.status).toBe(200); + expect(res.statusText).toBe("OK"); + expect(res.headers.get("content-type")).toBe("application/json"); + expect(res.headers.get("x-request-id")).toBe("req-123"); + }); - it("body reads exactly fixture.response.body", async () => { - const { fetch } = replayFetch(fixture); - const res = await fetch("https://api.example.com/v1/chat"); - expect(await res.text()).toBe('{"choices":[{"text":"hello"}]}'); - }); + it("body reads exactly fixture.response.body", async () => { + const { fetch } = replayFetch(fixture); + const res = await fetch("https://api.example.com/v1/chat"); + expect(await res.text()).toBe('{"choices":[{"text":"hello"}]}'); + }); - it("chunkBytes splits body into correct number of chunks and reassembles", async () => { - const body = "abcdefghij"; // 10 bytes - const fx: HttpExchangeFixture = { - ...fixture, - response: { ...fixture.response, body }, - }; - const { fetch } = replayFetch(fx, { chunkBytes: 3 }); - const res = await fetch("https://x"); - const responseBody = res.body; - if (!responseBody) throw new Error("body is null"); + it("chunkBytes splits body into correct number of chunks and reassembles", async () => { + const body = "abcdefghij"; // 10 bytes + const fx: HttpExchangeFixture = { + ...fixture, + response: { ...fixture.response, body }, + }; + const { fetch } = replayFetch(fx, { chunkBytes: 3 }); + const res = await fetch("https://x"); + const responseBody = res.body; + if (!responseBody) throw new Error("body is null"); - const reader = responseBody.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - expect(chunks).toHaveLength(4); // 3+3+3+1 + const reader = responseBody.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + expect(chunks).toHaveLength(4); // 3+3+3+1 - let reassembled = ""; - for (const c of chunks) reassembled += new TextDecoder().decode(c); - expect(reassembled).toBe("abcdefghij"); - }); + let reassembled = ""; + for (const c of chunks) reassembled += new TextDecoder().decode(c); + expect(reassembled).toBe("abcdefghij"); + }); - it("getCapturedRequest returns undefined before first call", () => { - const { getCapturedRequest } = replayFetch(fixture); - expect(getCapturedRequest()).toBeUndefined(); - }); + it("getCapturedRequest returns undefined before first call", () => { + const { getCapturedRequest } = replayFetch(fixture); + expect(getCapturedRequest()).toBeUndefined(); + }); - it("getCapturedRequest returns the request after fetch", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - await fetch("https://api.example.com/v1/chat", { - method: "POST", - headers: { "content-type": "application/json" }, - body: '{"prompt":"hi"}', - }); - const captured = getCapturedRequest(); - expect(captured).toBeDefined(); - expect(captured?.method).toBe("POST"); - expect(captured?.url).toBe("https://api.example.com/v1/chat"); - expect(captured?.headers["content-type"]).toBe("application/json"); - expect(captured?.body).toBe('{"prompt":"hi"}'); - }); + it("getCapturedRequest returns the request after fetch", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + await fetch("https://api.example.com/v1/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"prompt":"hi"}', + }); + const captured = getCapturedRequest(); + expect(captured).toBeDefined(); + expect(captured?.method).toBe("POST"); + expect(captured?.url).toBe("https://api.example.com/v1/chat"); + expect(captured?.headers["content-type"]).toBe("application/json"); + expect(captured?.body).toBe('{"prompt":"hi"}'); + }); - it("getCapturedRequest updates on subsequent calls", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - await fetch("https://first", { method: "GET" }); - expect(getCapturedRequest()?.url).toBe("https://first"); - await fetch("https://second", { method: "PUT" }); - expect(getCapturedRequest()?.url).toBe("https://second"); - expect(getCapturedRequest()?.method).toBe("PUT"); - }); + it("getCapturedRequest updates on subsequent calls", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + await fetch("https://first", { method: "GET" }); + expect(getCapturedRequest()?.url).toBe("https://first"); + await fetch("https://second", { method: "PUT" }); + expect(getCapturedRequest()?.url).toBe("https://second"); + expect(getCapturedRequest()?.method).toBe("PUT"); + }); - it("captures URL and method from Request object input", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - const req = new Request("https://from-request.com/path", { method: "DELETE" }); - await fetch(req); - expect(getCapturedRequest()?.url).toBe("https://from-request.com/path"); - expect(getCapturedRequest()?.method).toBe("DELETE"); - }); + it("captures URL and method from Request object input", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + const req = new Request("https://from-request.com/path", { method: "DELETE" }); + await fetch(req); + expect(getCapturedRequest()?.url).toBe("https://from-request.com/path"); + expect(getCapturedRequest()?.method).toBe("DELETE"); + }); - it("defaults method to GET when no init", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - await fetch("https://x"); - expect(getCapturedRequest()?.method).toBe("GET"); - }); + it("defaults method to GET when no init", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + await fetch("https://x"); + expect(getCapturedRequest()?.method).toBe("GET"); + }); - it("handles null request body", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - await fetch("https://x", { method: "GET" }); - expect(getCapturedRequest()?.body).toBeNull(); - }); + it("handles null request body", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + await fetch("https://x", { method: "GET" }); + expect(getCapturedRequest()?.body).toBeNull(); + }); - it("handles headers as array of tuples", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - await fetch("https://x", { - headers: [["x-custom", "val"]], - }); - expect(getCapturedRequest()?.headers["x-custom"]).toBe("val"); - }); + it("handles headers as array of tuples", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + await fetch("https://x", { + headers: [["x-custom", "val"]], + }); + expect(getCapturedRequest()?.headers["x-custom"]).toBe("val"); + }); - it("handles headers as Headers object", async () => { - const { fetch, getCapturedRequest } = replayFetch(fixture); - const h = new Headers({ "x-h": "v" }); - await fetch("https://x", { headers: h }); - expect(getCapturedRequest()?.headers["x-h"]).toBe("v"); - }); + it("handles headers as Headers object", async () => { + const { fetch, getCapturedRequest } = replayFetch(fixture); + const h = new Headers({ "x-h": "v" }); + await fetch("https://x", { headers: h }); + expect(getCapturedRequest()?.headers["x-h"]).toBe("v"); + }); - it("works with minimal response (no statusText, status 204, empty body)", async () => { - const fx: HttpExchangeFixture = { - request: { method: "GET", url: "https://x", headers: {}, body: null }, - response: { status: 204, headers: {}, body: "" }, - }; - const { fetch } = replayFetch(fx); - const res = await fetch("https://x"); - expect(res.status).toBe(204); - expect(await res.text()).toBe(""); - }); + it("works with minimal response (no statusText, status 204, empty body)", async () => { + const fx: HttpExchangeFixture = { + request: { method: "GET", url: "https://x", headers: {}, body: null }, + response: { status: 204, headers: {}, body: "" }, + }; + const { fetch } = replayFetch(fx); + const res = await fetch("https://x"); + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); + }); }); diff --git a/packages/trace-replay/src/replay.ts b/packages/trace-replay/src/replay.ts index 84e95b9..820df53 100644 --- a/packages/trace-replay/src/replay.ts +++ b/packages/trace-replay/src/replay.ts @@ -1,144 +1,144 @@ import type { CapturedRequest, FetchLike, HttpExchangeFixture } from "./types.js"; export interface ReplayOptions { - chunkBytes?: number; + chunkBytes?: number; } export interface ReplayResult { - fetch: FetchLike; - getCapturedRequest: () => CapturedRequest | undefined; + fetch: FetchLike; + getCapturedRequest: () => CapturedRequest | undefined; } export function replayFetch(fixture: HttpExchangeFixture, opts?: ReplayOptions): ReplayResult { - let captured: CapturedRequest | undefined; + let captured: CapturedRequest | undefined; - const replay: FetchLike = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const method = extractMethod(input, init); - const headers = normalizeHeaders( - init?.headers as Record | Headers | [string, string][] | undefined, - ); - const body = - init?.body != null - ? await readBody( - init.body as - | string - | Blob - | ArrayBuffer - | ArrayBufferView - | URLSearchParams - | ReadableStream, - ) - : null; + const replay: FetchLike = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = extractMethod(input, init); + const headers = normalizeHeaders( + init?.headers as Record | Headers | [string, string][] | undefined, + ); + const body = + init?.body != null + ? await readBody( + init.body as + | string + | Blob + | ArrayBuffer + | ArrayBufferView + | URLSearchParams + | ReadableStream, + ) + : null; - captured = { method, url, headers, body }; + captured = { method, url, headers, body }; - const isNullBodyStatus = - fixture.response.status === 204 || - fixture.response.status === 205 || - fixture.response.status === 304; - const responseBody = isNullBodyStatus - ? null - : buildBodyStream(fixture.response.body, opts?.chunkBytes); - const responseInit: { status: number; headers: Record; statusText?: string } = { - status: fixture.response.status, - headers: fixture.response.headers, - }; - if (fixture.response.statusText !== undefined) { - responseInit.statusText = fixture.response.statusText; - } - return new Response(responseBody, responseInit); - }; + const isNullBodyStatus = + fixture.response.status === 204 || + fixture.response.status === 205 || + fixture.response.status === 304; + const responseBody = isNullBodyStatus + ? null + : buildBodyStream(fixture.response.body, opts?.chunkBytes); + const responseInit: { status: number; headers: Record; statusText?: string } = { + status: fixture.response.status, + headers: fixture.response.headers, + }; + if (fixture.response.statusText !== undefined) { + responseInit.statusText = fixture.response.statusText; + } + return new Response(responseBody, responseInit); + }; - return { - fetch: replay, - getCapturedRequest: () => captured, - }; + return { + fetch: replay, + getCapturedRequest: () => captured, + }; } function extractMethod(input: string | URL | Request, init: RequestInit | undefined): string { - if (init?.method) return init.method; - if (typeof input !== "string" && !(input instanceof URL) && "method" in input) { - return input.method; - } - return "GET"; + if (init?.method) return init.method; + if (typeof input !== "string" && !(input instanceof URL) && "method" in input) { + return input.method; + } + return "GET"; } function normalizeHeaders( - raw: Headers | Record | [string, string][] | undefined, + raw: Headers | Record | [string, string][] | undefined, ): Record { - if (!raw) return {}; - if (raw instanceof Headers) { - const out: Record = {}; - raw.forEach((v, k) => { - out[k] = v; - }); - return out; - } - if (Array.isArray(raw)) { - const out: Record = {}; - for (const [k, v] of raw) out[k] = v; - return out; - } - return { ...raw }; + if (!raw) return {}; + if (raw instanceof Headers) { + const out: Record = {}; + raw.forEach((v, k) => { + out[k] = v; + }); + return out; + } + if (Array.isArray(raw)) { + const out: Record = {}; + for (const [k, v] of raw) out[k] = v; + return out; + } + return { ...raw }; } async function readBody( - body: string | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream, + body: string | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream, ): Promise { - if (typeof body === "string") return body; - if (body instanceof Blob) return body.text(); - if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); - if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body.buffer); - if (body instanceof URLSearchParams) return body.toString(); - if (body instanceof ReadableStream) { - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - return new TextDecoder().decode(concatUint8Arrays(chunks)); - } - return null; + if (typeof body === "string") return body; + if (body instanceof Blob) return body.text(); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); + if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body.buffer); + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof ReadableStream) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + return new TextDecoder().decode(concatUint8Arrays(chunks)); + } + return null; } function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { - let totalLen = 0; - for (const a of arrays) totalLen += a.byteLength; - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.byteLength; - } - return out; + let totalLen = 0; + for (const a of arrays) totalLen += a.byteLength; + const out = new Uint8Array(totalLen); + let offset = 0; + for (const a of arrays) { + out.set(a, offset); + offset += a.byteLength; + } + return out; } function buildBodyStream(body: string, chunkBytes: number | undefined): ReadableStream { - const encoder = new TextEncoder(); - const encoded = encoder.encode(body); + const encoder = new TextEncoder(); + const encoded = encoder.encode(body); - if (!chunkBytes || chunkBytes <= 0 || chunkBytes >= encoded.byteLength) { - return new ReadableStream({ - start(controller) { - controller.enqueue(encoded); - controller.close(); - }, - }); - } + if (!chunkBytes || chunkBytes <= 0 || chunkBytes >= encoded.byteLength) { + return new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }); + } - let offset = 0; - return new ReadableStream({ - pull(controller) { - if (offset >= encoded.byteLength) { - controller.close(); - return; - } - const end = Math.min(offset + chunkBytes, encoded.byteLength); - controller.enqueue(encoded.slice(offset, end)); - offset = end; - }, - }); + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= encoded.byteLength) { + controller.close(); + return; + } + const end = Math.min(offset + chunkBytes, encoded.byteLength); + controller.enqueue(encoded.slice(offset, end)); + offset = end; + }, + }); } diff --git a/packages/trace-replay/src/types.ts b/packages/trace-replay/src/types.ts index 235b41b..0bcea1f 100644 --- a/packages/trace-replay/src/types.ts +++ b/packages/trace-replay/src/types.ts @@ -1,24 +1,24 @@ export interface HttpExchangeFixture { - readonly request: { - readonly method: string; - readonly url: string; - readonly headers: Record; - readonly body: string | null; - }; - readonly response: { - readonly status: number; - readonly statusText?: string; - readonly headers: Record; - readonly body: string; - }; - readonly meta?: Record; + readonly request: { + readonly method: string; + readonly url: string; + readonly headers: Record; + readonly body: string | null; + }; + readonly response: { + readonly status: number; + readonly statusText?: string; + readonly headers: Record; + readonly body: string; + }; + readonly meta?: Record; } export interface CapturedRequest { - method: string; - url: string; - headers: Record; - body: string | null; + method: string; + url: string; + headers: Record; + body: string | null; } export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; diff --git a/packages/trace-replay/tsconfig.json b/packages/trace-replay/tsconfig.json index 0937214..b7571be 100644 --- a/packages/trace-replay/tsconfig.json +++ b/packages/trace-replay/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [] } diff --git a/packages/trace-store/package.json b/packages/trace-store/package.json index 9809efd..7a97841 100644 --- a/packages/trace-store/package.json +++ b/packages/trace-store/package.json @@ -1,11 +1,11 @@ { - "name": "@dispatch/trace-store", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*" - } + "name": "@dispatch/trace-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } } diff --git a/packages/trace-store/src/cli.ts b/packages/trace-store/src/cli.ts index 9092c91..80a5c8b 100644 --- a/packages/trace-store/src/cli.ts +++ b/packages/trace-store/src/cli.ts @@ -2,15 +2,15 @@ import { createTraceStore } from "./store.js"; const turnId = process.argv[2]; if (turnId === undefined) { - console.error("Usage: bun packages/trace-store/src/cli.ts [dbPath]"); - process.exit(1); + console.error("Usage: bun packages/trace-store/src/cli.ts [dbPath]"); + process.exit(1); } const dbPath = process.argv[3] ?? process.env.TRACE_DB_PATH ?? "./.dispatch-data/traces.db"; const store = createTraceStore({ path: dbPath }); try { - const output = store.easyView(turnId); - console.log(output); + const output = store.easyView(turnId); + console.log(output); } finally { - store.close(); + store.close(); } diff --git a/packages/trace-store/src/easy-view.test.ts b/packages/trace-store/src/easy-view.test.ts index 3ebc814..07e0316 100644 --- a/packages/trace-store/src/easy-view.test.ts +++ b/packages/trace-store/src/easy-view.test.ts @@ -3,358 +3,358 @@ import { describe, expect, it } from "vitest"; import { formatDuration, renderEasyView } from "./easy-view.js"; describe("renderEasyView", () => { - it("returns empty string for empty records", () => { - expect(renderEasyView([])).toBe(""); - }); + it("returns empty string for empty records", () => { + expect(renderEasyView([])).toBe(""); + }); - it("renders a single span with no children", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "span-close", - spanId: "s1", - name: "step", - timestamp: 2800, - durationMs: 1800, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe("- step 1.8s"); - }); + it("renders a single span with no children", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "span-close", + spanId: "s1", + name: "step", + timestamp: 2800, + durationMs: 1800, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe("- step 1.8s"); + }); - it("renders a span with nested log records", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "log", - level: "info", - msg: "thinking...", - timestamp: 1200, - extensionId: "ext", - turnId: "t1", - parentSpanId: "s1", - }, - { - kind: "span-close", - spanId: "s1", - name: "step", - timestamp: 2800, - durationMs: 1800, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - const expected = ["- step 1.8s", " - thinking..."].join("\n"); - expect(renderEasyView(records)).toBe(expected); - }); + it("renders a span with nested log records", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "log", + level: "info", + msg: "thinking...", + timestamp: 1200, + extensionId: "ext", + turnId: "t1", + parentSpanId: "s1", + }, + { + kind: "span-close", + spanId: "s1", + name: "step", + timestamp: 2800, + durationMs: 1800, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + const expected = ["- step 1.8s", " - thinking..."].join("\n"); + expect(renderEasyView(records)).toBe(expected); + }); - it("renders nested spans with indentation", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "parent", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "span-open", - spanId: "child", - name: "tool.call", - timestamp: 1100, - extensionId: "ext", - turnId: "t1", - parentSpanId: "parent", - }, - { - kind: "log", - level: "info", - msg: "executing", - timestamp: 1200, - extensionId: "ext", - turnId: "t1", - parentSpanId: "child", - }, - { - kind: "span-close", - spanId: "child", - name: "tool.call", - timestamp: 1500, - durationMs: 400, - status: "ok", - extensionId: "ext", - turnId: "t1", - parentSpanId: "parent", - }, - { - kind: "span-close", - spanId: "parent", - name: "step", - timestamp: 2800, - durationMs: 1800, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - const expected = ["- step 1.8s", " - tool.call 400ms", " - executing"].join("\n"); - expect(renderEasyView(records)).toBe(expected); - }); + it("renders nested spans with indentation", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "parent", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "span-open", + spanId: "child", + name: "tool.call", + timestamp: 1100, + extensionId: "ext", + turnId: "t1", + parentSpanId: "parent", + }, + { + kind: "log", + level: "info", + msg: "executing", + timestamp: 1200, + extensionId: "ext", + turnId: "t1", + parentSpanId: "child", + }, + { + kind: "span-close", + spanId: "child", + name: "tool.call", + timestamp: 1500, + durationMs: 400, + status: "ok", + extensionId: "ext", + turnId: "t1", + parentSpanId: "parent", + }, + { + kind: "span-close", + spanId: "parent", + name: "step", + timestamp: 2800, + durationMs: 1800, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + const expected = ["- step 1.8s", " - tool.call 400ms", " - executing"].join("\n"); + expect(renderEasyView(records)).toBe(expected); + }); - it("renders a span-open without matching span-close as (open)", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s-open", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe("- step (open)"); - }); + it("renders a span-open without matching span-close as (open)", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s-open", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe("- step (open)"); + }); - it("renders root-level log records without a parent span", () => { - const records: LogRecord[] = [ - { - kind: "log", - level: "info", - msg: "top-level", - timestamp: 500, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "log", - level: "warn", - msg: "a warning", - timestamp: 600, - extensionId: "ext", - turnId: "t1", - }, - ]; - const expected = ["- top-level", "- [warn] a warning"].join("\n"); - expect(renderEasyView(records)).toBe(expected); - }); + it("renders root-level log records without a parent span", () => { + const records: LogRecord[] = [ + { + kind: "log", + level: "info", + msg: "top-level", + timestamp: 500, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "log", + level: "warn", + msg: "a warning", + timestamp: 600, + extensionId: "ext", + turnId: "t1", + }, + ]; + const expected = ["- top-level", "- [warn] a warning"].join("\n"); + expect(renderEasyView(records)).toBe(expected); + }); - it("skips span-close records without a matching span-open", () => { - const records: LogRecord[] = [ - { - kind: "span-close", - spanId: "orphan", - name: "orphan", - timestamp: 1000, - durationMs: 100, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - { - kind: "log", - level: "info", - msg: "real", - timestamp: 2000, - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe("- real"); - }); + it("skips span-close records without a matching span-open", () => { + const records: LogRecord[] = [ + { + kind: "span-close", + spanId: "orphan", + name: "orphan", + timestamp: 1000, + durationMs: 100, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + { + kind: "log", + level: "info", + msg: "real", + timestamp: 2000, + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe("- real"); + }); - it("renders span with ERR status", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "failing-step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "span-close", - spanId: "s1", - name: "failing-step", - timestamp: 1100, - durationMs: 100, - status: "error", - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe("- failing-step 100ms ERR"); - }); + it("renders span with ERR status", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "failing-step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "span-close", + spanId: "s1", + name: "failing-step", + timestamp: 1100, + durationMs: 100, + status: "error", + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe("- failing-step 100ms ERR"); + }); - it("renders body hint when body is present", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "prompt", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: "x".repeat(2100), - }, - { - kind: "span-close", - spanId: "s1", - name: "prompt", - timestamp: 1100, - durationMs: 100, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe("- prompt 100ms [body 2.1k]"); - }); + it("renders body hint when body is present", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "prompt", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: "x".repeat(2100), + }, + { + kind: "span-close", + spanId: "s1", + name: "prompt", + timestamp: 1100, + durationMs: 100, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe("- prompt 100ms [body 2.1k]"); + }); - it("renders log record body hint", () => { - const records: LogRecord[] = [ - { - kind: "log", - level: "debug", - msg: "payload", - timestamp: 500, - extensionId: "ext", - turnId: "t1", - body: "abc", - }, - ]; - expect(renderEasyView(records)).toBe("- [debug] payload [body 3b]"); - }); + it("renders log record body hint", () => { + const records: LogRecord[] = [ + { + kind: "log", + level: "debug", + msg: "payload", + timestamp: 500, + extensionId: "ext", + turnId: "t1", + body: "abc", + }, + ]; + expect(renderEasyView(records)).toBe("- [debug] payload [body 3b]"); + }); - it("renders attributes on span (up to 3)", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "provider.request", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - attributes: { model: "gpt-4", method: "POST", status: 200 }, - }, - { - kind: "span-close", - spanId: "s1", - name: "provider.request", - timestamp: 1500, - durationMs: 500, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - const output = renderEasyView(records); - expect(output).toContain("provider.request 500ms"); - expect(output).toContain('model="gpt-4"'); - expect(output).toContain('method="POST"'); - expect(output).toContain("status=200"); - }); + it("renders attributes on span (up to 3)", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "provider.request", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + attributes: { model: "gpt-4", method: "POST", status: 200 }, + }, + { + kind: "span-close", + spanId: "s1", + name: "provider.request", + timestamp: 1500, + durationMs: 500, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + const output = renderEasyView(records); + expect(output).toContain("provider.request 500ms"); + expect(output).toContain('model="gpt-4"'); + expect(output).toContain('method="POST"'); + expect(output).toContain("status=200"); + }); - it("renders mixed records in correct order", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "log", - level: "info", - msg: "inside step", - timestamp: 1200, - extensionId: "ext", - turnId: "t1", - parentSpanId: "s1", - }, - { - kind: "span-close", - spanId: "s1", - name: "step", - timestamp: 2800, - durationMs: 1800, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - { - kind: "log", - level: "info", - msg: "after step", - timestamp: 3000, - extensionId: "ext", - turnId: "t1", - }, - ]; - const expected = ["- step 1.8s", " - inside step", "- after step"].join("\n"); - expect(renderEasyView(records)).toBe(expected); - }); + it("renders mixed records in correct order", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "log", + level: "info", + msg: "inside step", + timestamp: 1200, + extensionId: "ext", + turnId: "t1", + parentSpanId: "s1", + }, + { + kind: "span-close", + spanId: "s1", + name: "step", + timestamp: 2800, + durationMs: 1800, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + { + kind: "log", + level: "info", + msg: "after step", + timestamp: 3000, + extensionId: "ext", + turnId: "t1", + }, + ]; + const expected = ["- step 1.8s", " - inside step", "- after step"].join("\n"); + expect(renderEasyView(records)).toBe(expected); + }); - it("deterministic output for same input", () => { - const records: LogRecord[] = [ - { - kind: "span-open", - spanId: "s1", - name: "step", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - }, - { - kind: "span-close", - spanId: "s1", - name: "step", - timestamp: 1800, - durationMs: 800, - status: "ok", - extensionId: "ext", - turnId: "t1", - }, - ]; - expect(renderEasyView(records)).toBe(renderEasyView(records)); - }); + it("deterministic output for same input", () => { + const records: LogRecord[] = [ + { + kind: "span-open", + spanId: "s1", + name: "step", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + }, + { + kind: "span-close", + spanId: "s1", + name: "step", + timestamp: 1800, + durationMs: 800, + status: "ok", + extensionId: "ext", + turnId: "t1", + }, + ]; + expect(renderEasyView(records)).toBe(renderEasyView(records)); + }); }); describe("formatDuration", () => { - it("formats milliseconds under 1s", () => { - expect(formatDuration(42)).toBe("42ms"); - expect(formatDuration(999)).toBe("999ms"); - }); + it("formats milliseconds under 1s", () => { + expect(formatDuration(42)).toBe("42ms"); + expect(formatDuration(999)).toBe("999ms"); + }); - it("formats seconds", () => { - expect(formatDuration(1000)).toBe("1.0s"); - expect(formatDuration(1500)).toBe("1.5s"); - expect(formatDuration(59999)).toBe("60.0s"); - }); + it("formats seconds", () => { + expect(formatDuration(1000)).toBe("1.0s"); + expect(formatDuration(1500)).toBe("1.5s"); + expect(formatDuration(59999)).toBe("60.0s"); + }); - it("formats minutes", () => { - expect(formatDuration(60000)).toBe("1m0s"); - expect(formatDuration(90000)).toBe("1m30s"); - expect(formatDuration(125000)).toBe("2m5s"); - }); + it("formats minutes", () => { + expect(formatDuration(60000)).toBe("1m0s"); + expect(formatDuration(90000)).toBe("1m30s"); + expect(formatDuration(125000)).toBe("2m5s"); + }); }); diff --git a/packages/trace-store/src/easy-view.ts b/packages/trace-store/src/easy-view.ts index 55477ee..cc32c9c 100644 --- a/packages/trace-store/src/easy-view.ts +++ b/packages/trace-store/src/easy-view.ts @@ -1,205 +1,205 @@ import type { Attributes, LogRecord } from "@dispatch/kernel"; interface SpanInfo { - name: string; - openTimestamp: number; - closeTimestamp?: number; - durationMs?: number; - status?: string; - body?: string; - attributes?: Attributes; - children: TimelineEntry[]; + name: string; + openTimestamp: number; + closeTimestamp?: number; + durationMs?: number; + status?: string; + body?: string; + attributes?: Attributes; + children: TimelineEntry[]; } interface LogEntry { - record: LogRecord; + record: LogRecord; } type TimelineEntry = { type: "span"; span: SpanInfo } | { type: "log"; entry: LogEntry }; export function renderEasyView(records: readonly LogRecord[]): string { - if (records.length === 0) { - return ""; - } - - const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp); - - const spansById = new Map(); - const roots: TimelineEntry[] = []; - const childrenByParent = new Map(); - - for (const r of sorted) { - if (r.kind === "span-open") { - const span: SpanInfo = { - name: r.name, - openTimestamp: r.timestamp, - ...(r.body !== undefined && { body: r.body }), - ...(r.attributes !== undefined && { attributes: r.attributes }), - children: [], - }; - spansById.set(r.spanId, span); - } else if (r.kind === "span-close") { - const span = spansById.get(r.spanId); - if (span !== undefined) { - span.closeTimestamp = r.timestamp; - span.durationMs = r.durationMs; - span.status = r.status; - if (r.body !== undefined) { - span.body = r.body; - } - if (r.attributes !== undefined) { - span.attributes = { ...span.attributes, ...r.attributes }; - } - } - } else { - const entry: TimelineEntry = { type: "log", entry: { record: r } }; - const parentId = r.parentSpanId; - let siblings = childrenByParent.get(parentId); - if (siblings === undefined) { - siblings = []; - childrenByParent.set(parentId, siblings); - } - siblings.push(entry); - } - } - - for (const [spanId, span] of spansById) { - const entry: TimelineEntry = { type: "span", span }; - const parentId = getSpanParentId(sorted, spanId); - let siblings = childrenByParent.get(parentId); - if (siblings === undefined) { - siblings = []; - childrenByParent.set(parentId, siblings); - } - siblings.push(entry); - } - - for (const [parentId, children] of childrenByParent) { - if (parentId === undefined) { - roots.push(...children); - } else { - const parent = spansById.get(parentId); - if (parent !== undefined) { - parent.children.push(...children); - } else { - roots.push(...children); - } - } - } - - sortByTimestamp(roots); - for (const entry of roots) { - if (entry.type === "span") { - sortByTimestamp(entry.span.children); - } - } - - const lines: string[] = []; - for (const entry of roots) { - renderEntry(entry, 0, lines); - } - return lines.join("\n"); + if (records.length === 0) { + return ""; + } + + const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp); + + const spansById = new Map(); + const roots: TimelineEntry[] = []; + const childrenByParent = new Map(); + + for (const r of sorted) { + if (r.kind === "span-open") { + const span: SpanInfo = { + name: r.name, + openTimestamp: r.timestamp, + ...(r.body !== undefined && { body: r.body }), + ...(r.attributes !== undefined && { attributes: r.attributes }), + children: [], + }; + spansById.set(r.spanId, span); + } else if (r.kind === "span-close") { + const span = spansById.get(r.spanId); + if (span !== undefined) { + span.closeTimestamp = r.timestamp; + span.durationMs = r.durationMs; + span.status = r.status; + if (r.body !== undefined) { + span.body = r.body; + } + if (r.attributes !== undefined) { + span.attributes = { ...span.attributes, ...r.attributes }; + } + } + } else { + const entry: TimelineEntry = { type: "log", entry: { record: r } }; + const parentId = r.parentSpanId; + let siblings = childrenByParent.get(parentId); + if (siblings === undefined) { + siblings = []; + childrenByParent.set(parentId, siblings); + } + siblings.push(entry); + } + } + + for (const [spanId, span] of spansById) { + const entry: TimelineEntry = { type: "span", span }; + const parentId = getSpanParentId(sorted, spanId); + let siblings = childrenByParent.get(parentId); + if (siblings === undefined) { + siblings = []; + childrenByParent.set(parentId, siblings); + } + siblings.push(entry); + } + + for (const [parentId, children] of childrenByParent) { + if (parentId === undefined) { + roots.push(...children); + } else { + const parent = spansById.get(parentId); + if (parent !== undefined) { + parent.children.push(...children); + } else { + roots.push(...children); + } + } + } + + sortByTimestamp(roots); + for (const entry of roots) { + if (entry.type === "span") { + sortByTimestamp(entry.span.children); + } + } + + const lines: string[] = []; + for (const entry of roots) { + renderEntry(entry, 0, lines); + } + return lines.join("\n"); } function sortByTimestamp(entries: TimelineEntry[]): void { - entries.sort((a, b) => { - const tsA = a.type === "span" ? a.span.openTimestamp : a.entry.record.timestamp; - const tsB = b.type === "span" ? b.span.openTimestamp : b.entry.record.timestamp; - return tsA - tsB; - }); + entries.sort((a, b) => { + const tsA = a.type === "span" ? a.span.openTimestamp : a.entry.record.timestamp; + const tsB = b.type === "span" ? b.span.openTimestamp : b.entry.record.timestamp; + return tsA - tsB; + }); } function getSpanParentId(records: readonly LogRecord[], spanId: string): string | undefined { - for (const r of records) { - if (r.kind === "span-open" && r.spanId === spanId) { - return r.parentSpanId; - } - } - return undefined; + for (const r of records) { + if (r.kind === "span-open" && r.spanId === spanId) { + return r.parentSpanId; + } + } + return undefined; } function renderEntry(entry: TimelineEntry, depth: number, lines: string[]): void { - const indent = " ".repeat(depth); - - if (entry.type === "span") { - const span = entry.span; - const dur = span.durationMs !== undefined ? formatDuration(span.durationMs) : undefined; - const statusStr = span.status === "error" ? " ERR" : ""; - const suffix = dur !== undefined ? ` ${dur}${statusStr}` : " (open)"; - - let detail = ""; - if (span.attributes !== undefined) { - const keys = Object.keys(span.attributes); - if (keys.length > 0) { - const parts: string[] = []; - for (const k of keys.slice(0, 3)) { - const v = span.attributes[k]; - if (v !== undefined) { - parts.push(`${k}=${formatAttrValue(v)}`); - } - } - detail = ` {${parts.join(", ")}}`; - } - } - - const bodyHint = span.body !== undefined ? ` [body ${formatSize(span.body.length)}]` : ""; - - lines.push(`${indent}- ${span.name}${suffix}${detail}${bodyHint}`); - - for (const child of span.children) { - renderEntry(child, depth + 1, lines); - } - } else { - const r = entry.entry.record; - if (r.kind === "log") { - const lvl = levelTag(r.level); - const bodyHint = r.body !== undefined ? ` [body ${formatSize(r.body.length)}]` : ""; - lines.push(`${indent}- ${lvl}${r.msg}${bodyHint}`); - } - } + const indent = " ".repeat(depth); + + if (entry.type === "span") { + const span = entry.span; + const dur = span.durationMs !== undefined ? formatDuration(span.durationMs) : undefined; + const statusStr = span.status === "error" ? " ERR" : ""; + const suffix = dur !== undefined ? ` ${dur}${statusStr}` : " (open)"; + + let detail = ""; + if (span.attributes !== undefined) { + const keys = Object.keys(span.attributes); + if (keys.length > 0) { + const parts: string[] = []; + for (const k of keys.slice(0, 3)) { + const v = span.attributes[k]; + if (v !== undefined) { + parts.push(`${k}=${formatAttrValue(v)}`); + } + } + detail = ` {${parts.join(", ")}}`; + } + } + + const bodyHint = span.body !== undefined ? ` [body ${formatSize(span.body.length)}]` : ""; + + lines.push(`${indent}- ${span.name}${suffix}${detail}${bodyHint}`); + + for (const child of span.children) { + renderEntry(child, depth + 1, lines); + } + } else { + const r = entry.entry.record; + if (r.kind === "log") { + const lvl = levelTag(r.level); + const bodyHint = r.body !== undefined ? ` [body ${formatSize(r.body.length)}]` : ""; + lines.push(`${indent}- ${lvl}${r.msg}${bodyHint}`); + } + } } function levelTag(level: string): string { - if (level === "info") { - return ""; - } - return `[${level}] `; + if (level === "info") { + return ""; + } + return `[${level}] `; } export function formatDuration(ms: number): string { - if (ms < 1000) { - return `${ms}ms`; - } - const s = ms / 1000; - if (s < 60) { - return `${s.toFixed(1)}s`; - } - const m = Math.floor(s / 60); - const rem = (s % 60).toFixed(0); - return `${m}m${rem}s`; + if (ms < 1000) { + return `${ms}ms`; + } + const s = ms / 1000; + if (s < 60) { + return `${s.toFixed(1)}s`; + } + const m = Math.floor(s / 60); + const rem = (s % 60).toFixed(0); + return `${m}m${rem}s`; } function formatSize(n: number): string { - if (n < 1024) { - return `${n}b`; - } - const kb = n / 1024; - if (kb < 1024) { - return `${kb.toFixed(1)}k`; - } - const mb = kb / 1024; - return `${mb.toFixed(1)}M`; + if (n < 1024) { + return `${n}b`; + } + const kb = n / 1024; + if (kb < 1024) { + return `${kb.toFixed(1)}k`; + } + const mb = kb / 1024; + return `${mb.toFixed(1)}M`; } function formatAttrValue(value: string | number | boolean | null): string { - if (value === null) { - return "null"; - } - if (typeof value === "string") { - if (value.length > 20) { - return `"${value.slice(0, 17)}..."`; - } - return `"${value}"`; - } - return String(value); + if (value === null) { + return "null"; + } + if (typeof value === "string") { + if (value.length > 20) { + return `"${value.slice(0, 17)}..."`; + } + return `"${value}"`; + } + return String(value); } diff --git a/packages/trace-store/src/store.test.ts b/packages/trace-store/src/store.test.ts index 131d099..9b44084 100644 --- a/packages/trace-store/src/store.test.ts +++ b/packages/trace-store/src/store.test.ts @@ -5,516 +5,516 @@ import { describe, expect, it } from "vitest"; import { computeEvictions, createTraceStore, stableId } from "./store.js"; const logRecord: LogRecord = { - kind: "log", - level: "info", - msg: "hello", - timestamp: 1700000000000, - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - spanId: "span-1", - attributes: { key: "value" }, + kind: "log", + level: "info", + msg: "hello", + timestamp: 1700000000000, + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + spanId: "span-1", + attributes: { key: "value" }, }; const spanOpenRecord: LogRecord = { - kind: "span-open", - spanId: "span-2", - name: "step", - timestamp: 1700000000100, - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - parentSpanId: "span-1", + kind: "span-open", + spanId: "span-2", + name: "step", + timestamp: 1700000000100, + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + parentSpanId: "span-1", }; const spanCloseRecord: LogRecord = { - kind: "span-close", - spanId: "span-2", - name: "step", - timestamp: 1700000000500, - durationMs: 400, - status: "ok", - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - parentSpanId: "span-1", + kind: "span-close", + spanId: "span-2", + name: "step", + timestamp: 1700000000500, + durationMs: 400, + status: "ok", + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + parentSpanId: "span-1", }; const bodyRecord: LogRecord = { - kind: "span-open", - spanId: "span-3", - name: "prompt", - timestamp: 1700000000200, - extensionId: "test-ext", - conversationId: "conv-1", - turnId: "turn-1", - body: "the full prompt text", + kind: "span-open", + spanId: "span-3", + name: "prompt", + timestamp: 1700000000200, + extensionId: "test-ext", + conversationId: "conv-1", + turnId: "turn-1", + body: "the full prompt text", }; const logRecordNoBody: LogRecord = { - kind: "log", - level: "debug", - msg: "no body here", - timestamp: 1700000000050, - extensionId: "ext-min", - turnId: "turn-1", + kind: "log", + level: "debug", + msg: "no body here", + timestamp: 1700000000050, + extensionId: "ext-min", + turnId: "turn-1", }; describe("stableId", () => { - it("produces a 16-char hex string", () => { - const id = stableId(logRecord); - expect(id).toMatch(/^[0-9a-f]{16}$/); - }); - - it("is deterministic for the same record", () => { - expect(stableId(logRecord)).toBe(stableId(logRecord)); - }); - - it("produces different ids for different records", () => { - expect(stableId(logRecord)).not.toBe(stableId(spanOpenRecord)); - }); + it("produces a 16-char hex string", () => { + const id = stableId(logRecord); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + + it("is deterministic for the same record", () => { + expect(stableId(logRecord)).toBe(stableId(logRecord)); + }); + + it("produces different ids for different records", () => { + expect(stableId(logRecord)).not.toBe(stableId(spanOpenRecord)); + }); }); describe("createTraceStore", () => { - function freshStore() { - return createTraceStore({ path: ":memory:" }); - } - - it("inserts and retrieves records ordered by timestamp", () => { - const store = freshStore(); - store.insertRecords([logRecord, spanCloseRecord, spanOpenRecord]); - const result = store.getTurn("turn-1"); - expect(result).toHaveLength(3); - expect(result[0]?.timestamp).toBe(1700000000000); - expect(result[1]?.timestamp).toBe(1700000000100); - expect(result[2]?.timestamp).toBe(1700000000500); - store.close(); - }); - - it("reconstructs attributes from JSON", () => { - const store = freshStore(); - store.insertRecords([logRecord]); - const result = store.getTurn("turn-1"); - expect(result[0]?.kind).toBe("log"); - if (result[0]?.kind === "log") { - expect(result[0].attributes).toEqual({ key: "value" }); - } - store.close(); - }); - - it("reconstructs links from JSON", () => { - const store = freshStore(); - const withLinks: LogRecord = { - kind: "span-open", - spanId: "span-link", - name: "linked", - timestamp: 1700000000999, - extensionId: "ext", - turnId: "turn-1", - links: [{ spanId: "other-span", turnId: "turn-0", reason: "caused" }], - }; - store.insertRecords([withLinks]); - const result = store.getTurn("turn-1"); - expect(result[0]?.kind).toBe("span-open"); - if (result[0]?.kind === "span-open") { - expect(result[0].links).toEqual([ - { spanId: "other-span", turnId: "turn-0", reason: "caused" }, - ]); - } - store.close(); - }); - - it("returns empty array for unknown turnId", () => { - const store = freshStore(); - store.insertRecords([logRecord]); - expect(store.getTurn("nonexistent")).toEqual([]); - store.close(); - }); - - it("getBody returns body for body-bearing records", () => { - const store = freshStore(); - store.insertRecords([bodyRecord]); - const id = stableId(bodyRecord); - expect(store.getBody(id)).toBe("the full prompt text"); - store.close(); - }); - - it("getBody returns undefined for records without body", () => { - const store = freshStore(); - store.insertRecords([logRecordNoBody]); - const id = stableId(logRecordNoBody); - expect(store.getBody(id)).toBeUndefined(); - store.close(); - }); - - it("bodies table only holds body-bearing records", () => { - const store = freshStore(); - store.insertRecords([logRecord, bodyRecord, logRecordNoBody]); - const bodyId = stableId(bodyRecord); - expect(store.getBody(bodyId)).toBe("the full prompt text"); - - const logId = stableId(logRecord); - expect(store.getBody(logId)).toBeUndefined(); - - const noBodyId = stableId(logRecordNoBody); - expect(store.getBody(noBodyId)).toBeUndefined(); - store.close(); - }); - - it("is idempotent — re-inserting the same records produces no duplicates", () => { - const store = freshStore(); - store.insertRecords([logRecord, spanOpenRecord, spanCloseRecord, bodyRecord]); - store.insertRecords([logRecord, spanOpenRecord, spanCloseRecord, bodyRecord]); - const result = store.getTurn("turn-1"); - expect(result).toHaveLength(4); - store.close(); - }); - - it("handles span-close record with attributes and links round-trip", () => { - const store = freshStore(); - const closeWithMeta: LogRecord = { - kind: "span-close", - spanId: "span-m", - name: "step", - timestamp: 1700000001000, - durationMs: 250, - status: "error", - extensionId: "ext", - turnId: "turn-1", - attributes: { httpStatus: 500 }, - links: [{ spanId: "upstream" }], - }; - store.insertRecords([closeWithMeta]); - const result = store.getTurn("turn-1"); - expect(result[0]?.kind).toBe("span-close"); - if (result[0]?.kind === "span-close") { - expect(result[0].durationMs).toBe(250); - expect(result[0].status).toBe("error"); - expect(result[0].attributes).toEqual({ httpStatus: 500 }); - expect(result[0].links).toEqual([{ spanId: "upstream" }]); - } - store.close(); - }); - - it("easyView delegates to renderEasyView", () => { - const store = freshStore(); - store.insertRecords([spanOpenRecord]); - const output = store.easyView("turn-1"); - expect(output).toContain("step (open)"); - store.close(); - }); - - it("persists to a file path", () => { - const tmpPath = `/tmp/trace-store-test-${Date.now()}.db`; - const store = createTraceStore({ path: tmpPath }); - store.insertRecords([logRecord]); - store.close(); - - const store2 = createTraceStore({ path: tmpPath }); - const result = store2.getTurn("turn-1"); - expect(result).toHaveLength(1); - expect(result[0]?.kind).toBe("log"); - store2.close(); - - try { - unlinkSync(tmpPath); - } catch { - // ignore cleanup error - } - }); - - it("body round-trips through getTurn", () => { - const store = freshStore(); - store.insertRecords([bodyRecord]); - const result = store.getTurn("turn-1"); - expect(result).toHaveLength(1); - expect(result[0]?.kind).toBe("span-open"); - if (result[0]?.kind === "span-open") { - expect(result[0].body).toBe("the full prompt text"); - } - store.close(); - }); + function freshStore() { + return createTraceStore({ path: ":memory:" }); + } + + it("inserts and retrieves records ordered by timestamp", () => { + const store = freshStore(); + store.insertRecords([logRecord, spanCloseRecord, spanOpenRecord]); + const result = store.getTurn("turn-1"); + expect(result).toHaveLength(3); + expect(result[0]?.timestamp).toBe(1700000000000); + expect(result[1]?.timestamp).toBe(1700000000100); + expect(result[2]?.timestamp).toBe(1700000000500); + store.close(); + }); + + it("reconstructs attributes from JSON", () => { + const store = freshStore(); + store.insertRecords([logRecord]); + const result = store.getTurn("turn-1"); + expect(result[0]?.kind).toBe("log"); + if (result[0]?.kind === "log") { + expect(result[0].attributes).toEqual({ key: "value" }); + } + store.close(); + }); + + it("reconstructs links from JSON", () => { + const store = freshStore(); + const withLinks: LogRecord = { + kind: "span-open", + spanId: "span-link", + name: "linked", + timestamp: 1700000000999, + extensionId: "ext", + turnId: "turn-1", + links: [{ spanId: "other-span", turnId: "turn-0", reason: "caused" }], + }; + store.insertRecords([withLinks]); + const result = store.getTurn("turn-1"); + expect(result[0]?.kind).toBe("span-open"); + if (result[0]?.kind === "span-open") { + expect(result[0].links).toEqual([ + { spanId: "other-span", turnId: "turn-0", reason: "caused" }, + ]); + } + store.close(); + }); + + it("returns empty array for unknown turnId", () => { + const store = freshStore(); + store.insertRecords([logRecord]); + expect(store.getTurn("nonexistent")).toEqual([]); + store.close(); + }); + + it("getBody returns body for body-bearing records", () => { + const store = freshStore(); + store.insertRecords([bodyRecord]); + const id = stableId(bodyRecord); + expect(store.getBody(id)).toBe("the full prompt text"); + store.close(); + }); + + it("getBody returns undefined for records without body", () => { + const store = freshStore(); + store.insertRecords([logRecordNoBody]); + const id = stableId(logRecordNoBody); + expect(store.getBody(id)).toBeUndefined(); + store.close(); + }); + + it("bodies table only holds body-bearing records", () => { + const store = freshStore(); + store.insertRecords([logRecord, bodyRecord, logRecordNoBody]); + const bodyId = stableId(bodyRecord); + expect(store.getBody(bodyId)).toBe("the full prompt text"); + + const logId = stableId(logRecord); + expect(store.getBody(logId)).toBeUndefined(); + + const noBodyId = stableId(logRecordNoBody); + expect(store.getBody(noBodyId)).toBeUndefined(); + store.close(); + }); + + it("is idempotent — re-inserting the same records produces no duplicates", () => { + const store = freshStore(); + store.insertRecords([logRecord, spanOpenRecord, spanCloseRecord, bodyRecord]); + store.insertRecords([logRecord, spanOpenRecord, spanCloseRecord, bodyRecord]); + const result = store.getTurn("turn-1"); + expect(result).toHaveLength(4); + store.close(); + }); + + it("handles span-close record with attributes and links round-trip", () => { + const store = freshStore(); + const closeWithMeta: LogRecord = { + kind: "span-close", + spanId: "span-m", + name: "step", + timestamp: 1700000001000, + durationMs: 250, + status: "error", + extensionId: "ext", + turnId: "turn-1", + attributes: { httpStatus: 500 }, + links: [{ spanId: "upstream" }], + }; + store.insertRecords([closeWithMeta]); + const result = store.getTurn("turn-1"); + expect(result[0]?.kind).toBe("span-close"); + if (result[0]?.kind === "span-close") { + expect(result[0].durationMs).toBe(250); + expect(result[0].status).toBe("error"); + expect(result[0].attributes).toEqual({ httpStatus: 500 }); + expect(result[0].links).toEqual([{ spanId: "upstream" }]); + } + store.close(); + }); + + it("easyView delegates to renderEasyView", () => { + const store = freshStore(); + store.insertRecords([spanOpenRecord]); + const output = store.easyView("turn-1"); + expect(output).toContain("step (open)"); + store.close(); + }); + + it("persists to a file path", () => { + const tmpPath = `/tmp/trace-store-test-${Date.now()}.db`; + const store = createTraceStore({ path: tmpPath }); + store.insertRecords([logRecord]); + store.close(); + + const store2 = createTraceStore({ path: tmpPath }); + const result = store2.getTurn("turn-1"); + expect(result).toHaveLength(1); + expect(result[0]?.kind).toBe("log"); + store2.close(); + + try { + unlinkSync(tmpPath); + } catch { + // ignore cleanup error + } + }); + + it("body round-trips through getTurn", () => { + const store = freshStore(); + store.insertRecords([bodyRecord]); + const result = store.getTurn("turn-1"); + expect(result).toHaveLength(1); + expect(result[0]?.kind).toBe("span-open"); + if (result[0]?.kind === "span-open") { + expect(result[0].body).toBe("the full prompt text"); + } + store.close(); + }); }); describe("content-addressed body storage", () => { - function freshStore() { - return createTraceStore({ path: ":memory:" }); - } - - it("content-addresses two identical bodies to a single stored body row", () => { - const store = freshStore(); - const rec1: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "prompt", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: "identical body content", - }; - const rec2: LogRecord = { - kind: "span-open", - spanId: "s2", - name: "prompt", - timestamp: 2000, - extensionId: "ext", - turnId: "t1", - body: "identical body content", - }; - store.insertRecords([rec1, rec2]); - - const id1 = stableId(rec1); - const id2 = stableId(rec2); - expect(store.getBody(id1)).toBe("identical body content"); - expect(store.getBody(id2)).toBe("identical body content"); - store.close(); - }); - - it("stores distinct bodies separately", () => { - const store = freshStore(); - const rec1: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "prompt", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: "body A", - }; - const rec2: LogRecord = { - kind: "span-open", - spanId: "s2", - name: "prompt", - timestamp: 2000, - extensionId: "ext", - turnId: "t1", - body: "body B", - }; - store.insertRecords([rec1, rec2]); - - const id1 = stableId(rec1); - const id2 = stableId(rec2); - expect(store.getBody(id1)).toBe("body A"); - expect(store.getBody(id2)).toBe("body B"); - store.close(); - }); - - it("compresses a body above the threshold and round-trips it on read", () => { - const store = freshStore(); - const largeBody = "x".repeat(2048); - const rec: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "prompt", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: largeBody, - }; - store.insertRecords([rec]); - const id = stableId(rec); - expect(store.getBody(id)).toBe(largeBody); - store.close(); - }); + function freshStore() { + return createTraceStore({ path: ":memory:" }); + } + + it("content-addresses two identical bodies to a single stored body row", () => { + const store = freshStore(); + const rec1: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "prompt", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: "identical body content", + }; + const rec2: LogRecord = { + kind: "span-open", + spanId: "s2", + name: "prompt", + timestamp: 2000, + extensionId: "ext", + turnId: "t1", + body: "identical body content", + }; + store.insertRecords([rec1, rec2]); + + const id1 = stableId(rec1); + const id2 = stableId(rec2); + expect(store.getBody(id1)).toBe("identical body content"); + expect(store.getBody(id2)).toBe("identical body content"); + store.close(); + }); + + it("stores distinct bodies separately", () => { + const store = freshStore(); + const rec1: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "prompt", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: "body A", + }; + const rec2: LogRecord = { + kind: "span-open", + spanId: "s2", + name: "prompt", + timestamp: 2000, + extensionId: "ext", + turnId: "t1", + body: "body B", + }; + store.insertRecords([rec1, rec2]); + + const id1 = stableId(rec1); + const id2 = stableId(rec2); + expect(store.getBody(id1)).toBe("body A"); + expect(store.getBody(id2)).toBe("body B"); + store.close(); + }); + + it("compresses a body above the threshold and round-trips it on read", () => { + const store = freshStore(); + const largeBody = "x".repeat(2048); + const rec: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "prompt", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: largeBody, + }; + store.insertRecords([rec]); + const id = stableId(rec); + expect(store.getBody(id)).toBe(largeBody); + store.close(); + }); }); describe("prune", () => { - function freshStore() { - return createTraceStore({ path: ":memory:" }); - } - - it("prune by maxAgeMs deletes records and their bodies older than the cutoff", () => { - const store = freshStore(); - const oldRec: LogRecord = { - kind: "span-open", - spanId: "s-old", - name: "old-prompt", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: "old body content", - }; - const newRec: LogRecord = { - kind: "span-open", - spanId: "s-new", - name: "new-prompt", - timestamp: Date.now(), - extensionId: "ext", - turnId: "t2", - body: "new body content", - }; - store.insertRecords([oldRec, newRec]); - - const summary = store.prune({ maxAgeMs: 60000 }); - expect(summary.recordsDeleted).toBe(1); - - const result = store.getTurn("t1"); - expect(result).toHaveLength(0); - expect(store.getBody(stableId(oldRec))).toBeUndefined(); - - const newResult = store.getTurn("t2"); - expect(newResult).toHaveLength(1); - expect(store.getBody(stableId(newRec))).toBe("new body content"); - store.close(); - }); - - it("prune by maxTotalBodyBytes evicts oldest bodies until under the cap", () => { - const store = freshStore(); - const body1 = "a".repeat(300); - const body2 = "b".repeat(300); - const body3 = "c".repeat(300); - const rec1: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "p", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: body1, - }; - const rec2: LogRecord = { - kind: "span-open", - spanId: "s2", - name: "p", - timestamp: 2000, - extensionId: "ext", - turnId: "t2", - body: body2, - }; - const rec3: LogRecord = { - kind: "span-open", - spanId: "s3", - name: "p", - timestamp: 3000, - extensionId: "ext", - turnId: "t3", - body: body3, - }; - store.insertRecords([rec1, rec2, rec3]); - - const summary = store.prune({ maxTotalBodyBytes: 500 }); - expect(summary.bodiesDeleted).toBeGreaterThanOrEqual(1); - - expect(store.getBody(stableId(rec1))).toBeUndefined(); - - const remaining = store.getTurn("t3"); - if (remaining.length > 0 && remaining[0]?.kind === "span-open") { - expect(remaining[0].body).toBe(body3); - } - store.close(); - }); - - it("prune garbage-collects an orphaned body with no referencing record", () => { - const store = freshStore(); - const rec: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "p", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: "orphan body", - }; - store.insertRecords([rec]); - - const summary = store.prune({ maxAgeMs: 60000 }); - expect(summary.recordsDeleted).toBe(1); - expect(summary.bodiesDeleted).toBe(1); - store.close(); - }); - - it("prune keeps a still-referenced body when a duplicate referrer remains", () => { - const store = freshStore(); - const sharedBody = "shared body content"; - const rec1: LogRecord = { - kind: "span-open", - spanId: "s1", - name: "p", - timestamp: 1000, - extensionId: "ext", - turnId: "t1", - body: sharedBody, - }; - const rec2: LogRecord = { - kind: "span-open", - spanId: "s2", - name: "p", - timestamp: Date.now(), - extensionId: "ext", - turnId: "t2", - body: sharedBody, - }; - store.insertRecords([rec1, rec2]); - - const summary = store.prune({ maxAgeMs: 60000 }); - expect(summary.recordsDeleted).toBe(1); - expect(summary.bodiesDeleted).toBe(0); - - const id2 = stableId(rec2); - expect(store.getBody(id2)).toBe(sharedBody); - store.close(); - }); + function freshStore() { + return createTraceStore({ path: ":memory:" }); + } + + it("prune by maxAgeMs deletes records and their bodies older than the cutoff", () => { + const store = freshStore(); + const oldRec: LogRecord = { + kind: "span-open", + spanId: "s-old", + name: "old-prompt", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: "old body content", + }; + const newRec: LogRecord = { + kind: "span-open", + spanId: "s-new", + name: "new-prompt", + timestamp: Date.now(), + extensionId: "ext", + turnId: "t2", + body: "new body content", + }; + store.insertRecords([oldRec, newRec]); + + const summary = store.prune({ maxAgeMs: 60000 }); + expect(summary.recordsDeleted).toBe(1); + + const result = store.getTurn("t1"); + expect(result).toHaveLength(0); + expect(store.getBody(stableId(oldRec))).toBeUndefined(); + + const newResult = store.getTurn("t2"); + expect(newResult).toHaveLength(1); + expect(store.getBody(stableId(newRec))).toBe("new body content"); + store.close(); + }); + + it("prune by maxTotalBodyBytes evicts oldest bodies until under the cap", () => { + const store = freshStore(); + const body1 = "a".repeat(300); + const body2 = "b".repeat(300); + const body3 = "c".repeat(300); + const rec1: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "p", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: body1, + }; + const rec2: LogRecord = { + kind: "span-open", + spanId: "s2", + name: "p", + timestamp: 2000, + extensionId: "ext", + turnId: "t2", + body: body2, + }; + const rec3: LogRecord = { + kind: "span-open", + spanId: "s3", + name: "p", + timestamp: 3000, + extensionId: "ext", + turnId: "t3", + body: body3, + }; + store.insertRecords([rec1, rec2, rec3]); + + const summary = store.prune({ maxTotalBodyBytes: 500 }); + expect(summary.bodiesDeleted).toBeGreaterThanOrEqual(1); + + expect(store.getBody(stableId(rec1))).toBeUndefined(); + + const remaining = store.getTurn("t3"); + if (remaining.length > 0 && remaining[0]?.kind === "span-open") { + expect(remaining[0].body).toBe(body3); + } + store.close(); + }); + + it("prune garbage-collects an orphaned body with no referencing record", () => { + const store = freshStore(); + const rec: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "p", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: "orphan body", + }; + store.insertRecords([rec]); + + const summary = store.prune({ maxAgeMs: 60000 }); + expect(summary.recordsDeleted).toBe(1); + expect(summary.bodiesDeleted).toBe(1); + store.close(); + }); + + it("prune keeps a still-referenced body when a duplicate referrer remains", () => { + const store = freshStore(); + const sharedBody = "shared body content"; + const rec1: LogRecord = { + kind: "span-open", + spanId: "s1", + name: "p", + timestamp: 1000, + extensionId: "ext", + turnId: "t1", + body: sharedBody, + }; + const rec2: LogRecord = { + kind: "span-open", + spanId: "s2", + name: "p", + timestamp: Date.now(), + extensionId: "ext", + turnId: "t2", + body: sharedBody, + }; + store.insertRecords([rec1, rec2]); + + const summary = store.prune({ maxAgeMs: 60000 }); + expect(summary.recordsDeleted).toBe(1); + expect(summary.bodiesDeleted).toBe(0); + + const id2 = stableId(rec2); + expect(store.getBody(id2)).toBe(sharedBody); + store.close(); + }); }); describe("computeEvictions", () => { - it("returns empty when under cap", () => { - const bodies = [ - { hash: "a", storedSize: 100, oldestRecordTimestamp: 1000 }, - { hash: "b", storedSize: 200, oldestRecordTimestamp: 2000 }, - ]; - expect(computeEvictions(bodies, 500)).toEqual([]); - }); - - it("evicts oldest bodies until under cap", () => { - const bodies = [ - { hash: "a", storedSize: 300, oldestRecordTimestamp: 1000 }, - { hash: "b", storedSize: 300, oldestRecordTimestamp: 2000 }, - { hash: "c", storedSize: 300, oldestRecordTimestamp: 3000 }, - ]; - const evicted = computeEvictions(bodies, 500); - expect(evicted).toEqual(["a", "b"]); - }); - - it("evicts multiple oldest bodies", () => { - const bodies = [ - { hash: "a", storedSize: 200, oldestRecordTimestamp: 1000 }, - { hash: "b", storedSize: 200, oldestRecordTimestamp: 2000 }, - { hash: "c", storedSize: 200, oldestRecordTimestamp: 3000 }, - ]; - const evicted = computeEvictions(bodies, 300); - expect(evicted).toEqual(["a", "b"]); - }); + it("returns empty when under cap", () => { + const bodies = [ + { hash: "a", storedSize: 100, oldestRecordTimestamp: 1000 }, + { hash: "b", storedSize: 200, oldestRecordTimestamp: 2000 }, + ]; + expect(computeEvictions(bodies, 500)).toEqual([]); + }); + + it("evicts oldest bodies until under cap", () => { + const bodies = [ + { hash: "a", storedSize: 300, oldestRecordTimestamp: 1000 }, + { hash: "b", storedSize: 300, oldestRecordTimestamp: 2000 }, + { hash: "c", storedSize: 300, oldestRecordTimestamp: 3000 }, + ]; + const evicted = computeEvictions(bodies, 500); + expect(evicted).toEqual(["a", "b"]); + }); + + it("evicts multiple oldest bodies", () => { + const bodies = [ + { hash: "a", storedSize: 200, oldestRecordTimestamp: 1000 }, + { hash: "b", storedSize: 200, oldestRecordTimestamp: 2000 }, + { hash: "c", storedSize: 200, oldestRecordTimestamp: 3000 }, + ]; + const evicted = computeEvictions(bodies, 300); + expect(evicted).toEqual(["a", "b"]); + }); }); describe("old-schema migration", () => { - function tmpPath(): string { - return `/tmp/trace-store-migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; - } - - function cleanup(path: string): void { - try { - unlinkSync(path); - } catch { - // ignore - } - try { - unlinkSync(`${path}-wal`); - } catch { - // ignore - } - try { - unlinkSync(`${path}-shm`); - } catch { - // ignore - } - } - - it("migrates a pre-existing old-schema DB (records without bodyHash + bodies keyed by recordId) on open without error", () => { - const path = tmpPath(); - try { - const oldDb = new Database(path); - oldDb.run("PRAGMA journal_mode = WAL"); - oldDb.run(` + function tmpPath(): string { + return `/tmp/trace-store-migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + } + + function cleanup(path: string): void { + try { + unlinkSync(path); + } catch { + // ignore + } + try { + unlinkSync(`${path}-wal`); + } catch { + // ignore + } + try { + unlinkSync(`${path}-shm`); + } catch { + // ignore + } + } + + it("migrates a pre-existing old-schema DB (records without bodyHash + bodies keyed by recordId) on open without error", () => { + const path = tmpPath(); + try { + const oldDb = new Database(path); + oldDb.run("PRAGMA journal_mode = WAL"); + oldDb.run(` CREATE TABLE records ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -533,89 +533,89 @@ describe("old-schema migration", () => { links TEXT ) `); - oldDb.run(` + oldDb.run(` CREATE TABLE bodies ( recordId TEXT PRIMARY KEY REFERENCES records(id), body TEXT NOT NULL ) `); - oldDb.run( - `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) + oldDb.run( + `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "rec-1", - "span-open", - null, - null, - "prompt", - "s1", - null, - "conv-1", - "t1", - "ext", - 1000, - null, - null, - null, - null, - ], - ); - oldDb.run( - `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) + [ + "rec-1", + "span-open", + null, + null, + "prompt", + "s1", + null, + "conv-1", + "t1", + "ext", + 1000, + null, + null, + null, + null, + ], + ); + oldDb.run( + `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "rec-2", - "span-open", - null, - null, - "prompt", - "s2", - null, - "conv-1", - "t1", - "ext", - 2000, - null, - null, - null, - null, - ], - ); - - const sharedBody = "identical body content for migration test"; - oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-1", sharedBody]); - oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-2", sharedBody]); - - oldDb.close(); - - const store = createTraceStore({ path }); - - const turn = store.getTurn("t1"); - expect(turn).toHaveLength(2); - expect(turn[0]?.kind).toBe("span-open"); - expect(turn[1]?.kind).toBe("span-open"); - - expect(store.getBody("rec-1")).toBe(sharedBody); - expect(store.getBody("rec-2")).toBe(sharedBody); - - const db = new Database(path); - const bodyRows = db.query("SELECT hash FROM bodies").all() as Array<{ hash: string }>; - expect(bodyRows).toHaveLength(1); - db.close(); - - store.close(); - } finally { - cleanup(path); - } - }); - - it("re-opening an already-migrated DB is a no-op (no error, no double-migrate)", () => { - const path = tmpPath(); - try { - const oldDb = new Database(path); - oldDb.run("PRAGMA journal_mode = WAL"); - oldDb.run(` + [ + "rec-2", + "span-open", + null, + null, + "prompt", + "s2", + null, + "conv-1", + "t1", + "ext", + 2000, + null, + null, + null, + null, + ], + ); + + const sharedBody = "identical body content for migration test"; + oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-1", sharedBody]); + oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-2", sharedBody]); + + oldDb.close(); + + const store = createTraceStore({ path }); + + const turn = store.getTurn("t1"); + expect(turn).toHaveLength(2); + expect(turn[0]?.kind).toBe("span-open"); + expect(turn[1]?.kind).toBe("span-open"); + + expect(store.getBody("rec-1")).toBe(sharedBody); + expect(store.getBody("rec-2")).toBe(sharedBody); + + const db = new Database(path); + const bodyRows = db.query("SELECT hash FROM bodies").all() as Array<{ hash: string }>; + expect(bodyRows).toHaveLength(1); + db.close(); + + store.close(); + } finally { + cleanup(path); + } + }); + + it("re-opening an already-migrated DB is a no-op (no error, no double-migrate)", () => { + const path = tmpPath(); + try { + const oldDb = new Database(path); + oldDb.run("PRAGMA journal_mode = WAL"); + oldDb.run(` CREATE TABLE records ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -634,58 +634,58 @@ describe("old-schema migration", () => { links TEXT ) `); - oldDb.run(` + oldDb.run(` CREATE TABLE bodies ( recordId TEXT PRIMARY KEY REFERENCES records(id), body TEXT NOT NULL ) `); - oldDb.run( - `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) + oldDb.run( + `INSERT INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, durationMs, status, attributes, links) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - "rec-1", - "span-open", - null, - null, - "prompt", - "s1", - null, - "conv-1", - "t1", - "ext", - 1000, - null, - null, - null, - null, - ], - ); - oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-1", "some body"]); - oldDb.close(); - - const store1 = createTraceStore({ path }); - expect(store1.getBody("rec-1")).toBe("some body"); - store1.close(); - - const store2 = createTraceStore({ path }); - expect(store2.getBody("rec-1")).toBe("some body"); - - const turn = store2.getTurn("t1"); - expect(turn).toHaveLength(1); - - store2.close(); - } finally { - cleanup(path); - } - }); - - it("idx_records_bodyHash exists after migration", () => { - const path = tmpPath(); - try { - const oldDb = new Database(path); - oldDb.run("PRAGMA journal_mode = WAL"); - oldDb.run(` + [ + "rec-1", + "span-open", + null, + null, + "prompt", + "s1", + null, + "conv-1", + "t1", + "ext", + 1000, + null, + null, + null, + null, + ], + ); + oldDb.run("INSERT INTO bodies (recordId, body) VALUES (?, ?)", ["rec-1", "some body"]); + oldDb.close(); + + const store1 = createTraceStore({ path }); + expect(store1.getBody("rec-1")).toBe("some body"); + store1.close(); + + const store2 = createTraceStore({ path }); + expect(store2.getBody("rec-1")).toBe("some body"); + + const turn = store2.getTurn("t1"); + expect(turn).toHaveLength(1); + + store2.close(); + } finally { + cleanup(path); + } + }); + + it("idx_records_bodyHash exists after migration", () => { + const path = tmpPath(); + try { + const oldDb = new Database(path); + oldDb.run("PRAGMA journal_mode = WAL"); + oldDb.run(` CREATE TABLE records ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -704,25 +704,25 @@ describe("old-schema migration", () => { links TEXT ) `); - oldDb.run(` + oldDb.run(` CREATE TABLE bodies ( recordId TEXT PRIMARY KEY REFERENCES records(id), body TEXT NOT NULL ) `); - oldDb.close(); - - const store = createTraceStore({ path }); - store.close(); - - const db = new Database(path); - const indexes = db - .query("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_records_bodyHash'") - .all() as Array<{ name: string }>; - expect(indexes).toHaveLength(1); - db.close(); - } finally { - cleanup(path); - } - }); + oldDb.close(); + + const store = createTraceStore({ path }); + store.close(); + + const db = new Database(path); + const indexes = db + .query("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_records_bodyHash'") + .all() as Array<{ name: string }>; + expect(indexes).toHaveLength(1); + db.close(); + } finally { + cleanup(path); + } + }); }); diff --git a/packages/trace-store/src/store.ts b/packages/trace-store/src/store.ts index f9564bb..2a9724f 100644 --- a/packages/trace-store/src/store.ts +++ b/packages/trace-store/src/store.ts @@ -6,58 +6,58 @@ import { renderEasyView } from "./easy-view.js"; const COMPRESS_THRESHOLD_BYTES = 1024; export interface RetentionPolicy { - readonly maxAgeMs?: number; - readonly maxTotalBodyBytes?: number; + readonly maxAgeMs?: number; + readonly maxTotalBodyBytes?: number; } export const DEFAULT_RETENTION: Required = { - maxAgeMs: 7 * 24 * 60 * 60 * 1000, - maxTotalBodyBytes: 256 * 1024 * 1024, + maxAgeMs: 7 * 24 * 60 * 60 * 1000, + maxTotalBodyBytes: 256 * 1024 * 1024, }; export interface PruneSummary { - recordsDeleted: number; - bodiesDeleted: number; - bytesReclaimed: number; + recordsDeleted: number; + bodiesDeleted: number; + bytesReclaimed: number; } export interface TraceStore { - insertRecords(records: readonly LogRecord[]): void; - getTurn(turnId: string): LogRecord[]; - getBody(recordId: string): string | undefined; - easyView(turnId: string): string; - prune(policy: RetentionPolicy): PruneSummary; - close(): void; + insertRecords(records: readonly LogRecord[]): void; + getTurn(turnId: string): LogRecord[]; + getBody(recordId: string): string | undefined; + easyView(turnId: string): string; + prune(policy: RetentionPolicy): PruneSummary; + close(): void; } export function createTraceStore(opts: { path: string }): TraceStore { - const db = new Database(opts.path); - db.run("PRAGMA journal_mode = WAL"); - schema(db); - return { - insertRecords(records) { - insertRecords(db, records); - }, - getTurn(turnId) { - return getTurn(db, turnId); - }, - getBody(recordId) { - return getBody(db, recordId); - }, - easyView(turnId) { - return renderEasyView(getTurn(db, turnId)); - }, - prune(policy) { - return prune(db, policy); - }, - close() { - db.close(); - }, - }; + const db = new Database(opts.path); + db.run("PRAGMA journal_mode = WAL"); + schema(db); + return { + insertRecords(records) { + insertRecords(db, records); + }, + getTurn(turnId) { + return getTurn(db, turnId); + }, + getBody(recordId) { + return getBody(db, recordId); + }, + easyView(turnId) { + return renderEasyView(getTurn(db, turnId)); + }, + prune(policy) { + return prune(db, policy); + }, + close() { + db.close(); + }, + }; } function schema(db: Database): void { - db.run(` + db.run(` CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -77,15 +77,15 @@ function schema(db: Database): void { bodyHash TEXT ) `); - db.run("CREATE INDEX IF NOT EXISTS idx_records_turnId ON records(turnId)"); - db.run("CREATE INDEX IF NOT EXISTS idx_records_conversationId ON records(conversationId)"); - db.run("CREATE INDEX IF NOT EXISTS idx_records_spanId ON records(spanId)"); - db.run("CREATE INDEX IF NOT EXISTS idx_records_kind ON records(kind)"); - db.run("CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records(timestamp)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_turnId ON records(turnId)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_conversationId ON records(conversationId)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_spanId ON records(spanId)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_kind ON records(kind)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records(timestamp)"); - migrateOldBodies(db); + migrateOldBodies(db); - db.run(` + db.run(` CREATE TABLE IF NOT EXISTS bodies ( hash TEXT PRIMARY KEY, body BLOB NOT NULL, @@ -95,33 +95,33 @@ function schema(db: Database): void { ) `); - db.run("CREATE INDEX IF NOT EXISTS idx_records_bodyHash ON records(bodyHash)"); + db.run("CREATE INDEX IF NOT EXISTS idx_records_bodyHash ON records(bodyHash)"); } function migrateOldBodies(db: Database): void { - const hasOldTable = db - .query("SELECT name FROM sqlite_master WHERE type='table' AND name='bodies_old'") - .get() as { name: string } | null; - if (hasOldTable !== null) { - return; - } - - const cols = db.query("PRAGMA table_info(bodies)").all() as Array<{ - name: string; - }>; - const hasRecordId = cols.some((c) => c.name === "recordId"); - if (!hasRecordId) { - return; - } - - const oldRows = db.query("SELECT recordId, body FROM bodies").all() as Array<{ - recordId: string; - body: string; - }>; - - db.run("ALTER TABLE bodies RENAME TO bodies_old"); - - db.run(` + const hasOldTable = db + .query("SELECT name FROM sqlite_master WHERE type='table' AND name='bodies_old'") + .get() as { name: string } | null; + if (hasOldTable !== null) { + return; + } + + const cols = db.query("PRAGMA table_info(bodies)").all() as Array<{ + name: string; + }>; + const hasRecordId = cols.some((c) => c.name === "recordId"); + if (!hasRecordId) { + return; + } + + const oldRows = db.query("SELECT recordId, body FROM bodies").all() as Array<{ + recordId: string; + body: string; + }>; + + db.run("ALTER TABLE bodies RENAME TO bodies_old"); + + db.run(` CREATE TABLE IF NOT EXISTS bodies ( hash TEXT PRIMARY KEY, body BLOB NOT NULL, @@ -131,195 +131,195 @@ function migrateOldBodies(db: Database): void { ) `); - const hasBodyHash = cols.some((c) => c.name === "bodyHash"); - if (!hasBodyHash) { - db.run("ALTER TABLE records ADD COLUMN bodyHash TEXT"); - } + const hasBodyHash = cols.some((c) => c.name === "bodyHash"); + if (!hasBodyHash) { + db.run("ALTER TABLE records ADD COLUMN bodyHash TEXT"); + } - const upsertBody = db.prepare(` + const upsertBody = db.prepare(` INSERT OR IGNORE INTO bodies (hash, body, isCompressed, originalSize, storedSize) VALUES (?, ?, 0, ?, ?) `); - const updateRecord = db.prepare("UPDATE records SET bodyHash = ? WHERE id = ?"); - - const migrateTxn = db.transaction(() => { - for (const row of oldRows) { - const hash = contentHash(row.body); - const bodyBytes = new TextEncoder().encode(row.body); - upsertBody.run(hash, bodyBytes, bodyBytes.length, bodyBytes.length); - updateRecord.run(hash, row.recordId); - } - db.run("DROP TABLE bodies_old"); - }); - migrateTxn(); + const updateRecord = db.prepare("UPDATE records SET bodyHash = ? WHERE id = ?"); + + const migrateTxn = db.transaction(() => { + for (const row of oldRows) { + const hash = contentHash(row.body); + const bodyBytes = new TextEncoder().encode(row.body); + upsertBody.run(hash, bodyBytes, bodyBytes.length, bodyBytes.length); + updateRecord.run(hash, row.recordId); + } + db.run("DROP TABLE bodies_old"); + }); + migrateTxn(); } function sha256Hex(input: string): string { - const data = new TextEncoder().encode(input); - let h0 = 0x6a09e667; - let h1 = 0xbb67ae85; - let h2 = 0x3c6ef372; - let h3 = 0xa54ff53a; - let h4 = 0x510e527f; - let h5 = 0x9b05688c; - let h6 = 0x1f83d9ab; - let h7 = 0x5be0cd19; - - const msgLen = data.length; - const bitLen = msgLen * 8; - const withOne = msgLen + 1; - const paddedLen = withOne + ((96 - (withOne % 64)) % 64) + 8; - const padded = new Uint8Array(paddedLen); - padded.set(data); - padded[msgLen] = 0x80; - padded[paddedLen - 8] = (bitLen / 0x100000000) >>> 0; - padded[paddedLen - 4] = bitLen >>> 0; - - const kArr = new Uint32Array(K); - - for (let offset = 0; offset < paddedLen; offset += 64) { - const w = new Uint32Array(64); - for (let i = 0; i < 16; i++) { - const o = offset + i * 4; - const b0 = padded[o] ?? 0; - const b1 = padded[o + 1] ?? 0; - const b2 = padded[o + 2] ?? 0; - const b3 = padded[o + 3] ?? 0; - w[i] = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; - } - for (let i = 16; i < 64; i++) { - const prev15 = w[i - 15] ?? 0; - const prev2 = w[i - 2] ?? 0; - const prev16 = w[i - 16] ?? 0; - const prev7 = w[i - 7] ?? 0; - const s0 = rightRotate(prev15, 7) ^ rightRotate(prev15, 18) ^ (prev15 >>> 3); - const s1 = rightRotate(prev2, 17) ^ rightRotate(prev2, 19) ^ (prev2 >>> 10); - w[i] = (prev16 + s0 + prev7 + s1) | 0; - } - - let a = h0; - let b = h1; - let c = h2; - let d = h3; - let e = h4; - let f = h5; - let g = h6; - let h = h7; - - for (let i = 0; i < 64; i++) { - const S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); - const ch = (e & f) ^ (~e & g); - const ki = kArr[i] ?? 0; - const wi = w[i] ?? 0; - const temp1 = (h + S1 + ch + ki + wi) | 0; - const S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); - const maj = (a & b) ^ (a & c) ^ (b & c); - const temp2 = (S0 + maj) | 0; - - h = g; - g = f; - f = e; - e = (d + temp1) | 0; - d = c; - c = b; - b = a; - a = (temp1 + temp2) | 0; - } - - h0 = (h0 + a) | 0; - h1 = (h1 + b) | 0; - h2 = (h2 + c) | 0; - h3 = (h3 + d) | 0; - h4 = (h4 + e) | 0; - h5 = (h5 + f) | 0; - h6 = (h6 + g) | 0; - h7 = (h7 + h) | 0; - } - - return ( - toHex32(h0) + - toHex32(h1) + - toHex32(h2) + - toHex32(h3) + - toHex32(h4) + - toHex32(h5) + - toHex32(h6) + - toHex32(h7) - ); + const data = new TextEncoder().encode(input); + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const msgLen = data.length; + const bitLen = msgLen * 8; + const withOne = msgLen + 1; + const paddedLen = withOne + ((96 - (withOne % 64)) % 64) + 8; + const padded = new Uint8Array(paddedLen); + padded.set(data); + padded[msgLen] = 0x80; + padded[paddedLen - 8] = (bitLen / 0x100000000) >>> 0; + padded[paddedLen - 4] = bitLen >>> 0; + + const kArr = new Uint32Array(K); + + for (let offset = 0; offset < paddedLen; offset += 64) { + const w = new Uint32Array(64); + for (let i = 0; i < 16; i++) { + const o = offset + i * 4; + const b0 = padded[o] ?? 0; + const b1 = padded[o + 1] ?? 0; + const b2 = padded[o + 2] ?? 0; + const b3 = padded[o + 3] ?? 0; + w[i] = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; + } + for (let i = 16; i < 64; i++) { + const prev15 = w[i - 15] ?? 0; + const prev2 = w[i - 2] ?? 0; + const prev16 = w[i - 16] ?? 0; + const prev7 = w[i - 7] ?? 0; + const s0 = rightRotate(prev15, 7) ^ rightRotate(prev15, 18) ^ (prev15 >>> 3); + const s1 = rightRotate(prev2, 17) ^ rightRotate(prev2, 19) ^ (prev2 >>> 10); + w[i] = (prev16 + s0 + prev7 + s1) | 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let i = 0; i < 64; i++) { + const S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + const ch = (e & f) ^ (~e & g); + const ki = kArr[i] ?? 0; + const wi = w[i] ?? 0; + const temp1 = (h + S1 + ch + ki + wi) | 0; + const S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) | 0; + + h = g; + g = f; + f = e; + e = (d + temp1) | 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) | 0; + } + + h0 = (h0 + a) | 0; + h1 = (h1 + b) | 0; + h2 = (h2 + c) | 0; + h3 = (h3 + d) | 0; + h4 = (h4 + e) | 0; + h5 = (h5 + f) | 0; + h6 = (h6 + g) | 0; + h7 = (h7 + h) | 0; + } + + return ( + toHex32(h0) + + toHex32(h1) + + toHex32(h2) + + toHex32(h3) + + toHex32(h4) + + toHex32(h5) + + toHex32(h6) + + toHex32(h7) + ); } function rightRotate(x: number, n: number): number { - return ((x >>> n) | (x << (32 - n))) >>> 0; + return ((x >>> n) | (x << (32 - n))) >>> 0; } function toHex32(n: number): string { - return (n >>> 0).toString(16).padStart(8, "0"); + return (n >>> 0).toString(16).padStart(8, "0"); } const K = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]; function contentHash(body: string): string { - return sha256Hex(body); + return sha256Hex(body); } function compressBody(body: string): { stored: Uint8Array; isCompressed: boolean } { - const raw = new TextEncoder().encode(body); - if (raw.length <= COMPRESS_THRESHOLD_BYTES) { - return { stored: raw, isCompressed: false }; - } - const compressed = gzipSync(raw); - if (compressed.length >= raw.length) { - return { stored: raw, isCompressed: false }; - } - return { stored: compressed, isCompressed: true }; + const raw = new TextEncoder().encode(body); + if (raw.length <= COMPRESS_THRESHOLD_BYTES) { + return { stored: raw, isCompressed: false }; + } + const compressed = gzipSync(raw); + if (compressed.length >= raw.length) { + return { stored: raw, isCompressed: false }; + } + return { stored: compressed, isCompressed: true }; } function decompressBody(stored: Uint8Array, isCompressed: boolean): string { - if (!isCompressed) { - return new TextDecoder().decode(stored); - } - const decompressed = gunzipSync(stored); - return new TextDecoder().decode(decompressed); + if (!isCompressed) { + return new TextDecoder().decode(stored); + } + const decompressed = gunzipSync(stored); + return new TextDecoder().decode(decompressed); } function storeBody(db: Database, body: string): string { - const hash = contentHash(body); - const existing = db.query("SELECT 1 FROM bodies WHERE hash = ?").get(hash) as unknown; - if (existing !== null) { - return hash; - } - const { stored, isCompressed } = compressBody(body); - db.prepare( - "INSERT OR IGNORE INTO bodies (hash, body, isCompressed, originalSize, storedSize) VALUES (?, ?, ?, ?, ?)", - ).run(hash, stored, isCompressed ? 1 : 0, new TextEncoder().encode(body).length, stored.length); - return hash; + const hash = contentHash(body); + const existing = db.query("SELECT 1 FROM bodies WHERE hash = ?").get(hash) as unknown; + if (existing !== null) { + return hash; + } + const { stored, isCompressed } = compressBody(body); + db.prepare( + "INSERT OR IGNORE INTO bodies (hash, body, isCompressed, originalSize, storedSize) VALUES (?, ?, ?, ?, ?)", + ).run(hash, stored, isCompressed ? 1 : 0, new TextEncoder().encode(body).length, stored.length); + return hash; } function resolveBody(db: Database, hash: string | null): string | undefined { - if (hash === null) { - return undefined; - } - const row = db.query("SELECT body, isCompressed FROM bodies WHERE hash = ?").get(hash) as { - body: Uint8Array; - isCompressed: number; - } | null; - if (row === undefined || row === null) { - return undefined; - } - return decompressBody(row.body, row.isCompressed === 1); + if (hash === null) { + return undefined; + } + const row = db.query("SELECT body, isCompressed FROM bodies WHERE hash = ?").get(hash) as { + body: Uint8Array; + isCompressed: number; + } | null; + if (row === undefined || row === null) { + return undefined; + } + return decompressBody(row.body, row.isCompressed === 1); } function insertRecords(db: Database, records: readonly LogRecord[]): void { - const recStmt = db.prepare(` + const recStmt = db.prepare(` INSERT OR IGNORE INTO records (id, kind, level, msg, name, spanId, parentSpanId, conversationId, turnId, extensionId, timestamp, @@ -328,294 +328,294 @@ function insertRecords(db: Database, records: readonly LogRecord[]): void { (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - const txn = db.transaction(() => { - for (const r of records) { - const id = stableId(r); - const kind = r.kind; - let level: string | null = null; - let msg: string | null = null; - let name: string | null = null; - let spanId: string | null = null; - let parentSpanId: string | null = null; - let durationMs: number | null = null; - let status: string | null = null; - let links: string | null = null; - - if (r.kind === "log") { - level = r.level; - msg = r.msg; - spanId = r.spanId ?? null; - parentSpanId = r.parentSpanId ?? null; - } else if (r.kind === "span-open") { - name = r.name; - spanId = r.spanId; - parentSpanId = r.parentSpanId ?? null; - if (r.links !== undefined) { - links = JSON.stringify(r.links); - } - } else { - name = r.name; - spanId = r.spanId; - parentSpanId = r.parentSpanId ?? null; - durationMs = r.durationMs; - status = r.status; - if (r.links !== undefined) { - links = JSON.stringify(r.links); - } - } - - const attributes: string | null = - r.attributes !== undefined ? JSON.stringify(r.attributes) : null; - - let bodyHash: string | null = null; - if (r.body !== undefined) { - bodyHash = storeBody(db, r.body); - } - - recStmt.run( - id, - kind, - level, - msg, - name, - spanId, - parentSpanId, - r.conversationId ?? null, - r.turnId ?? null, - r.extensionId, - r.timestamp, - durationMs, - status, - attributes, - links, - bodyHash, - ); - } - }); - txn(); + const txn = db.transaction(() => { + for (const r of records) { + const id = stableId(r); + const kind = r.kind; + let level: string | null = null; + let msg: string | null = null; + let name: string | null = null; + let spanId: string | null = null; + let parentSpanId: string | null = null; + let durationMs: number | null = null; + let status: string | null = null; + let links: string | null = null; + + if (r.kind === "log") { + level = r.level; + msg = r.msg; + spanId = r.spanId ?? null; + parentSpanId = r.parentSpanId ?? null; + } else if (r.kind === "span-open") { + name = r.name; + spanId = r.spanId; + parentSpanId = r.parentSpanId ?? null; + if (r.links !== undefined) { + links = JSON.stringify(r.links); + } + } else { + name = r.name; + spanId = r.spanId; + parentSpanId = r.parentSpanId ?? null; + durationMs = r.durationMs; + status = r.status; + if (r.links !== undefined) { + links = JSON.stringify(r.links); + } + } + + const attributes: string | null = + r.attributes !== undefined ? JSON.stringify(r.attributes) : null; + + let bodyHash: string | null = null; + if (r.body !== undefined) { + bodyHash = storeBody(db, r.body); + } + + recStmt.run( + id, + kind, + level, + msg, + name, + spanId, + parentSpanId, + r.conversationId ?? null, + r.turnId ?? null, + r.extensionId, + r.timestamp, + durationMs, + status, + attributes, + links, + bodyHash, + ); + } + }); + txn(); } interface RecordRow { - id: string; - kind: string; - level: string | null; - msg: string | null; - name: string | null; - spanId: string | null; - parentSpanId: string | null; - conversationId: string | null; - turnId: string | null; - extensionId: string; - timestamp: number; - durationMs: number | null; - status: string | null; - attributes: string | null; - links: string | null; - bodyHash: string | null; + id: string; + kind: string; + level: string | null; + msg: string | null; + name: string | null; + spanId: string | null; + parentSpanId: string | null; + conversationId: string | null; + turnId: string | null; + extensionId: string; + timestamp: number; + durationMs: number | null; + status: string | null; + attributes: string | null; + links: string | null; + bodyHash: string | null; } function getTurn(db: Database, turnId: string): LogRecord[] { - const rows = db - .query("SELECT * FROM records WHERE turnId = ? ORDER BY timestamp ASC, rowid ASC") - .all(turnId) as RecordRow[]; - return rows.map((row) => rowToRecord(db, row)); + const rows = db + .query("SELECT * FROM records WHERE turnId = ? ORDER BY timestamp ASC, rowid ASC") + .all(turnId) as RecordRow[]; + return rows.map((row) => rowToRecord(db, row)); } function getBody(db: Database, recordId: string): string | undefined { - const row = db.query("SELECT bodyHash FROM records WHERE id = ?").get(recordId) as { - bodyHash: string | null; - } | null; - if (row === undefined || row === null || row.bodyHash === null) { - return undefined; - } - return resolveBody(db, row.bodyHash); + const row = db.query("SELECT bodyHash FROM records WHERE id = ?").get(recordId) as { + bodyHash: string | null; + } | null; + if (row === undefined || row === null || row.bodyHash === null) { + return undefined; + } + return resolveBody(db, row.bodyHash); } function rowToRecord(db: Database, row: RecordRow): LogRecord { - const attributes: Attributes | undefined = - row.attributes !== null ? JSON.parse(row.attributes) : undefined; - const links: SpanLink[] | undefined = row.links !== null ? JSON.parse(row.links) : undefined; - const body: string | undefined = resolveBody(db, row.bodyHash); - - if (row.kind === "log") { - const record: LogRecord = { - kind: "log", - level: row.level as "debug" | "info" | "warn" | "error", - msg: row.msg ?? "", - timestamp: row.timestamp, - extensionId: row.extensionId, - ...(row.conversationId !== null && { conversationId: row.conversationId }), - ...(row.turnId !== null && { turnId: row.turnId }), - ...(row.spanId !== null && { spanId: row.spanId }), - ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), - ...(attributes !== undefined && { attributes }), - ...(body !== undefined && { body }), - }; - return record; - } - - if (row.kind === "span-open") { - const record: LogRecord = { - kind: "span-open", - spanId: row.spanId ?? "", - name: row.name ?? "", - timestamp: row.timestamp, - extensionId: row.extensionId, - ...(row.conversationId !== null && { conversationId: row.conversationId }), - ...(row.turnId !== null && { turnId: row.turnId }), - ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), - ...(attributes !== undefined && { attributes }), - ...(links !== undefined && { links }), - ...(body !== undefined && { body }), - }; - return record; - } - - const record: LogRecord = { - kind: "span-close", - spanId: row.spanId ?? "", - name: row.name ?? "", - timestamp: row.timestamp, - durationMs: row.durationMs ?? 0, - status: (row.status as "ok" | "error") ?? "ok", - extensionId: row.extensionId, - ...(row.conversationId !== null && { conversationId: row.conversationId }), - ...(row.turnId !== null && { turnId: row.turnId }), - ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), - ...(attributes !== undefined && { attributes }), - ...(links !== undefined && { links }), - ...(body !== undefined && { body }), - }; - return record; + const attributes: Attributes | undefined = + row.attributes !== null ? JSON.parse(row.attributes) : undefined; + const links: SpanLink[] | undefined = row.links !== null ? JSON.parse(row.links) : undefined; + const body: string | undefined = resolveBody(db, row.bodyHash); + + if (row.kind === "log") { + const record: LogRecord = { + kind: "log", + level: row.level as "debug" | "info" | "warn" | "error", + msg: row.msg ?? "", + timestamp: row.timestamp, + extensionId: row.extensionId, + ...(row.conversationId !== null && { conversationId: row.conversationId }), + ...(row.turnId !== null && { turnId: row.turnId }), + ...(row.spanId !== null && { spanId: row.spanId }), + ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), + ...(attributes !== undefined && { attributes }), + ...(body !== undefined && { body }), + }; + return record; + } + + if (row.kind === "span-open") { + const record: LogRecord = { + kind: "span-open", + spanId: row.spanId ?? "", + name: row.name ?? "", + timestamp: row.timestamp, + extensionId: row.extensionId, + ...(row.conversationId !== null && { conversationId: row.conversationId }), + ...(row.turnId !== null && { turnId: row.turnId }), + ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), + ...(attributes !== undefined && { attributes }), + ...(links !== undefined && { links }), + ...(body !== undefined && { body }), + }; + return record; + } + + const record: LogRecord = { + kind: "span-close", + spanId: row.spanId ?? "", + name: row.name ?? "", + timestamp: row.timestamp, + durationMs: row.durationMs ?? 0, + status: (row.status as "ok" | "error") ?? "ok", + extensionId: row.extensionId, + ...(row.conversationId !== null && { conversationId: row.conversationId }), + ...(row.turnId !== null && { turnId: row.turnId }), + ...(row.parentSpanId !== null && { parentSpanId: row.parentSpanId }), + ...(attributes !== undefined && { attributes }), + ...(links !== undefined && { links }), + ...(body !== undefined && { body }), + }; + return record; } interface BodyRow { - hash: string; - storedSize: number; + hash: string; + storedSize: number; } interface BodyWithTimestamp extends BodyRow { - oldestRecordTimestamp: number; + oldestRecordTimestamp: number; } export function computeEvictions( - bodies: readonly BodyWithTimestamp[], - maxTotalBodyBytes: number, + bodies: readonly BodyWithTimestamp[], + maxTotalBodyBytes: number, ): string[] { - let totalBytes = 0; - for (const b of bodies) { - totalBytes += b.storedSize; - } - if (totalBytes <= maxTotalBodyBytes) { - return []; - } - - const sorted = [...bodies].sort((a, b) => a.oldestRecordTimestamp - b.oldestRecordTimestamp); - const evict: string[] = []; - let remaining = totalBytes; - for (const b of sorted) { - if (remaining <= maxTotalBodyBytes) { - break; - } - evict.push(b.hash); - remaining -= b.storedSize; - } - return evict; + let totalBytes = 0; + for (const b of bodies) { + totalBytes += b.storedSize; + } + if (totalBytes <= maxTotalBodyBytes) { + return []; + } + + const sorted = [...bodies].sort((a, b) => a.oldestRecordTimestamp - b.oldestRecordTimestamp); + const evict: string[] = []; + let remaining = totalBytes; + for (const b of sorted) { + if (remaining <= maxTotalBodyBytes) { + break; + } + evict.push(b.hash); + remaining -= b.storedSize; + } + return evict; } function prune(db: Database, policy: RetentionPolicy): PruneSummary { - let recordsDeleted = 0; - let bodiesDeleted = 0; - let bytesReclaimed = 0; - - const now = Date.now(); - - if (policy.maxAgeMs !== undefined) { - const cutoff = now - policy.maxAgeMs; - const oldRecords = db - .query("SELECT id, bodyHash FROM records WHERE timestamp < ?") - .all(cutoff) as Array<{ id: string; bodyHash: string | null }>; - - if (oldRecords.length > 0) { - const bodyHashes = oldRecords.map((r) => r.bodyHash).filter((h): h is string => h !== null); - - const deleteTxn = db.transaction(() => { - db.prepare("DELETE FROM records WHERE timestamp < ?").run(cutoff); - for (const hash of bodyHashes) { - const refCount = db - .query("SELECT COUNT(*) as cnt FROM records WHERE bodyHash = ?") - .get(hash) as { cnt: number }; - if (refCount.cnt === 0) { - const bodyRow = db.query("SELECT storedSize FROM bodies WHERE hash = ?").get(hash) as { - storedSize: number; - } | null; - if (bodyRow !== undefined && bodyRow !== null) { - bytesReclaimed += bodyRow.storedSize; - } - db.prepare("DELETE FROM bodies WHERE hash = ?").run(hash); - bodiesDeleted++; - } - } - }); - deleteTxn(); - recordsDeleted = oldRecords.length; - } - } - - if (policy.maxTotalBodyBytes !== undefined) { - const bodyRows = db - .query(` + let recordsDeleted = 0; + let bodiesDeleted = 0; + let bytesReclaimed = 0; + + const now = Date.now(); + + if (policy.maxAgeMs !== undefined) { + const cutoff = now - policy.maxAgeMs; + const oldRecords = db + .query("SELECT id, bodyHash FROM records WHERE timestamp < ?") + .all(cutoff) as Array<{ id: string; bodyHash: string | null }>; + + if (oldRecords.length > 0) { + const bodyHashes = oldRecords.map((r) => r.bodyHash).filter((h): h is string => h !== null); + + const deleteTxn = db.transaction(() => { + db.prepare("DELETE FROM records WHERE timestamp < ?").run(cutoff); + for (const hash of bodyHashes) { + const refCount = db + .query("SELECT COUNT(*) as cnt FROM records WHERE bodyHash = ?") + .get(hash) as { cnt: number }; + if (refCount.cnt === 0) { + const bodyRow = db.query("SELECT storedSize FROM bodies WHERE hash = ?").get(hash) as { + storedSize: number; + } | null; + if (bodyRow !== undefined && bodyRow !== null) { + bytesReclaimed += bodyRow.storedSize; + } + db.prepare("DELETE FROM bodies WHERE hash = ?").run(hash); + bodiesDeleted++; + } + } + }); + deleteTxn(); + recordsDeleted = oldRecords.length; + } + } + + if (policy.maxTotalBodyBytes !== undefined) { + const bodyRows = db + .query(` SELECT b.hash, b.storedSize, MIN(r.timestamp) as oldestRecordTimestamp FROM bodies b JOIN records r ON r.bodyHash = b.hash GROUP BY b.hash `) - .all() as Array<{ hash: string; storedSize: number; oldestRecordTimestamp: number }>; - - const toEvict = computeEvictions(bodyRows, policy.maxTotalBodyBytes); - - if (toEvict.length > 0) { - const evictTxn = db.transaction(() => { - for (const hash of toEvict) { - const bodyRow = db.query("SELECT storedSize FROM bodies WHERE hash = ?").get(hash) as { - storedSize: number; - } | null; - if (bodyRow !== undefined && bodyRow !== null) { - bytesReclaimed += bodyRow.storedSize; - } - db.prepare("DELETE FROM records WHERE bodyHash = ?").run(hash); - db.prepare("DELETE FROM bodies WHERE hash = ?").run(hash); - bodiesDeleted++; - } - }); - evictTxn(); - recordsDeleted += toEvict.length; - } - } - - return { recordsDeleted, bodiesDeleted, bytesReclaimed }; + .all() as Array<{ hash: string; storedSize: number; oldestRecordTimestamp: number }>; + + const toEvict = computeEvictions(bodyRows, policy.maxTotalBodyBytes); + + if (toEvict.length > 0) { + const evictTxn = db.transaction(() => { + for (const hash of toEvict) { + const bodyRow = db.query("SELECT storedSize FROM bodies WHERE hash = ?").get(hash) as { + storedSize: number; + } | null; + if (bodyRow !== undefined && bodyRow !== null) { + bytesReclaimed += bodyRow.storedSize; + } + db.prepare("DELETE FROM records WHERE bodyHash = ?").run(hash); + db.prepare("DELETE FROM bodies WHERE hash = ?").run(hash); + bodiesDeleted++; + } + }); + evictTxn(); + recordsDeleted += toEvict.length; + } + } + + return { recordsDeleted, bodiesDeleted, bytesReclaimed }; } function toCanonicalJson(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map(toCanonicalJson).join(",")}]`; - } - const obj = value as Record; - const keys = Object.keys(obj).sort(); - const entries = keys.map((k) => `${JSON.stringify(k)}:${toCanonicalJson(obj[k])}`); - return `{${entries.join(",")}}`; + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(toCanonicalJson).join(",")}]`; + } + const obj = value as Record; + const keys = Object.keys(obj).sort(); + const entries = keys.map((k) => `${JSON.stringify(k)}:${toCanonicalJson(obj[k])}`); + return `{${entries.join(",")}}`; } export function stableId(record: LogRecord): string { - const json = toCanonicalJson(record); - let hash = 0xcbf29ce484222325n; - const prime = 0x100000001b3n; - for (let i = 0; i < json.length; i++) { - hash ^= BigInt(json.charCodeAt(i)); - hash = (hash * prime) & 0xffffffffffffffffn; - } - return hash.toString(16).padStart(16, "0"); + const json = toCanonicalJson(record); + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let i = 0; i < json.length; i++) { + hash ^= BigInt(json.charCodeAt(i)); + hash = (hash * prime) & 0xffffffffffffffffn; + } + return hash.toString(16).padStart(16, "0"); } diff --git a/packages/trace-store/tsconfig.json b/packages/trace-store/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/trace-store/tsconfig.json +++ b/packages/trace-store/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 842a28b..0b38cb8 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/transport-contract", - "version": "0.22.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/ui-contract": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/transport-contract", + "version": "0.22.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/ui-contract": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts index 0aad643..9d3d904 100644 --- a/packages/transport-contract/src/contract.types.test.ts +++ b/packages/transport-contract/src/contract.types.test.ts @@ -8,71 +8,71 @@ import { describe, expect, it } from "vitest"; import type { - ChatRequest, - Computer, - ComputerEntry, - ComputerListResponse, - ComputerResponse, - ComputerStatusResponse, - ConversationComputerResponse, - CwdResponse, - LspServerInfo, - LspServerState, - LspStatusResponse, - McpStatusResponse, - SetConversationComputerRequest, - SetCwdRequest, - SetWorkspaceDefaultComputerRequest, - TestComputerResponse, + ChatRequest, + Computer, + ComputerEntry, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, + CwdResponse, + LspServerInfo, + LspServerState, + LspStatusResponse, + McpStatusResponse, + SetConversationComputerRequest, + SetCwdRequest, + SetWorkspaceDefaultComputerRequest, + TestComputerResponse, } from "./index.js"; // ─── CwdResponse ───────────────────────────────────────────────────────────── const _cwdNull: CwdResponse = { - conversationId: "conv-1", - cwd: null, + conversationId: "conv-1", + cwd: null, }; const _cwdSet: CwdResponse = { - conversationId: "conv-2", - cwd: "/home/user/project", + conversationId: "conv-2", + cwd: "/home/user/project", }; // ─── SetCwdRequest ─────────────────────────────────────────────────────────── const _setCwd: SetCwdRequest = { - cwd: "/tmp/workspace", + cwd: "/tmp/workspace", }; // ─── ChatRequest.computerId (additive optional) ────────────────────────────── const _chatWithComputer: ChatRequest = { - message: "run the test suite", - computerId: "prod-box", + message: "run the test suite", + computerId: "prod-box", }; const _chatWithoutComputer: ChatRequest = { - message: "hello", + message: "hello", }; // ─── Computer list / single response ───────────────────────────────────────── const _computer: Computer = { - alias: "prod-box", - hostName: "10.0.0.5", - port: 22, - user: "deploy", - identityFile: "/home/user/.ssh/id_ed25519", - knownHost: true, + alias: "prod-box", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, }; const _computerEntry: ComputerEntry = { - ..._computer, - usageCount: 3, + ..._computer, + usageCount: 3, }; const _computerList: ComputerListResponse = { - computers: [_computerEntry], + computers: [_computerEntry], }; const _computerResponse: ComputerResponse = _computer; @@ -80,51 +80,51 @@ const _computerResponse: ComputerResponse = _computer; // ─── Computer status / test probe ──────────────────────────────────────────── const _statusConnected: ComputerStatusResponse = { - alias: "prod-box", - state: "connected", - knownHost: true, + alias: "prod-box", + state: "connected", + knownHost: true, }; const _statusError: ComputerStatusResponse = { - alias: "prod-box", - state: "error", - error: "connection refused", - knownHost: false, + alias: "prod-box", + state: "error", + error: "connection refused", + knownHost: false, }; const _testOk: TestComputerResponse = { - alias: "prod-box", - ok: true, + alias: "prod-box", + ok: true, }; const _testFail: TestComputerResponse = { - alias: "prod-box", - ok: false, - error: "auth failed", + alias: "prod-box", + ok: false, + error: "auth failed", }; // ─── Per-conversation + workspace computer ─────────────────────────────────── const _setConvComputer: SetConversationComputerRequest = { - computerId: "prod-box", + computerId: "prod-box", }; const _clearConvComputer: SetConversationComputerRequest = { - computerId: null, + computerId: null, }; const _convComputer: ConversationComputerResponse = { - conversationId: "conv-1", - computerId: "prod-box", + conversationId: "conv-1", + computerId: "prod-box", }; const _convComputerNull: ConversationComputerResponse = { - conversationId: "conv-2", - computerId: null, + conversationId: "conv-2", + computerId: null, }; const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = { - computerId: null, + computerId: null, }; // ─── LspServerState ────────────────────────────────────────────────────────── @@ -137,178 +137,178 @@ const _stateNotStarted: LspServerState = "not-started"; // ─── LspServerInfo ─────────────────────────────────────────────────────────── const _serverOk: LspServerInfo = { - id: "typescript", - name: "TypeScript Language Server", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected", + id: "typescript", + name: "TypeScript Language Server", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected", }; const _serverErr: LspServerInfo = { - id: "luau-lsp", - name: "Luau LSP", - root: "/home/user/game", - extensions: [".luau"], - state: "error", - error: "Failed to start: binary not found", + id: "luau-lsp", + name: "Luau LSP", + root: "/home/user/game", + extensions: [".luau"], + state: "error", + error: "Failed to start: binary not found", }; const _serverWithSource: LspServerInfo = { - id: "ruby-lsp", - name: "Ruby LSP", - root: "/home/user/raylib", - extensions: [".rb"], - state: "connected", - configSource: ".dispatch/lsp.json", + id: "ruby-lsp", + name: "Ruby LSP", + root: "/home/user/raylib", + extensions: [".rb"], + state: "connected", + configSource: ".dispatch/lsp.json", }; // ─── LspStatusResponse ─────────────────────────────────────────────────────── const _lspNoCwd: LspStatusResponse = { - conversationId: "conv-3", - cwd: null, - servers: [], + conversationId: "conv-3", + cwd: null, + servers: [], }; const _lspWithServers: LspStatusResponse = { - conversationId: "conv-4", - cwd: "/home/user/project", - servers: [_serverOk, _serverErr], + conversationId: "conv-4", + cwd: "/home/user/project", + servers: [_serverOk, _serverErr], }; // ─── Runtime smoke (vitest needs a suite) ──────────────────────────────────── describe("transport-contract types compile and are exported", () => { - it("CwdResponse: null cwd round-trips", () => { - expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null }); - }); - - it("CwdResponse: set cwd round-trips", () => { - expect(_cwdSet.cwd).toBe("/home/user/project"); - }); - - it("SetCwdRequest: carries cwd", () => { - expect(_setCwd.cwd).toBe("/tmp/workspace"); - }); - - it("LspServerState: all four variants are valid", () => { - const states: LspServerState[] = [ - _stateConnected, - _stateStarting, - _stateError, - _stateNotStarted, - ]; - expect(states).toHaveLength(4); - }); - - it("LspServerInfo: ok server has no error field", () => { - expect(_serverOk.state).toBe("connected"); - expect(_serverOk.error).toBeUndefined(); - }); - - it("LspServerInfo: error server carries error message", () => { - expect(_serverErr.state).toBe("error"); - expect(_serverErr.error).toBe("Failed to start: binary not found"); - }); - - it("LspServerInfo: carries optional configSource", () => { - expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json"); - expect(_serverOk.configSource).toBeUndefined(); - }); - - it("LspStatusResponse: empty servers when cwd is null", () => { - expect(_lspNoCwd.servers).toEqual([]); - }); - - it("LspStatusResponse: populated servers when cwd is set", () => { - expect(_lspWithServers.servers).toHaveLength(2); - }); - - // ─── MCP status ───────────────────────────────────────────────────────────── - - it("McpStatusResponse: empty servers when cwd is null", () => { - const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] }; - expect(_noCwd.servers).toEqual([]); - }); - - it("McpStatusResponse: populated servers when cwd is set", () => { - const _withServers: McpStatusResponse = { - conversationId: "c2", - cwd: "/home/user/project", - servers: [ - { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" }, - { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 }, - ], - }; - expect(_withServers.servers).toHaveLength(2); - expect(_withServers.servers[0]?.toolCount).toBe(12); - expect(_withServers.servers[1]?.error).toBe("spawn failed"); - }); - - // ─── ChatRequest.computerId ────────────────────────────────────────────── - - it("ChatRequest: computerId is additive optional (omittable)", () => { - expect(_chatWithoutComputer.computerId).toBeUndefined(); - }); - - it("ChatRequest: carries computerId when set", () => { - expect(_chatWithComputer.computerId).toBe("prod-box"); - }); - - // ─── Computers ─────────────────────────────────────────────────────────── - - it("ComputerListResponse: carries entries with usage counts", () => { - expect(_computerList.computers).toHaveLength(1); - expect(_computerList.computers[0]?.usageCount).toBe(3); - expect(_computerList.computers[0]?.alias).toBe("prod-box"); - }); - - it("ComputerResponse: is a single Computer", () => { - expect(_computerResponse.alias).toBe("prod-box"); - expect(_computerResponse.port).toBe(22); - }); - - it("ComputerStatusResponse: all four states are valid", () => { - const states: ComputerStatusResponse["state"][] = [ - "disconnected", - "connecting", - "connected", - "error", - ]; - expect(states).toHaveLength(4); - }); - - it("ComputerStatusResponse: connected has no error field", () => { - expect(_statusConnected.state).toBe("connected"); - expect(_statusConnected.error).toBeUndefined(); - }); - - it("ComputerStatusResponse: error carries message", () => { - expect(_statusError.state).toBe("error"); - expect(_statusError.error).toBe("connection refused"); - }); - - it("TestComputerResponse: ok has no error field", () => { - expect(_testOk.ok).toBe(true); - expect(_testOk.error).toBeUndefined(); - }); - - it("TestComputerResponse: failure carries error", () => { - expect(_testFail.ok).toBe(false); - expect(_testFail.error).toBe("auth failed"); - }); - - it("SetConversationComputerRequest: null clears to inherit/local", () => { - expect(_setConvComputer.computerId).toBe("prod-box"); - expect(_clearConvComputer.computerId).toBeNull(); - }); - - it("ConversationComputerResponse: null computerId round-trips", () => { - expect(_convComputer.computerId).toBe("prod-box"); - expect(_convComputerNull.computerId).toBeNull(); - }); - - it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { - expect(_setDefaultComputer.computerId).toBeNull(); - }); + it("CwdResponse: null cwd round-trips", () => { + expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null }); + }); + + it("CwdResponse: set cwd round-trips", () => { + expect(_cwdSet.cwd).toBe("/home/user/project"); + }); + + it("SetCwdRequest: carries cwd", () => { + expect(_setCwd.cwd).toBe("/tmp/workspace"); + }); + + it("LspServerState: all four variants are valid", () => { + const states: LspServerState[] = [ + _stateConnected, + _stateStarting, + _stateError, + _stateNotStarted, + ]; + expect(states).toHaveLength(4); + }); + + it("LspServerInfo: ok server has no error field", () => { + expect(_serverOk.state).toBe("connected"); + expect(_serverOk.error).toBeUndefined(); + }); + + it("LspServerInfo: error server carries error message", () => { + expect(_serverErr.state).toBe("error"); + expect(_serverErr.error).toBe("Failed to start: binary not found"); + }); + + it("LspServerInfo: carries optional configSource", () => { + expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json"); + expect(_serverOk.configSource).toBeUndefined(); + }); + + it("LspStatusResponse: empty servers when cwd is null", () => { + expect(_lspNoCwd.servers).toEqual([]); + }); + + it("LspStatusResponse: populated servers when cwd is set", () => { + expect(_lspWithServers.servers).toHaveLength(2); + }); + + // ─── MCP status ───────────────────────────────────────────────────────────── + + it("McpStatusResponse: empty servers when cwd is null", () => { + const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] }; + expect(_noCwd.servers).toEqual([]); + }); + + it("McpStatusResponse: populated servers when cwd is set", () => { + const _withServers: McpStatusResponse = { + conversationId: "c2", + cwd: "/home/user/project", + servers: [ + { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" }, + { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 }, + ], + }; + expect(_withServers.servers).toHaveLength(2); + expect(_withServers.servers[0]?.toolCount).toBe(12); + expect(_withServers.servers[1]?.error).toBe("spawn failed"); + }); + + // ─── ChatRequest.computerId ────────────────────────────────────────────── + + it("ChatRequest: computerId is additive optional (omittable)", () => { + expect(_chatWithoutComputer.computerId).toBeUndefined(); + }); + + it("ChatRequest: carries computerId when set", () => { + expect(_chatWithComputer.computerId).toBe("prod-box"); + }); + + // ─── Computers ─────────────────────────────────────────────────────────── + + it("ComputerListResponse: carries entries with usage counts", () => { + expect(_computerList.computers).toHaveLength(1); + expect(_computerList.computers[0]?.usageCount).toBe(3); + expect(_computerList.computers[0]?.alias).toBe("prod-box"); + }); + + it("ComputerResponse: is a single Computer", () => { + expect(_computerResponse.alias).toBe("prod-box"); + expect(_computerResponse.port).toBe(22); + }); + + it("ComputerStatusResponse: all four states are valid", () => { + const states: ComputerStatusResponse["state"][] = [ + "disconnected", + "connecting", + "connected", + "error", + ]; + expect(states).toHaveLength(4); + }); + + it("ComputerStatusResponse: connected has no error field", () => { + expect(_statusConnected.state).toBe("connected"); + expect(_statusConnected.error).toBeUndefined(); + }); + + it("ComputerStatusResponse: error carries message", () => { + expect(_statusError.state).toBe("error"); + expect(_statusError.error).toBe("connection refused"); + }); + + it("TestComputerResponse: ok has no error field", () => { + expect(_testOk.ok).toBe(true); + expect(_testOk.error).toBeUndefined(); + }); + + it("TestComputerResponse: failure carries error", () => { + expect(_testFail.ok).toBe(false); + expect(_testFail.error).toBe("auth failed"); + }); + + it("SetConversationComputerRequest: null clears to inherit/local", () => { + expect(_setConvComputer.computerId).toBe("prod-box"); + expect(_clearConvComputer.computerId).toBeNull(); + }); + + it("ConversationComputerResponse: null computerId round-trips", () => { + expect(_convComputer.computerId).toBe("prod-box"); + expect(_convComputerNull.computerId).toBeNull(); + }); + + it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { + expect(_setDefaultComputer.computerId).toBeNull(); + }); }); diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index b32c8a0..6a2f9f4 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -21,33 +21,33 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { - AgentEvent, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - QueuedMessage, - ReasoningEffort, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; export type { - AgentEvent, - CompactionResult, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - QueuedMessage, - ReasoningEffort, - StepMetrics, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + CompactionResult, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + QueuedMessage, + ReasoningEffort, + StepMetrics, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; /** @@ -58,53 +58,53 @@ export type { * response header (useful when `conversationId` was omitted). */ export interface ChatRequest { - /** - * The conversation to continue. Omit to start a fresh conversation — the - * server mints an id and returns it via the `X-Conversation-Id` header. - */ - readonly conversationId?: string; - - /** The user's message text for this turn. */ - readonly message: string; - - /** - * The model to use, as a model name in `/` form — one - * of the exact strings returned by `GET /models`. Omit to use the server's - * default credential + model. - */ - readonly model?: string; - - /** - * Working directory for this turn's tool execution. Defaults server-side when - * omitted. Forwarded to tools for path resolution; never part of the model - * prompt (so it does not affect prompt caching). - */ - readonly cwd?: string; - - /** - * The computer to run this turn's tools on — an SSH config `Host` alias - * (one of the `alias` values returned by `GET /computers`). Omit to inherit - * the resolved chain: per-conversation `computerId` → the workspace's - * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is - * a per-turn tool-execution target forwarded to tools and never part of the - * model prompt (so it does not affect prompt caching). Mirrors `cwd`. - */ - readonly computerId?: string; - - /** - * Reasoning-effort override for THIS turn only (does not persist). When - * omitted, the server resolves the conversation's persisted value, falling - * back to `"high"`. Must be one of the `ReasoningEffort` levels; an - * unrecognized value → HTTP 400 `{ error }`. - */ - readonly reasoningEffort?: ReasoningEffort; - - /** - * The workspace to assign this conversation to. Omit for `"default"`. - * If the workspace doesn't exist yet, it is auto-created (title = id, - * defaultCwd = null). - */ - readonly workspaceId?: string; + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + */ + readonly conversationId?: string; + + /** The user's message text for this turn. */ + readonly message: string; + + /** + * The model to use, as a model name in `/` form — one + * of the exact strings returned by `GET /models`. Omit to use the server's + * default credential + model. + */ + readonly model?: string; + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ + readonly cwd?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; + + /** + * Reasoning-effort override for THIS turn only (does not persist). When + * omitted, the server resolves the conversation's persisted value, falling + * back to `"high"`. Must be one of the `ReasoningEffort` levels; an + * unrecognized value → HTTP 400 `{ error }`. + */ + readonly reasoningEffort?: ReasoningEffort; + + /** + * The workspace to assign this conversation to. Omit for `"default"`. + * If the workspace doesn't exist yet, it is auto-created (title = id, + * defaultCwd = null). + */ + readonly workspaceId?: string; } /** @@ -117,13 +117,13 @@ export interface ChatRequest { * read `models` are unaffected. */ export interface ModelsResponse { - readonly models: readonly string[]; - readonly modelInfo?: Readonly>; + readonly models: readonly string[]; + readonly modelInfo?: Readonly>; } /** Per-model metadata returned alongside the model catalog. */ export interface ModelMetadata { - readonly contextWindow?: number; + readonly contextWindow?: number; } /** @@ -172,8 +172,8 @@ export interface ModelMetadata { * the store contract.) */ export interface ConversationHistoryResponse { - readonly chunks: readonly StoredChunk[]; - readonly latestSeq: number; + readonly chunks: readonly StoredChunk[]; + readonly latestSeq: number; } /** @@ -192,15 +192,15 @@ export interface ConversationHistoryResponse { * absent until then. */ export interface ConversationMetricsResponse { - readonly turns: readonly TurnMetrics[]; + readonly turns: readonly TurnMetrics[]; } export interface ConversationStatusResponse { - readonly conversationId: string; - /** True if the orchestrator has an in-memory active turn for this conversation. */ - readonly isActive: boolean; - /** The persisted lifecycle status from the conversation store. */ - readonly status: ConversationStatus; + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; } /** The aggregation window for `GET /metrics/throughput`. */ @@ -214,16 +214,16 @@ export type ThroughputPeriod = "day" | "week" | "month"; * waits). */ export interface ThroughputModelStat { - /** The model name in `/` form (as selected). */ - readonly model: string; - /** Token-weighted average tokens/second over the period. */ - readonly tokensPerSecond: number; - /** Total output tokens generated across the period's turns. */ - readonly totalOutputTokens: number; - /** Total pure generation time across the period's turns, in milliseconds. */ - readonly totalGenMs: number; - /** Number of turns that contributed. */ - readonly turns: number; + /** The model name in `/` form (as selected). */ + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + /** Total output tokens generated across the period's turns. */ + readonly totalOutputTokens: number; + /** Total pure generation time across the period's turns, in milliseconds. */ + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; } /** @@ -237,21 +237,21 @@ export interface ThroughputModelStat { * `tokensPerSecond` descending. */ export interface ThroughputResponse { - readonly period: ThroughputPeriod; - readonly date: string; - /** Inclusive start of the window, epoch-ms. */ - readonly start: number; - /** Exclusive end of the window, epoch-ms. */ - readonly end: number; - readonly models: readonly ThroughputModelStat[]; + readonly period: ThroughputPeriod; + readonly date: string; + /** Inclusive start of the window, epoch-ms. */ + readonly start: number; + /** Exclusive end of the window, epoch-ms. */ + readonly end: number; + readonly models: readonly ThroughputModelStat[]; } // ─── Per-conversation working directory (cwd) ───────────────────────────────── /** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ export interface CwdResponse { - readonly conversationId: string; - readonly cwd: string | null; + readonly conversationId: string; + readonly cwd: string | null; } /** @@ -265,8 +265,8 @@ export interface CwdResponse { * `"default"` if none). */ export interface SetCwdRequest { - readonly cwd: string; - readonly workspaceId?: string; + readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -277,8 +277,8 @@ export interface SetCwdRequest { * `"high"`). */ export interface ReasoningEffortResponse { - readonly conversationId: string; - readonly reasoningEffort: ReasoningEffort | null; + readonly conversationId: string; + readonly reasoningEffort: ReasoningEffort | null; } /** @@ -288,7 +288,7 @@ export interface ReasoningEffortResponse { * unrecognized level → HTTP 400 `{ error }`. */ export interface SetReasoningEffortRequest { - readonly reasoningEffort: ReasoningEffort; + readonly reasoningEffort: ReasoningEffort; } // ─── Per-conversation model ────────────────────────────────────────────────── @@ -299,8 +299,8 @@ export interface SetReasoningEffortRequest { * then resolves turns using the default provider + model). */ export interface ModelResponse { - readonly conversationId: string; - readonly model: string | null; + readonly conversationId: string; + readonly model: string | null; } /** @@ -311,7 +311,7 @@ export interface ModelResponse { * at turn time; an unknown model → turn error, not a 400). */ export interface SetModelRequest { - readonly model: string | null; + readonly model: string | null; } // ─── Conversation close (explicit tab close) ────────────────────────────────── @@ -331,9 +331,9 @@ export interface SetModelRequest { * `abortedTurn: false`. */ export interface CloseConversationResponse { - readonly conversationId: string; - /** True when an in-flight turn existed and was aborted by this close. */ - readonly abortedTurn: boolean; + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; } // ─── System prompt template ─────────────────────────────────────────────────── @@ -348,8 +348,8 @@ export interface CloseConversationResponse { * and reused on all subsequent turns (cache-safe — no per-turn reconstruction). */ export interface SystemPromptTemplateResponse { - /** The template text (may be empty — then no system prompt is sent). */ - readonly template: string; + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; } /** @@ -360,7 +360,7 @@ export interface SystemPromptTemplateResponse { * conversations use the new template on their first turn. */ export interface SetSystemPromptTemplateRequest { - readonly template: string; + readonly template: string; } /** @@ -369,22 +369,22 @@ export interface SetSystemPromptTemplateRequest { * selector buttons. */ export interface SystemPromptVariable { - /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ - readonly type: string; - /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ - readonly name: string; - /** Human-readable description of what the variable resolves to. */ - readonly description: string; - /** - * When `true`, any name is valid for this type (e.g. `file:` accepts - * any file path). The frontend should allow free-text input for the name. - */ - readonly dynamic?: boolean; + /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ + readonly type: string; + /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ + readonly name: string; + /** Human-readable description of what the variable resolves to. */ + readonly description: string; + /** + * When `true`, any name is valid for this type (e.g. `file:` accepts + * any file path). The frontend should allow free-text input for the name. + */ + readonly dynamic?: boolean; } /** Response of `GET /system-prompt/variables`. */ export interface SystemPromptVariablesResponse { - readonly variables: readonly SystemPromptVariable[]; + readonly variables: readonly SystemPromptVariable[]; } // ─── Message queue (steering) ───────────────────────────────────────────────── @@ -405,12 +405,12 @@ export interface SystemPromptVariablesResponse { * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. */ export interface QueueRequest { - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -422,9 +422,9 @@ export interface QueueRequest { * the chat channel as usual. */ export interface QueueResponse { - readonly conversationId: string; - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly conversationId: string; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; } // ─── Per-conversation LSP status ────────────────────────────────────────────── @@ -434,39 +434,39 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; /** One language server's status as reported to the frontend. */ export interface LspServerInfo { - /** Stable server id, e.g. "typescript", "luau-lsp". */ - readonly id: string; - /** Human-readable display name. */ - readonly name: string; - /** The resolved workspace root the server is (or would be) rooted at (absolute). */ - readonly root: string; - /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ - readonly extensions: readonly string[]; - /** Current connection state. */ - readonly state: LspServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted - * when not yet resolved. Surfaces config-shadow debugging to the status caller - * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). - */ - readonly configSource?: string; + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + readonly state: LspServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted + * when not yet resolved. Surfaces config-shadow debugging to the status caller + * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). + */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/lsp`. */ export interface LspStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the LSP connects on, or `null` when no - * cwd has been set for the conversation (then `servers` is empty). When - * non-null, this is the effective cwd — a relative persisted cwd resolved - * against the conversation's workspace `defaultCwd`. - */ - readonly cwd: string | null; - /** The language servers configured for `cwd` and their live state. */ - readonly servers: readonly LspServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ + readonly cwd: string | null; + /** The language servers configured for `cwd` and their live state. */ + readonly servers: readonly LspServerInfo[]; } // ─── MCP status ────────────────────────────────────────────────────── @@ -475,29 +475,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte /** One MCP server's status as reported to the frontend. */ export interface McpServerInfo { - /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ - readonly id: string; - /** Current connection state. */ - readonly state: McpServerState; - /** Present only when `state === "error"`: a short human-readable reason. */ - readonly error?: string; - /** Number of tools discovered from this server. */ - readonly toolCount: number; - /** Which config source this server was resolved from. */ - readonly configSource?: string; + /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ + readonly id: string; + /** Current connection state. */ + readonly state: McpServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source this server was resolved from. */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/mcp`. */ export interface McpStatusResponse { - readonly conversationId: string; - /** - * The resolved working directory the MCP servers are configured for, or - * `null` when no cwd has been set for the conversation (then `servers` is - * empty). Mirrors the LSP status endpoint behavior. - */ - readonly cwd: string | null; - /** The MCP servers configured for `cwd` and their live state. */ - readonly servers: readonly McpServerInfo[]; + readonly conversationId: string; + /** + * The resolved working directory the MCP servers are configured for, or + * `null` when no cwd has been set for the conversation (then `servers` is + * empty). Mirrors the LSP status endpoint behavior. + */ + readonly cwd: string | null; + /** The MCP servers configured for `cwd` and their live state. */ + readonly servers: readonly McpServerInfo[]; } /** @@ -511,17 +511,17 @@ export interface McpStatusResponse { * prefix is byte-identical to a real turn (which is what makes the cache hit). */ export interface WarmRequest { - /** The conversation whose prompt cache to warm. */ - readonly conversationId: string; + /** The conversation whose prompt cache to warm. */ + readonly conversationId: string; - /** - * The model name in `/` form the conversation uses, so - * the warm resolves the same provider + prefix. Omit to use the server default. - */ - readonly model?: string; + /** + * The model name in `/` form the conversation uses, so + * the warm resolves the same provider + prefix. Omit to use the server default. + */ + readonly model?: string; - /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ - readonly cwd?: string; + /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ + readonly cwd?: string; } /** @@ -533,26 +533,26 @@ export interface WarmRequest { * server responds `409` with `{ error }` instead of this body. */ export interface WarmResponse { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; - /** - * **Cache rate** — what fraction of THIS request's prompt was served from cache: - * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). - * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) - */ - readonly cachePct: number; - /** - * **Expected cache (retention)** — of the cacheable prefix this warm touched, how - * much was still warm and read back vs. had to be (re)written: - * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the - * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still - * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite - * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's - * tiny fresh probe makes ~equal, but which on a real turn reflects new content). - */ - readonly expectedCacheRate: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** — what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** — of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still + * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite + * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's + * tiny fresh probe makes ~equal, but which on a real turn reflects new content). + */ + readonly expectedCacheRate: number; } // ─── WebSocket chat ops ─────────────────────────────────────────────────────── @@ -567,7 +567,7 @@ export interface WarmResponse { * `AgentEvent`s (each carries `conversationId`). */ export interface ChatSendMessage extends ChatRequest { - readonly type: "chat.send"; + readonly type: "chat.send"; } /** @@ -577,8 +577,8 @@ export interface ChatSendMessage extends ChatRequest { * carrier. */ export interface ChatDeltaMessage { - readonly type: "chat.delta"; - readonly event: AgentEvent; + readonly type: "chat.delta"; + readonly event: AgentEvent; } /** @@ -587,9 +587,9 @@ export interface ChatDeltaMessage { * `TurnErrorEvent` inside a `chat.delta`.) */ export interface ChatErrorMessage { - readonly type: "chat.error"; - readonly conversationId?: string; - readonly message: string; + readonly type: "chat.error"; + readonly conversationId?: string; + readonly message: string; } /** @@ -609,8 +609,8 @@ export interface ChatErrorMessage { * `chat.subscribe` for conversations it is viewing but did not send to. */ export interface ChatSubscribeMessage { - readonly type: "chat.subscribe"; - readonly conversationId: string; + readonly type: "chat.subscribe"; + readonly conversationId: string; } /** @@ -620,8 +620,8 @@ export interface ChatSubscribeMessage { * the socket closes — again WITHOUT aborting any in-flight turn. */ export interface ChatUnsubscribeMessage { - readonly type: "chat.unsubscribe"; - readonly conversationId: string; + readonly type: "chat.unsubscribe"; + readonly conversationId: string; } /** @@ -636,14 +636,14 @@ export interface ChatUnsubscribeMessage { * latter being equivalent to `chat.send`). */ export interface ChatQueueMessage { - readonly type: "chat.queue"; - readonly conversationId: string; - readonly text: string; - /** - * The workspace to assign the conversation to (if a new conversation is - * started). Omit for `"default"`. Auto-creates if missing. - */ - readonly workspaceId?: string; + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -651,23 +651,23 @@ export interface ChatQueueMessage { * ops. A server discriminates on `type`. */ export type WsClientMessage = - | SurfaceClientMessage - | ChatSendMessage - | ChatSubscribeMessage - | ChatUnsubscribeMessage - | ChatQueueMessage; + | SurfaceClientMessage + | ChatSendMessage + | ChatSubscribeMessage + | ChatUnsubscribeMessage + | ChatQueueMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ export type WsServerMessage = - | SurfaceServerMessage - | ChatDeltaMessage - | ChatErrorMessage - | ConversationOpenMessage - | ConversationStatusChangedMessage - | ConversationCompactedMessage; + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ConversationOpenMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── @@ -677,14 +677,14 @@ export type WsServerMessage = * — the backend just signals. Additive to `WsServerMessage`. */ export interface ConversationOpenMessage { - readonly type: "conversation.open"; - readonly conversationId: string; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; + readonly type: "conversation.open"; + readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -693,15 +693,15 @@ export interface ConversationOpenMessage { * devices in real time. */ export interface ConversationStatusChangedMessage { - readonly type: "conversation.statusChanged"; - readonly conversationId: string; - readonly status: ConversationStatus; - /** - * The conversation's actual workspace id, so a frontend can open/focus it - * in the correct workspace instead of stamping it with the viewer's current - * workspace. - */ - readonly workspaceId: string; + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -710,11 +710,11 @@ export interface ConversationStatusChangedMessage { * via `GET /conversations/:id` to reflect the compacted state. */ export interface ConversationCompactedMessage { - readonly type: "conversation.compacted"; - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly type: "conversation.compacted"; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -724,7 +724,7 @@ export interface ConversationCompactedMessage { * Optional `?q=` query param filters by id prefix (short-id resolution). */ export interface ConversationListResponse { - readonly conversations: readonly ConversationMeta[]; + readonly conversations: readonly ConversationMeta[]; } /** @@ -734,9 +734,9 @@ export interface ConversationListResponse { * `turnId` is the turn that produced the message (absent if no turn ran). */ export interface LastMessageResponse { - readonly conversationId: string; - readonly content: string; - readonly turnId?: string; + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; } /** @@ -744,22 +744,22 @@ export interface LastMessageResponse { * signal was broadcast to connected WS clients. */ export interface OpenConversationResponse { - readonly conversationId: string; + readonly conversationId: string; } /** * Request body for `PUT /conversations/:id/title` — set a human-readable title. */ export interface SetTitleRequest { - readonly title: string; + readonly title: string; } /** * Response for `GET/PUT /conversations/:id/title` — the current title. */ export interface TitleResponse { - readonly conversationId: string; - readonly title: string; + readonly conversationId: string; + readonly title: string; } /** @@ -767,10 +767,10 @@ export interface TitleResponse { * history was compacted (old messages summarized, recent messages retained). */ export interface CompactResponse { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -778,15 +778,15 @@ export interface CompactResponse { * at which automatic compaction triggers (0 = manual only). */ export interface CompactPercentResponse { - readonly conversationId: string; - readonly threshold: number; + readonly conversationId: string; + readonly threshold: number; } /** * Request body for `PUT /conversations/:id/compact-percent`. */ export interface SetCompactPercentRequest { - readonly threshold: number; + readonly threshold: number; } // ─── Workspaces ─────────────────────────────────────────────────────────────── @@ -797,10 +797,10 @@ export interface SetCompactPercentRequest { * an existing workspace is returned as-is. */ export interface EnsureWorkspaceRequest { - /** Display title. Default: the workspace id. Only used on create. */ - readonly title?: string; - /** Default cwd. Default: null (inherit server default). Only used on create. */ - readonly defaultCwd?: string | null; + /** Display title. Default: the workspace id. Only used on create. */ + readonly title?: string; + /** Default cwd. Default: null (inherit server default). Only used on create. */ + readonly defaultCwd?: string | null; } /** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ @@ -808,17 +808,17 @@ export interface WorkspaceResponse extends Workspace {} /** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ export interface WorkspaceListResponse { - readonly workspaces: readonly WorkspaceEntry[]; + readonly workspaces: readonly WorkspaceEntry[]; } /** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ export interface SetWorkspaceTitleRequest { - readonly title: string; + readonly title: string; } /** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; + readonly defaultCwd: string | null; } /** @@ -827,9 +827,9 @@ export interface SetWorkspaceDefaultCwdRequest { * workspace entity is deleted. `"default"` is non-deletable (HTTP 409). */ export interface DeleteWorkspaceResponse { - readonly workspaceId: string; - /** Conversations that were closed (status → "closed") by this delete. */ - readonly closedCount: number; + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; } // ─── Computers ─────────────────────────────────────────────────────────────── @@ -842,7 +842,7 @@ export interface DeleteWorkspaceResponse { * block to `~/.ssh/config` and Dispatch discovers it on the next read. */ export interface ComputerListResponse { - readonly computers: readonly ComputerEntry[]; + readonly computers: readonly ComputerEntry[]; } /** @@ -859,10 +859,10 @@ export interface ComputerResponse extends Computer {} * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. */ export interface ComputerStatusResponse { - readonly alias: string; - readonly state: "disconnected" | "connecting" | "connected" | "error"; - readonly error?: string; - readonly knownHost: boolean; + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; } /** @@ -874,7 +874,7 @@ export interface ComputerStatusResponse { * a 400). Mirrors the cwd/model PUT clear semantics. */ export interface SetConversationComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -883,8 +883,8 @@ export interface SetConversationComputerRequest { * the workspace default → local). Parallel to `CwdResponse`. */ export interface ConversationComputerResponse { - readonly conversationId: string; - readonly computerId: string | null; + readonly conversationId: string; + readonly computerId: string | null; } /** @@ -894,7 +894,7 @@ export interface ConversationComputerResponse { * `computerId` of their own inherit this. */ export interface SetWorkspaceDefaultComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -904,7 +904,7 @@ export interface SetWorkspaceDefaultComputerRequest { * failure reason (e.g. auth refused, host unreachable) when `ok` is false. */ export interface TestComputerResponse { - readonly alias: string; - readonly ok: boolean; - readonly error?: string; + readonly alias: string; + readonly ok: boolean; + readonly error?: string; } diff --git a/packages/transport-contract/tsconfig.json b/packages/transport-contract/tsconfig.json index 5a5de0f..c5e5978 100644 --- a/packages/transport-contract/tsconfig.json +++ b/packages/transport-contract/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../ui-contract" }, { "path": "../wire" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../ui-contract" }, { "path": "../wire" }] } diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index e7eb85c..2411781 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -1,21 +1,21 @@ { - "name": "@dispatch/transport-http", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/conversation-store": "workspace:*", - "@dispatch/credential-store": "workspace:*", - "@dispatch/kernel": "workspace:*", - "@dispatch/lsp": "workspace:*", - "@dispatch/mcp": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/throughput-store": "workspace:*", - "@dispatch/transport-contract": "workspace:*", - "@dispatch/wire": "workspace:*", - "hono": "^4.0.0", - "@dispatch/system-prompt": "workspace:*" - } + "name": "@dispatch/transport-http", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/conversation-store": "workspace:*", + "@dispatch/credential-store": "workspace:*", + "@dispatch/kernel": "workspace:*", + "@dispatch/lsp": "workspace:*", + "@dispatch/mcp": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/throughput-store": "workspace:*", + "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", + "hono": "^4.0.0", + "@dispatch/system-prompt": "workspace:*" + } } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 4f64ece..d108e72 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,227 +1,227 @@ import type { - AgentEvent, - ChatMessage, - ConversationMeta, - HostAPI, - Logger, - ReasoningEffort, - StepId, - StorageNamespace, - StoredChunk, - TurnMetrics, + AgentEvent, + ChatMessage, + ConversationMeta, + HostAPI, + Logger, + ReasoningEffort, + StepId, + StorageNamespace, + StoredChunk, + TurnMetrics, } from "@dispatch/kernel"; import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt"; import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store"; import type { - DeleteWorkspaceResponse, - QueuedMessage, - QueueResponse, - SystemPromptVariable, - ThroughputResponse, - WorkspaceListResponse, - WorkspaceResponse, + DeleteWorkspaceResponse, + QueuedMessage, + QueueResponse, + SystemPromptVariable, + ThroughputResponse, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; import { extractLastAssistantText } from "./logic.js"; import type { - ComputerService, - ConversationStore, - CredentialStore, - LspService, - McpService, - SessionOrchestrator, - SystemPromptService, - WarmService, + ComputerService, + ConversationStore, + CredentialStore, + LspService, + McpService, + SessionOrchestrator, + SystemPromptService, + WarmService, } from "./seam.js"; import { conversationOpened } from "./seam.js"; function createMemStorage(): StorageNamespace { - const map = new Map(); - return { - get: async (k) => map.get(k) ?? null, - set: async (k, v) => { - map.set(k, v); - }, - delete: async (k) => { - map.delete(k); - }, - has: async (k) => map.has(k), - keys: async (prefix) => - [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const map = new Map(); + return { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + delete: async (k) => { + map.delete(k); + }, + has: async (k) => map.has(k), + keys: async (prefix) => + [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } interface CapturedLog { - readonly level: "debug" | "info" | "warn" | "error"; - readonly msg: string; - readonly attrs?: Record; + readonly level: "debug" | "info" | "warn" | "error"; + readonly msg: string; + readonly attrs?: Record; } function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] } { - const records: CapturedLog[] = []; - return { - get records() { - return records; - }, - debug(msg, attrs) { - records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) }); - }, - info(msg, attrs) { - records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) }); - }, - warn(msg, attrs) { - records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) }); - }, - error(msg, attrs) { - records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) }); - }, - child() { - return createFakeLogger(); - }, - span() { - return { - id: "fake-span", - log: createFakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const records: CapturedLog[] = []; + return { + get records() { + return records; + }, + debug(msg, attrs) { + records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) }); + }, + info(msg, attrs) { + records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) }); + }, + warn(msg, attrs) { + records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) }); + }, + error(msg, attrs) { + records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) }); + }, + child() { + return createFakeLogger(); + }, + span() { + return { + id: "fake-span", + log: createFakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function createFakeConversationStore( - store: Map = new Map(), - metricsStore: Map = new Map(), - cwdStore: Map = new Map(), - reasoningEffortStore: Map = new Map(), - modelStore: Map = new Map(), - computerStore: Map = new Map(), + store: Map = new Map(), + metricsStore: Map = new Map(), + cwdStore: Map = new Map(), + reasoningEffortStore: Map = new Map(), + modelStore: Map = new Map(), + computerStore: Map = new Map(), ): ConversationStore { - const sampleWorkspace = { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - return { - async append() {}, - async load() { - return []; - }, - async loadSince(conversationId, sinceSeq, window) { - const chunks = store.get(conversationId) ?? []; - const minSeq = sinceSeq ?? 0; - const beforeSeq = window?.beforeSeq; - const limit = window?.limit; - const selected = chunks.filter( - (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq), - ); - // Window: keep only the NEWEST `limit`, still ascending by seq. - if (limit !== undefined && selected.length > limit) { - return selected.slice(selected.length - limit); - } - return selected; - }, - async appendMetrics() {}, - async loadMetrics(conversationId) { - return metricsStore.get(conversationId) ?? []; - }, - async getCwd(conversationId) { - return cwdStore.get(conversationId) ?? null; - }, - async setCwd(conversationId, cwd) { - cwdStore.set(conversationId, cwd); - }, - async clearCwd(conversationId) { - cwdStore.delete(conversationId); - }, - async getComputerId(conversationId) { - return computerStore.get(conversationId) ?? null; - }, - async setComputerId(conversationId, alias) { - if (alias === null) { - computerStore.delete(conversationId); - } else { - computerStore.set(conversationId, alias); - } - }, - async clearComputerId(conversationId) { - computerStore.delete(conversationId); - }, - async getReasoningEffort(conversationId) { - return reasoningEffortStore.get(conversationId) ?? null; - }, - async setReasoningEffort(conversationId, effort) { - reasoningEffortStore.set(conversationId, effort); - }, - async getModel(conversationId) { - return modelStore.get(conversationId) ?? null; - }, - async setModel(conversationId, model) { - if (model === "") { - modelStore.delete(conversationId); - } else { - modelStore.set(conversationId, model); - } - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return sampleWorkspace; - }, - async setWorkspaceTitle() { - return sampleWorkspace; - }, - async setWorkspaceDefaultCwd() { - return sampleWorkspace; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...sampleWorkspace, id, defaultComputerId }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd(conversationId) { - return cwdStore.get(conversationId) ?? null; - }, - async getEffectiveComputer(conversationId) { - return computerStore.get(conversationId) ?? null; - }, - }; + const sampleWorkspace = { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + return { + async append() {}, + async load() { + return []; + }, + async loadSince(conversationId, sinceSeq, window) { + const chunks = store.get(conversationId) ?? []; + const minSeq = sinceSeq ?? 0; + const beforeSeq = window?.beforeSeq; + const limit = window?.limit; + const selected = chunks.filter( + (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq), + ); + // Window: keep only the NEWEST `limit`, still ascending by seq. + if (limit !== undefined && selected.length > limit) { + return selected.slice(selected.length - limit); + } + return selected; + }, + async appendMetrics() {}, + async loadMetrics(conversationId) { + return metricsStore.get(conversationId) ?? []; + }, + async getCwd(conversationId) { + return cwdStore.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdStore.set(conversationId, cwd); + }, + async clearCwd(conversationId) { + cwdStore.delete(conversationId); + }, + async getComputerId(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerStore.delete(conversationId); + } else { + computerStore.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerStore.delete(conversationId); + }, + async getReasoningEffort(conversationId) { + return reasoningEffortStore.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + reasoningEffortStore.set(conversationId, effort); + }, + async getModel(conversationId) { + return modelStore.get(conversationId) ?? null; + }, + async setModel(conversationId, model) { + if (model === "") { + modelStore.delete(conversationId); + } else { + modelStore.set(conversationId, model); + } + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return sampleWorkspace; + }, + async setWorkspaceTitle() { + return sampleWorkspace; + }, + async setWorkspaceDefaultCwd() { + return sampleWorkspace; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...sampleWorkspace, id, defaultComputerId }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdStore.get(conversationId) ?? null; + }, + async getEffectiveComputer(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + }; } /** @@ -230,4103 +230,4103 @@ function createFakeConversationStore( * assert that workspace assignment happens BEFORE setCwd. */ function createCallTrackingStore( - base: ConversationStore, + base: ConversationStore, ): ConversationStore & { readonly calls: readonly string[] } { - const calls: string[] = []; - return { - ...base, - get calls() { - return calls; - }, - async ensureWorkspace(id, opts) { - calls.push(`ensureWorkspace:${id}`); - return base.ensureWorkspace(id, opts); - }, - async setWorkspaceId(conversationId, workspaceId) { - calls.push(`setWorkspaceId:${workspaceId}`); - await base.setWorkspaceId(conversationId, workspaceId); - }, - async setCwd(conversationId, cwd) { - calls.push(`setCwd:${cwd}`); - await base.setCwd(conversationId, cwd); - }, - }; + const calls: string[] = []; + return { + ...base, + get calls() { + return calls; + }, + async ensureWorkspace(id, opts) { + calls.push(`ensureWorkspace:${id}`); + return base.ensureWorkspace(id, opts); + }, + async setWorkspaceId(conversationId, workspaceId) { + calls.push(`setWorkspaceId:${workspaceId}`); + await base.setWorkspaceId(conversationId, workspaceId); + }, + async setCwd(conversationId, cwd) { + calls.push(`setCwd:${cwd}`); + await base.setCwd(conversationId, cwd); + }, + }; } function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { - return { - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(input) { - for (const event of events) { - input.onEvent(event); - } - }, - }; + return { + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(input) { + for (const event of events) { + input.onEvent(event); + } + }, + }; } function createCapturingOrchestrator(): SessionOrchestrator & { - received: Parameters[0] | undefined; + received: Parameters[0] | undefined; } { - const state: { - received: Parameters[0] | undefined; - } = { received: undefined }; - return { - get received() { - return state.received; - }, - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(input) { - state.received = input; - }, - }; + const state: { + received: Parameters[0] | undefined; + } = { received: undefined }; + return { + get received() { + return state.received; + }, + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(input) { + state.received = input; + }, + }; } function createThrowingOrchestrator(error: Error): SessionOrchestrator { - return { - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage() { - throw error; - }, - }; + return { + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage() { + throw error; + }, + }; } function createFakeCredentialStore(models: string[]): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - return models; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + return models; + }, + }; } function createThrowingCredentialStore(error: Error): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - throw error; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + throw error; + }, + }; } function createFakeWarmService( - result: - | { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - } - | { error: string }, + result: + | { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + } + | { error: string }, ): WarmService { - return { - async warm() { - return result; - }, - }; + return { + async warm() { + return result; + }, + }; } function createFakeLspService( - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: "connected" | "starting" | "error" | "not-started"; - readonly error?: string; - readonly configSource?: string; - }[] = [], + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + readonly configSource?: string; + }[] = [], ): LspService { - return { - async status() { - return statuses; - }, - }; + return { + async status() { + return statuses; + }, + }; } function createCapturingLspService( - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: "connected" | "starting" | "error" | "not-started"; - readonly error?: string; - readonly configSource?: string; - }[] = [], + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + readonly configSource?: string; + }[] = [], ): LspService & { readonly statusCalls: readonly string[] } { - const calls: string[] = []; - return { - get statusCalls() { - return calls; - }, - async status(cwd) { - calls.push(cwd); - return statuses; - }, - }; + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; } function createFakeMcpService( - statuses: readonly { - readonly id: string; - readonly state: "connecting" | "connected" | "error" | "disconnected"; - readonly error?: string; - readonly toolCount: number; - }[] = [], + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], ): McpService { - return { - async status() { - return statuses; - }, - }; + return { + async status() { + return statuses; + }, + }; } function createCapturingMcpService( - statuses: readonly { - readonly id: string; - readonly state: "connecting" | "connected" | "error" | "disconnected"; - readonly error?: string; - readonly toolCount: number; - }[] = [], + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], ): McpService & { readonly statusCalls: readonly string[] } { - const calls: string[] = []; - return { - get statusCalls() { - return calls; - }, - async status(cwd) { - calls.push(cwd); - return statuses; - }, - }; + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; } function createFakeSystemPromptService( - template: string = "custom template", + template: string = "custom template", ): SystemPromptService & { - readonly setTemplateCalls: readonly string[]; - readonly getTemplateCalls: number; + readonly setTemplateCalls: readonly string[]; + readonly getTemplateCalls: number; } { - const setCalls: string[] = []; - let getTemplateCount = 0; - let currentTemplate = template; - return { - get setTemplateCalls() { - return setCalls; - }, - get getTemplateCalls() { - return getTemplateCount; - }, - async construct() { - return currentTemplate; - }, - async get() { - return currentTemplate; - }, - async getTemplate() { - getTemplateCount++; - return currentTemplate; - }, - async setTemplate(t) { - setCalls.push(t); - currentTemplate = t; - }, - }; + const setCalls: string[] = []; + let getTemplateCount = 0; + let currentTemplate = template; + return { + get setTemplateCalls() { + return setCalls; + }, + get getTemplateCalls() { + return getTemplateCount; + }, + async construct() { + return currentTemplate; + }, + async get() { + return currentTemplate; + }, + async getTemplate() { + getTemplateCount++; + return currentTemplate; + }, + async setTemplate(t) { + setCalls.push(t); + currentTemplate = t; + }, + }; } function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService { - const byAlias = new Map(computers.map((c) => [c.alias, c])); - return { - async listComputers() { - return computers; - }, - async getComputer(alias) { - return byAlias.get(alias) ?? null; - }, - async getStatus(alias) { - const known = byAlias.has(alias); - return { alias, state: "disconnected", knownHost: known }; - }, - async test(alias) { - return byAlias.has(alias) - ? { alias, ok: true } - : { alias, ok: false, error: "Computer not found" }; - }, - }; + const byAlias = new Map(computers.map((c) => [c.alias, c])); + return { + async listComputers() { + return computers; + }, + async getComputer(alias) { + return byAlias.get(alias) ?? null; + }, + async getStatus(alias) { + const known = byAlias.has(alias); + return { alias, state: "disconnected", knownHost: known }; + }, + async test(alias) { + return byAlias.has(alias) + ? { alias, ok: true } + : { alias, ok: false, error: "Computer not found" }; + }, + }; } const noopLogger = createFakeLogger(); describe("GET /health", () => { - it("returns ok", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/health"); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toEqual({ ok: true }); - }); + it("returns ok", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/health"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + }); }); describe("GET /models", () => { - it("returns model catalog", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]); - }); - - it("returns empty array when no models", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual([]); - }); - - it("returns 502 when listCatalog throws", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createThrowingCredentialStore(new Error("db down")), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(502); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to retrieve model catalog"); - }); + it("returns model catalog", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]); + }); + + it("returns empty array when no models", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); + + it("returns 502 when listCatalog throws", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createThrowingCredentialStore(new Error("db down")), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to retrieve model catalog"); + }); }); describe("POST /chat", () => { - it("returns 400 for invalid JSON", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for missing message", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "c1" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("message"); - }); - - it("returns 400 for empty message", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "" }), - }); - expect(res.status).toBe(400); - }); - - it("streams events as NDJSON", async () => { - const events: AgentEvent[] = [ - { type: "turn-start", conversationId: "tab1", turnId: "turn1" }, - { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" }, - { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" }, - { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, - ]; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator(events), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); - expect(res.headers.get("X-Conversation-Id")).toBe("conv1"); - - const text = await res.text(); - const lines = text.trim().split("\n"); - expect(lines).toHaveLength(4); - - const parsed = lines.map((line) => JSON.parse(line) as AgentEvent); - expect(parsed[0]?.type).toBe("turn-start"); - expect(parsed[1]?.type).toBe("text-delta"); - expect((parsed[1] as { delta: string }).delta).toBe("Hello"); - expect(parsed[2]?.type).toBe("text-delta"); - expect(parsed[3]?.type).toBe("done"); - }); - - it("generates conversationId when not provided", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore([]), - generateId: () => "generated-uuid", - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi" }), - }); - - expect(res.status).toBe(200); - expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid"); - }); - - it("emits error event when orchestrator throws", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const text = await res.text(); - const lines = text.trim().split("\n"); - expect(lines.length).toBeGreaterThanOrEqual(1); - - const lastLine = lines[lines.length - 1]; - if (!lastLine) throw new Error("expected at least one line"); - const lastEvent = JSON.parse(lastLine) as AgentEvent; - expect(lastEvent.type).toBe("error"); - if (lastEvent.type === "error") { - expect(lastEvent.message).toContain("provider unavailable"); - } - }); - - it("handles empty event list", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi" }), - }); - - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toBe(""); - }); - - it("forwards modelName and cwd to orchestrator", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - model: "opencode/m1", - cwd: "/tmp", - }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.conversationId).toBe("conv1"); - expect(cap.received?.text).toBe("hi"); - expect(cap.received?.modelName).toBe("opencode/m1"); - expect(cap.received?.cwd).toBe("/tmp"); - }); - - it("omits modelName and cwd when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.modelName).toBeUndefined(); - expect(cap.received?.cwd).toBeUndefined(); - }); + it("returns 400 for invalid JSON", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing message", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "c1" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("message"); + }); + + it("returns 400 for empty message", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "" }), + }); + expect(res.status).toBe(400); + }); + + it("streams events as NDJSON", async () => { + const events: AgentEvent[] = [ + { type: "turn-start", conversationId: "tab1", turnId: "turn1" }, + { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" }, + { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" }, + { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, + ]; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator(events), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + expect(res.headers.get("X-Conversation-Id")).toBe("conv1"); + + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines).toHaveLength(4); + + const parsed = lines.map((line) => JSON.parse(line) as AgentEvent); + expect(parsed[0]?.type).toBe("turn-start"); + expect(parsed[1]?.type).toBe("text-delta"); + expect((parsed[1] as { delta: string }).delta).toBe("Hello"); + expect(parsed[2]?.type).toBe("text-delta"); + expect(parsed[3]?.type).toBe("done"); + }); + + it("generates conversationId when not provided", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore([]), + generateId: () => "generated-uuid", + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid"); + }); + + it("emits error event when orchestrator throws", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const lastLine = lines[lines.length - 1]; + if (!lastLine) throw new Error("expected at least one line"); + const lastEvent = JSON.parse(lastLine) as AgentEvent; + expect(lastEvent.type).toBe("error"); + if (lastEvent.type === "error") { + expect(lastEvent.message).toContain("provider unavailable"); + } + }); + + it("handles empty event list", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe(""); + }); + + it("forwards modelName and cwd to orchestrator", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + model: "opencode/m1", + cwd: "/tmp", + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.text).toBe("hi"); + expect(cap.received?.modelName).toBe("opencode/m1"); + expect(cap.received?.cwd).toBe("/tmp"); + }); + + it("omits modelName and cwd when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.modelName).toBeUndefined(); + expect(cap.received?.cwd).toBeUndefined(); + }); }); describe("POST /chat/warm", () => { - it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 1000, - outputTokens: 200, - cacheReadTokens: 800, - cacheWriteTokens: 100, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - cachePct: number; - expectedCacheRate: number; - }; - expect(body.inputTokens).toBe(1000); - expect(body.outputTokens).toBe(200); - expect(body.cacheReadTokens).toBe(800); - expect(body.cacheWriteTokens).toBe(100); - expect(body.cachePct).toBe(80); - expect(body.expectedCacheRate).toBe(89); - }); - - it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 500, - outputTokens: 100, - cacheReadTokens: 400, - cacheWriteTokens: 100, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { expectedCacheRate: number }; - expect(body.expectedCacheRate).toBe(80); - }); - - it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { expectedCacheRate: number }; - expect(body.expectedCacheRate).toBe(0); - }); - - it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ error: "conversation is generating" }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(409); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("conversation is generating"); - }); - - it("POST /chat/warm returns 400 when conversationId is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("conversationId"); - }); + it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 800, + cacheWriteTokens: 100, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + cachePct: number; + expectedCacheRate: number; + }; + expect(body.inputTokens).toBe(1000); + expect(body.outputTokens).toBe(200); + expect(body.cacheReadTokens).toBe(800); + expect(body.cacheWriteTokens).toBe(100); + expect(body.cachePct).toBe(80); + expect(body.expectedCacheRate).toBe(89); + }); + + it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 500, + outputTokens: 100, + cacheReadTokens: 400, + cacheWriteTokens: 100, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { expectedCacheRate: number }; + expect(body.expectedCacheRate).toBe(80); + }); + + it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { expectedCacheRate: number }; + expect(body.expectedCacheRate).toBe(0); + }); + + it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ error: "conversation is generating" }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("conversation is generating"); + }); + + it("POST /chat/warm returns 400 when conversationId is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("conversationId"); + }); }); describe("GET /conversations/:id", () => { - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, - { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, - { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, - ]; - - it("returns the full seq-ordered StoredChunk history", async () => { - const store = new Map([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(4); - expect(body.chunks[0]?.seq).toBe(1); - expect(body.chunks[3]?.seq).toBe(4); - expect(body.latestSeq).toBe(4); - }); - - it("returns only chunks with seq > N and latestSeq = last seq", async () => { - const store = new Map([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(2); - expect(body.chunks[0]?.seq).toBe(3); - expect(body.chunks[1]?.seq).toBe(4); - expect(body.latestSeq).toBe(4); - }); - - it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { - const store = new Map([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=4"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(0); - expect(body.latestSeq).toBe(4); - }); - - it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/unknown"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(0); - expect(body.latestSeq).toBe(0); - }); - - it("returns 400 for invalid sinceSeq", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=abc"); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("sinceSeq"); - }); - - it("returns 400 for negative sinceSeq", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=-1"); - expect(res.status).toBe(400); - }); - - const sixChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "one" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } }, - { seq: 3, role: "user", chunk: { type: "text", text: "three" } }, - { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } }, - { seq: 5, role: "user", chunk: { type: "text", text: "five" } }, - { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } }, - ]; - - function appWithChunks(chunks: StoredChunk[]) { - const store = new Map([["conv1", chunks]]); - return createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - } - - it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?limit=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]); - expect(body.latestSeq).toBe(6); - }); - - it("?limit=N with N >= conversation size returns the full log", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?limit=10"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]); - expect(body.latestSeq).toBe(6); - }); - - it("?beforeSeq=S returns only chunks with seq < S", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?beforeSeq=3"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]); - expect(body.latestSeq).toBe(2); - }); - - it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - // selection = seq 1..4; newest 2 = [3, 4] - expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); - expect(body.latestSeq).toBe(4); - }); - - it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); - expect(body.latestSeq).toBe(4); - }); - - describe("window param validation → 400 and store not called with an invalid window", () => { - function appCapturingWindow() { - const calls: { - readonly sinceSeq: number | undefined; - readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined; - }[] = []; - const store: ConversationStore = { - async append() {}, - async load() { - return []; - }, - async loadSince(_conversationId, sinceSeq, window) { - calls.push({ sinceSeq, window }); - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - return { app, calls }; - } - - const cases: readonly { readonly name: string; readonly query: string }[] = [ - { name: "limit=0", query: "limit=0" }, - { name: "limit=-1", query: "limit=-1" }, - { name: "limit=abc", query: "limit=abc" }, - { name: "beforeSeq=0", query: "beforeSeq=0" }, - { name: "beforeSeq=1.5", query: "beforeSeq=1.5" }, - ]; - - for (const { name, query } of cases) { - it(`${name} → 400 { error } and loadSince is never called`, async () => { - const { app, calls } = appCapturingWindow(); - const res = await app.request(`/conversations/conv1?${query}`); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(typeof body.error).toBe("string"); - expect(body.error.length).toBeGreaterThan(0); - expect(calls).toHaveLength(0); - }); - } - }); - - it("no params → byte-identical to the no-window read (regression guard)", async () => { - // Rest-param spy: record how many args the route actually passes, so we - // can prove the third (window) arg is OMITTED entirely — not merely - // forwarded as undefined — preserving the existing two-arg call shape. - const argCounts: number[] = []; - const store: ConversationStore = { - async append() {}, - async load() { - return []; - }, - loadSince(...args: Parameters) { - argCounts.push(args.length); - const sinceSeq = args[1] ?? 0; - return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq)); - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]); - expect(body.latestSeq).toBe(4); - // Called once, with exactly two arguments (no window arg). - expect(argCounts).toEqual([2]); - }); + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, + ]; + + it("returns the full seq-ordered StoredChunk history", async () => { + const store = new Map([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(4); + expect(body.chunks[0]?.seq).toBe(1); + expect(body.chunks[3]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns only chunks with seq > N and latestSeq = last seq", async () => { + const store = new Map([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(2); + expect(body.chunks[0]?.seq).toBe(3); + expect(body.chunks[1]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { + const store = new Map([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=4"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(0); + }); + + it("returns 400 for invalid sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=abc"); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("sinceSeq"); + }); + + it("returns 400 for negative sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=-1"); + expect(res.status).toBe(400); + }); + + const sixChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "one" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "three" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } }, + { seq: 5, role: "user", chunk: { type: "text", text: "five" } }, + { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } }, + ]; + + function appWithChunks(chunks: StoredChunk[]) { + const store = new Map([["conv1", chunks]]); + return createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + } + + it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?limit=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]); + expect(body.latestSeq).toBe(6); + }); + + it("?limit=N with N >= conversation size returns the full log", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?limit=10"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]); + expect(body.latestSeq).toBe(6); + }); + + it("?beforeSeq=S returns only chunks with seq < S", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?beforeSeq=3"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]); + expect(body.latestSeq).toBe(2); + }); + + it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + // selection = seq 1..4; newest 2 = [3, 4] + expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); + expect(body.latestSeq).toBe(4); + }); + + it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); + expect(body.latestSeq).toBe(4); + }); + + describe("window param validation → 400 and store not called with an invalid window", () => { + function appCapturingWindow() { + const calls: { + readonly sinceSeq: number | undefined; + readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined; + }[] = []; + const store: ConversationStore = { + async append() {}, + async load() { + return []; + }, + async loadSince(_conversationId, sinceSeq, window) { + calls.push({ sinceSeq, window }); + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + return { app, calls }; + } + + const cases: readonly { readonly name: string; readonly query: string }[] = [ + { name: "limit=0", query: "limit=0" }, + { name: "limit=-1", query: "limit=-1" }, + { name: "limit=abc", query: "limit=abc" }, + { name: "beforeSeq=0", query: "beforeSeq=0" }, + { name: "beforeSeq=1.5", query: "beforeSeq=1.5" }, + ]; + + for (const { name, query } of cases) { + it(`${name} → 400 { error } and loadSince is never called`, async () => { + const { app, calls } = appCapturingWindow(); + const res = await app.request(`/conversations/conv1?${query}`); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + expect(calls).toHaveLength(0); + }); + } + }); + + it("no params → byte-identical to the no-window read (regression guard)", async () => { + // Rest-param spy: record how many args the route actually passes, so we + // can prove the third (window) arg is OMITTED entirely — not merely + // forwarded as undefined — preserving the existing two-arg call shape. + const argCounts: number[] = []; + const store: ConversationStore = { + async append() {}, + async load() { + return []; + }, + loadSince(...args: Parameters) { + argCounts.push(args.length); + const sinceSeq = args[1] ?? 0; + return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq)); + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]); + expect(body.latestSeq).toBe(4); + // Called once, with exactly two arguments (no window arg). + expect(argCounts).toEqual([2]); + }); }); describe("GET /conversations/:id/metrics", () => { - const sampleMetrics: TurnMetrics[] = [ - { - turnId: "turn1", - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, - durationMs: 1000, - steps: [ - { - stepId: "step1" as StepId, - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, - ttftMs: 200, - decodeMs: 300, - genTotalMs: 500, - }, - ], - }, - { - turnId: "turn2", - usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, - durationMs: 1500, - steps: [ - { - stepId: "step2" as StepId, - usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, - ttftMs: 300, - decodeMs: 500, - genTotalMs: 800, - }, - ], - }, - ]; - - it("returns persisted turn metrics as { turns }", async () => { - const metricsStore = new Map([["conv1", sampleMetrics]]); - const app = createApp({ - conversationStore: createFakeConversationStore(new Map(), metricsStore), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1/metrics"); - expect(res.status).toBe(200); - const body = (await res.json()) as { turns: readonly TurnMetrics[] }; - expect(body.turns).toHaveLength(2); - expect(body.turns[0]?.turnId).toBe("turn1"); - expect(body.turns[1]?.turnId).toBe("turn2"); - }); - - it("returns { turns: [] } for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/unknown/metrics"); - expect(res.status).toBe(200); - const body = (await res.json()) as { turns: readonly TurnMetrics[] }; - expect(body.turns).toHaveLength(0); - }); - - it("the metrics route does not collide with GET /conversations/:id history route", async () => { - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - ]; - const store = new Map([["conv1", sampleChunks]]); - const metricsStore = new Map([["conv1", sampleMetrics]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store, metricsStore), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const metricsRes = await app.request("/conversations/conv1/metrics"); - expect(metricsRes.status).toBe(200); - const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] }; - expect(metricsBody.turns).toHaveLength(2); - - const historyRes = await app.request("/conversations/conv1"); - expect(historyRes.status).toBe(200); - const historyBody = (await historyRes.json()) as { - chunks: readonly StoredChunk[]; - latestSeq: number; - }; - expect(historyBody.chunks).toHaveLength(1); - }); - - it("a store failure on the metrics read returns an error status + logs an error", async () => { - const logger = createFakeLogger(); - const brokenStore: ConversationStore = { - async append() {}, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - throw new Error("storage exploded"); - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: brokenStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - const res = await app.request("/conversations/conv1/metrics"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to load conversation metrics"); - - const errorLogs = logger.records.filter((r) => r.level === "error"); - expect(errorLogs).toHaveLength(1); - expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure"); - expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); - }); + const sampleMetrics: TurnMetrics[] = [ + { + turnId: "turn1", + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, + durationMs: 1000, + steps: [ + { + stepId: "step1" as StepId, + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, + ttftMs: 200, + decodeMs: 300, + genTotalMs: 500, + }, + ], + }, + { + turnId: "turn2", + usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, + durationMs: 1500, + steps: [ + { + stepId: "step2" as StepId, + usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, + ttftMs: 300, + decodeMs: 500, + genTotalMs: 800, + }, + ], + }, + ]; + + it("returns persisted turn metrics as { turns }", async () => { + const metricsStore = new Map([["conv1", sampleMetrics]]); + const app = createApp({ + conversationStore: createFakeConversationStore(new Map(), metricsStore), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1/metrics"); + expect(res.status).toBe(200); + const body = (await res.json()) as { turns: readonly TurnMetrics[] }; + expect(body.turns).toHaveLength(2); + expect(body.turns[0]?.turnId).toBe("turn1"); + expect(body.turns[1]?.turnId).toBe("turn2"); + }); + + it("returns { turns: [] } for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown/metrics"); + expect(res.status).toBe(200); + const body = (await res.json()) as { turns: readonly TurnMetrics[] }; + expect(body.turns).toHaveLength(0); + }); + + it("the metrics route does not collide with GET /conversations/:id history route", async () => { + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + ]; + const store = new Map([["conv1", sampleChunks]]); + const metricsStore = new Map([["conv1", sampleMetrics]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store, metricsStore), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const metricsRes = await app.request("/conversations/conv1/metrics"); + expect(metricsRes.status).toBe(200); + const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] }; + expect(metricsBody.turns).toHaveLength(2); + + const historyRes = await app.request("/conversations/conv1"); + expect(historyRes.status).toBe(200); + const historyBody = (await historyRes.json()) as { + chunks: readonly StoredChunk[]; + latestSeq: number; + }; + expect(historyBody.chunks).toHaveLength(1); + }); + + it("a store failure on the metrics read returns an error status + logs an error", async () => { + const logger = createFakeLogger(); + const brokenStore: ConversationStore = { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + throw new Error("storage exploded"); + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: brokenStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + const res = await app.request("/conversations/conv1/metrics"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to load conversation metrics"); + + const errorLogs = logger.records.filter((r) => r.level === "error"); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure"); + expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); + }); }); describe("POST /chat logging", () => { - it("POST /chat logs an info line when a request is accepted", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - model: "opencode/m1", - cwd: "/tmp", - }), - }); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("chat: request accepted"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.hasModel).toBe(true); - expect(infoLogs[0]?.attrs?.hasCwd).toBe(true); - }); - - it("POST /chat logs a warn on a malformed body (400)", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - - const warnLogs = logger.records.filter((r) => r.level === "warn"); - expect(warnLogs.length).toBeGreaterThanOrEqual(1); - expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body"); - }); - - it("POST /chat logs an error when the turn fails", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createThrowingOrchestrator(new Error("boom")), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - const errorLogs = logger.records.filter((r) => r.level === "error"); - expect(errorLogs).toHaveLength(1); - expect(errorLogs[0]?.msg).toBe("chat: turn failed"); - expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); - }); + it("POST /chat logs an info line when a request is accepted", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + model: "opencode/m1", + cwd: "/tmp", + }), + }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("chat: request accepted"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.hasModel).toBe(true); + expect(infoLogs[0]?.attrs?.hasCwd).toBe(true); + }); + + it("POST /chat logs a warn on a malformed body (400)", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + + const warnLogs = logger.records.filter((r) => r.level === "warn"); + expect(warnLogs.length).toBeGreaterThanOrEqual(1); + expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body"); + }); + + it("POST /chat logs an error when the turn fails", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createThrowingOrchestrator(new Error("boom")), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + const errorLogs = logger.records.filter((r) => r.level === "error"); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0]?.msg).toBe("chat: turn failed"); + expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); + }); }); describe("GET /conversations/:id logging", () => { - it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => { - const logger = createFakeLogger(); - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, - ]; - const store = new Map([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1?sinceSeq=0"); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("conversations: read"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0); - expect(infoLogs[0]?.attrs?.count).toBe(2); - }); + it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => { + const logger = createFakeLogger(); + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + ]; + const store = new Map([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1?sinceSeq=0"); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: read"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0); + expect(infoLogs[0]?.attrs?.count).toBe(2); + }); }); describe("CORS", () => { - function createTestApp() { - return createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore(["opencode/m1"]), - }); - } - - it("POST /chat response carries Access-Control-Allow-Origin: *", async () => { - const app = createTestApp(); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); - - it("GET /models response carries the CORS headers", async () => { - const app = createTestApp(); - const res = await app.request("/models"); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); - }); - - it("GET /conversations/:id response carries the CORS headers", async () => { - const app = createTestApp(); - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); - }); - - it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => { - const app = createTestApp(); - const res = await app.request("/chat", { method: "OPTIONS" }); - expect(res.status).toBe(204); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS"); - expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type"); - }); + function createTestApp() { + return createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore(["opencode/m1"]), + }); + } + + it("POST /chat response carries Access-Control-Allow-Origin: *", async () => { + const app = createTestApp(); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("GET /models response carries the CORS headers", async () => { + const app = createTestApp(); + const res = await app.request("/models"); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); + }); + + it("GET /conversations/:id response carries the CORS headers", async () => { + const app = createTestApp(); + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); + }); + + it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => { + const app = createTestApp(); + const res = await app.request("/chat", { method: "OPTIONS" }); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS"); + expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type"); + }); }); describe("throughput recording + GET /metrics/throughput", () => { - const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); - const day = dayKeyOf(ts); - - function appWith( - throughputStore: ReturnType, - events: AgentEvent[], - ) { - return createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator(events), - credentialStore: createFakeCredentialStore([]), - throughputStore, - now: () => ts, - }); - } - - async function postChat(app: ReturnType, body: Record) { - return app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - } - - it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => { - const store = createThroughputStore({ storage: createMemStorage() }); - const events: AgentEvent[] = [ - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - genTotalMs: 2000, - }, - { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 10, outputTokens: 400 }, - }, - ]; - const app = appWith(store, events); - - const chat = await postChat(app, { - conversationId: "c1", - message: "hi", - model: "claude/haiku", - }); - expect(chat.status).toBe(200); - - const res = await app.request(`/metrics/throughput?period=day&date=${day}`); - expect(res.status).toBe(200); - const report = (await res.json()) as ThroughputResponse; - expect(report.period).toBe("day"); - expect(report.models).toHaveLength(1); - expect(report.models[0]).toMatchObject({ - model: "claude/haiku", - totalOutputTokens: 400, - totalGenMs: 2000, - tokensPerSecond: 200, // 400 tokens / 2s - turns: 1, - }); - }); - - it("does not record a sample when no model is selected", async () => { - const store = createThroughputStore({ storage: createMemStorage() }); - const events: AgentEvent[] = [ - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - genTotalMs: 2000, - }, - { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 1, outputTokens: 5 }, - }, - ]; - const app = appWith(store, events); - - await postChat(app, { conversationId: "c1", message: "hi" }); // no model - const res = await app.request(`/metrics/throughput?period=day&date=${day}`); - const report = (await res.json()) as { models: unknown[] }; - expect(report.models).toEqual([]); - }); - - it("returns 400 for an invalid period", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=year&date=2026"); - expect(res.status).toBe(400); - }); - - it("returns 400 for a malformed date", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=day&date=nope"); - expect(res.status).toBe(400); - }); - - it("returns 400 when date is missing", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=day"); - expect(res.status).toBe(400); - }); + const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); + const day = dayKeyOf(ts); + + function appWith( + throughputStore: ReturnType, + events: AgentEvent[], + ) { + return createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator(events), + credentialStore: createFakeCredentialStore([]), + throughputStore, + now: () => ts, + }); + } + + async function postChat(app: ReturnType, body: Record) { + return app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => { + const store = createThroughputStore({ storage: createMemStorage() }); + const events: AgentEvent[] = [ + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + genTotalMs: 2000, + }, + { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 10, outputTokens: 400 }, + }, + ]; + const app = appWith(store, events); + + const chat = await postChat(app, { + conversationId: "c1", + message: "hi", + model: "claude/haiku", + }); + expect(chat.status).toBe(200); + + const res = await app.request(`/metrics/throughput?period=day&date=${day}`); + expect(res.status).toBe(200); + const report = (await res.json()) as ThroughputResponse; + expect(report.period).toBe("day"); + expect(report.models).toHaveLength(1); + expect(report.models[0]).toMatchObject({ + model: "claude/haiku", + totalOutputTokens: 400, + totalGenMs: 2000, + tokensPerSecond: 200, // 400 tokens / 2s + turns: 1, + }); + }); + + it("does not record a sample when no model is selected", async () => { + const store = createThroughputStore({ storage: createMemStorage() }); + const events: AgentEvent[] = [ + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + genTotalMs: 2000, + }, + { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 1, outputTokens: 5 }, + }, + ]; + const app = appWith(store, events); + + await postChat(app, { conversationId: "c1", message: "hi" }); // no model + const res = await app.request(`/metrics/throughput?period=day&date=${day}`); + const report = (await res.json()) as { models: unknown[] }; + expect(report.models).toEqual([]); + }); + + it("returns 400 for an invalid period", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=year&date=2026"); + expect(res.status).toBe(400); + }); + + it("returns 400 for a malformed date", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=day&date=nope"); + expect(res.status).toBe(400); + }); + + it("returns 400 when date is missing", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=day"); + expect(res.status).toBe(400); + }); }); describe("POST /conversations/:id/close", () => { - it("closes via the orchestrator and returns CloseConversationResponse", async () => { - const closeCalls: string[] = []; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - closeConversation(conversationId) { - closeCalls.push(conversationId); - return { abortedTurn: true }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-9/close", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true }); - expect(closeCalls).toEqual(["conv-9"]); - }); - - it("reports abortedTurn false for an idle conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-idle/close", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false }); - }); + it("closes via the orchestrator and returns CloseConversationResponse", async () => { + const closeCalls: string[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + closeConversation(conversationId) { + closeCalls.push(conversationId); + return { abortedTurn: true }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-9/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true }); + expect(closeCalls).toEqual(["conv-9"]); + }); + + it("reports abortedTurn false for an idle conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false }); + }); }); describe("POST /conversations/:id/queue", () => { - it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => { - const queue: readonly QueuedMessage[] = [ - { id: "q1", text: "queued-msg", queuedAt: 1700000000000 }, - ]; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: false, queue }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "hello" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv1"); - expect(body.startedTurn).toBe(false); - expect(body.queue).toEqual(queue); - }); - - it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => { - let enqueueCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - enqueueCalled = true; - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: " " }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - expect(enqueueCalled).toBe(false); - }); - - it("with missing text field → 400 { error } and enqueue is never called", async () => { - let enqueueCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - enqueueCalled = true; - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - expect(enqueueCalled).toBe(false); - }); - - it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => { - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: true, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-idle/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "go" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv-idle"); - expect(body.startedTurn).toBe(true); - expect(body.queue).toEqual([]); - }); - - it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => { - const queue: readonly QueuedMessage[] = [ - { id: "q1", text: "second", queuedAt: 1700000000000 }, - { id: "q2", text: "third", queuedAt: 1700000001000 }, - ]; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: false, queue }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-active/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "steer" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv-active"); - expect(body.startedTurn).toBe(false); - expect(body.queue).toEqual(queue); - }); - - it("forwards the path conversationId and trimmed text to enqueue", async () => { - const calls: { conversationId: string; text: string }[] = []; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue(input) { - calls.push(input); - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: " hello world " }), - }); - - expect(res.status).toBe(200); - expect(calls).toHaveLength(1); - expect(calls[0]?.conversationId).toBe("conv-1"); - expect(calls[0]?.text).toBe("hello world"); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("JSON"); - }); - - it("returns 400 for a non-string text", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: 42 }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - }); - - it("logs an info line on success and never logs the enqueued text", async () => { - const logger = createFakeLogger(); - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: true, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "secret-ish user message" }), - }); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("conversations: enqueued"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.startedTurn).toBe(true); - expect(infoLogs[0]?.attrs?.queueLength).toBe(0); - // Restraint: the user's message text is never logged (mirrors POST /chat). - expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message"); - }); - - it("logs a warn on a malformed body (400)", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "" }), - }); - - const warnLogs = logger.records.filter((r) => r.level === "warn"); - expect(warnLogs.length).toBeGreaterThanOrEqual(1); - expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed"); - }); + it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "queued-msg", queuedAt: 1700000000000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "hello" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv1"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " " }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("with missing text field → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => { + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "go" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-idle"); + expect(body.startedTurn).toBe(true); + expect(body.queue).toEqual([]); + }); + + it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "second", queuedAt: 1700000000000 }, + { id: "q2", text: "third", queuedAt: 1700000001000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-active/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "steer" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-active"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("forwards the path conversationId and trimmed text to enqueue", async () => { + const calls: { conversationId: string; text: string }[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue(input) { + calls.push(input); + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " hello world " }), + }); + + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]?.conversationId).toBe("conv-1"); + expect(calls[0]?.text).toBe("hello world"); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("JSON"); + }); + + it("returns 400 for a non-string text", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: 42 }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + }); + + it("logs an info line on success and never logs the enqueued text", async () => { + const logger = createFakeLogger(); + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "secret-ish user message" }), + }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: enqueued"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.startedTurn).toBe(true); + expect(infoLogs[0]?.attrs?.queueLength).toBe(0); + // Restraint: the user's message text is never logged (mirrors POST /chat). + expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message"); + }); + + it("logs a warn on a malformed body (400)", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "" }), + }); + + const warnLogs = logger.records.filter((r) => r.level === "warn"); + expect(warnLogs.length).toBeGreaterThanOrEqual(1); + expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed"); + }); }); describe("GET /conversations/:id/cwd", () => { - it("returns null when unset", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; cwd: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - }); + it("returns null when unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + }); }); describe("PUT then GET /conversations/:id/cwd", () => { - it("round-trips the value", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(putRes.status).toBe(200); - const putBody = (await putRes.json()) as { conversationId: string; cwd: string }; - expect(putBody.conversationId).toBe("conv1"); - expect(putBody.cwd).toBe("/home/user/project"); - - const getRes = await app.request("/conversations/conv1/cwd"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; - expect(getBody.cwd).toBe("/home/user/project"); - }); + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; cwd: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.cwd).toBe("/home/user/project"); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBe("/home/user/project"); + }); }); describe("DELETE /conversations/:id/cwd", () => { - it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(putRes.status).toBe(200); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.cwd).toBeNull(); - - const getRes = await app.request("/conversations/conv1/cwd"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; - expect(getBody.cwd).toBeNull(); - }); - - it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.cwd).toBeNull(); - }); - - it("does NOT affect other conversations' cwds (isolation)", async () => { - const cwdStore = new Map([ - ["conv1", "/home/user/project"], - ["conv2", "/other/path"], - ]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - - const get1Res = await app.request("/conversations/conv1/cwd"); - expect(get1Res.status).toBe(200); - const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null }; - expect(get1Body.cwd).toBeNull(); - - const get2Res = await app.request("/conversations/conv2/cwd"); - expect(get2Res.status).toBe(200); - const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null }; - expect(get2Body.cwd).toBe("/other/path"); - }); + it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBeNull(); + }); + + it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + }); + + it("does NOT affect other conversations' cwds (isolation)", async () => { + const cwdStore = new Map([ + ["conv1", "/home/user/project"], + ["conv2", "/other/path"], + ]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1Res = await app.request("/conversations/conv1/cwd"); + expect(get1Res.status).toBe(200); + const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null }; + expect(get1Body.cwd).toBeNull(); + + const get2Res = await app.request("/conversations/conv2/cwd"); + expect(get2Res.status).toBe(200); + const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null }; + expect(get2Body.cwd).toBe("/other/path"); + }); }); describe("PUT /conversations/:id/cwd", () => { - it("with missing cwd returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("cwd"); - }); - - it("with empty cwd returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "" }), - }); - expect(res.status).toBe(400); - }); - - it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; cwd: string }; - expect(body.cwd).toBe("/home/user/project"); - // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd. - expect(store.calls).toEqual([ - "ensureWorkspace:my-team", - "setWorkspaceId:my-team", - "setCwd:/home/user/project", - ]); - }); - - it("PUT cwd without workspaceId: only setCwd", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(res.status).toBe(200); - // ensureWorkspace and setWorkspaceId NOT called. - expect(store.calls).toEqual(["setCwd:/home/user/project"]); - }); - - it("PUT cwd with invalid workspaceId: returns 400", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - // Uppercase is not a valid workspace slug. - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("Invalid workspaceId"); - // No mutating calls should have been made. - expect(store.calls).toEqual([]); - - // Empty string is also invalid. - const res2 = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }), - }); - expect(res2.status).toBe(400); - }); + it("with missing cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("cwd"); + }); + + it("with empty cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "" }), + }); + expect(res.status).toBe(400); + }); + + it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string }; + expect(body.cwd).toBe("/home/user/project"); + // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd. + expect(store.calls).toEqual([ + "ensureWorkspace:my-team", + "setWorkspaceId:my-team", + "setCwd:/home/user/project", + ]); + }); + + it("PUT cwd without workspaceId: only setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(res.status).toBe(200); + // ensureWorkspace and setWorkspaceId NOT called. + expect(store.calls).toEqual(["setCwd:/home/user/project"]); + }); + + it("PUT cwd with invalid workspaceId: returns 400", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + // Uppercase is not a valid workspace slug. + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Invalid workspaceId"); + // No mutating calls should have been made. + expect(store.calls).toEqual([]); + + // Empty string is also invalid. + const res2 = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }), + }); + expect(res2.status).toBe(400); + }); }); describe("GET /conversations/:id/lsp", () => { - it("returns empty servers when cwd is unset", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: createFakeLspService(), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - }); - - it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => { - const cwdStore = new Map([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const lspStatuses = [ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - { - id: "lua-lsp", - name: "Lua LSP", - root: "/home/user/project", - extensions: [".luau"], - state: "error" as const, - error: "spawn failed", - }, - ]; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: createFakeLspService(lspStatuses), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: string; - readonly error?: string; - }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/home/user/project"); - expect(body.servers).toHaveLength(2); - expect(body.servers[0]?.id).toBe("typescript"); - expect(body.servers[0]?.state).toBe("connected"); - expect(body.servers[0]?.error).toBeUndefined(); - expect(body.servers[1]?.id).toBe("lua-lsp"); - expect(body.servers[1]?.state).toBe("error"); - expect(body.servers[1]?.error).toBe("spawn failed"); - }); - - it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => { - const cwdStore = new Map(); // no persisted cwd - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const lsp = createCapturingLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/irrelevant", - extensions: [".ts"], - state: "connected" as const, - }, - ]); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - expect(lsp.statusCalls).toEqual([]); // status NOT called - }); - - it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { - // Persisted (relative) cwd differs from the resolved effective cwd. - const cwdStore = new Map([["conv1", "subdir"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - // Override getEffectiveCwd to return the resolved (absolute) value. - const resolvedStore: ConversationStore = { - ...store, - async getEffectiveCwd() { - return "/workspace/subdir"; - }, - }; - const lsp = createCapturingLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/workspace/subdir", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - ]); - const app = createApp({ - conversationStore: resolvedStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { readonly id: string }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted - expect(lsp.statusCalls).toEqual(["/workspace/subdir"]); - expect(body.servers).toHaveLength(1); - expect(body.servers[0]?.id).toBe("typescript"); - }); - - it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => { - const cwdStore = new Map([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - // Case 1: configSource is defined → reaches the wire verbatim. - const lspWithSource = createFakeLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - configSource: ".dispatch/lsp.json", - }, - ]); - const appWithSource = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lspWithSource, - logger: noopLogger, - }); - const resWithSource = await appWithSource.request("/conversations/conv1/lsp"); - expect(resWithSource.status).toBe(200); - const bodyWithSource = (await resWithSource.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly configSource?: string; - }[]; - }; - expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json"); - - // Case 2: configSource is undefined → the field is OMITTED from the - // response (proves exactOptionalPropertyTypes is respected — never - // stamping `undefined` onto the wire object). - const lspWithoutSource = createFakeLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - ]); - const appWithoutSource = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lspWithoutSource, - logger: noopLogger, - }); - const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp"); - expect(resWithoutSource.status).toBe(200); - const bodyWithoutSource = (await resWithoutSource.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly Record[]; - }; - expect(bodyWithoutSource.servers).toHaveLength(1); - expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource"); - }); + it("returns empty servers when cwd is unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + }); + + it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lspStatuses = [ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + { + id: "lua-lsp", + name: "Lua LSP", + root: "/home/user/project", + extensions: [".luau"], + state: "error" as const, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(lspStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("typescript"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("lua-lsp"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); + + it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => { + const cwdStore = new Map(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/irrelevant", + extensions: [".ts"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(lsp.statusCalls).toEqual([]); // status NOT called + }); + + it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + // Persisted (relative) cwd differs from the resolved effective cwd. + const cwdStore = new Map([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Override getEffectiveCwd to return the resolved (absolute) value. + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/workspace/subdir", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(lsp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("typescript"); + }); + + it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Case 1: configSource is defined → reaches the wire verbatim. + const lspWithSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + configSource: ".dispatch/lsp.json", + }, + ]); + const appWithSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithSource, + logger: noopLogger, + }); + const resWithSource = await appWithSource.request("/conversations/conv1/lsp"); + expect(resWithSource.status).toBe(200); + const bodyWithSource = (await resWithSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly configSource?: string; + }[]; + }; + expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json"); + + // Case 2: configSource is undefined → the field is OMITTED from the + // response (proves exactOptionalPropertyTypes is respected — never + // stamping `undefined` onto the wire object). + const lspWithoutSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const appWithoutSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithoutSource, + logger: noopLogger, + }); + const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp"); + expect(resWithoutSource.status).toBe(200); + const bodyWithoutSource = (await resWithoutSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly Record[]; + }; + expect(bodyWithoutSource.servers).toHaveLength(1); + expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource"); + }); }); describe("GET /conversations/:id/mcp", () => { - it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => { - const cwdStore = new Map(); // no persisted cwd - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const mcp = createCapturingMcpService([ - { - id: "freecad", - state: "connected" as const, - toolCount: 3, - }, - ]); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: mcp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - expect(mcp.statusCalls).toEqual([]); // status NOT called - }); - - it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => { - const cwdStore = new Map([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const mcpStatuses = [ - { - id: "freecad", - state: "connected" as const, - toolCount: 5, - }, - { - id: "broken", - state: "error" as const, - toolCount: 0, - error: "spawn failed", - }, - ]; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: createFakeMcpService(mcpStatuses), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly state: string; - readonly toolCount: number; - readonly error?: string; - }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/home/user/project"); - expect(body.servers).toHaveLength(2); - expect(body.servers[0]?.id).toBe("freecad"); - expect(body.servers[0]?.state).toBe("connected"); - expect(body.servers[0]?.toolCount).toBe(5); - expect(body.servers[0]?.error).toBeUndefined(); - expect(body.servers[1]?.id).toBe("broken"); - expect(body.servers[1]?.state).toBe("error"); - expect(body.servers[1]?.toolCount).toBe(0); - expect(body.servers[1]?.error).toBe("spawn failed"); - }); - - it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { - const cwdStore = new Map([["conv1", "subdir"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const resolvedStore: ConversationStore = { - ...store, - async getEffectiveCwd() { - return "/workspace/subdir"; - }, - }; - const mcp = createCapturingMcpService([ - { - id: "freecad", - state: "connected" as const, - toolCount: 2, - }, - ]); - const app = createApp({ - conversationStore: resolvedStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: mcp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { readonly id: string }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted - expect(mcp.statusCalls).toEqual(["/workspace/subdir"]); - expect(body.servers).toHaveLength(1); - expect(body.servers[0]?.id).toBe("freecad"); - }); - - it("MCP: returns 503 when mcpService is undefined", async () => { - const cwdStore = new Map([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - // mcpService intentionally omitted - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("MCP service not available"); - }); + it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => { + const cwdStore = new Map(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 3, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(mcp.statusCalls).toEqual([]); // status NOT called + }); + + it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcpStatuses = [ + { + id: "freecad", + state: "connected" as const, + toolCount: 5, + }, + { + id: "broken", + state: "error" as const, + toolCount: 0, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: createFakeMcpService(mcpStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly state: string; + readonly toolCount: number; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("freecad"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.toolCount).toBe(5); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("broken"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.toolCount).toBe(0); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); + + it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + const cwdStore = new Map([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 2, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(mcp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("freecad"); + }); + + it("MCP: returns 503 when mcpService is undefined", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + // mcpService intentionally omitted + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("MCP service not available"); + }); }); describe("POST /chat reasoningEffort", () => { - const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; - - for (const level of allLevels) { - it(`forwards reasoningEffort="${level}" to orchestrator`, async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: level, - }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.reasoningEffort).toBe(level); - }); - } - - it("omits reasoningEffort from orchestrator input when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.reasoningEffort).toBeUndefined(); - }); - - it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => { - let handleMessageCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - async handleMessage(input) { - handleMessageCalled = true; - return createFakeOrchestrator([]).handleMessage(input); - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: "banana", - }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("reasoningEffort"); - expect(handleMessageCalled).toBe(false); - }); - - it("returns 400 for non-string reasoningEffort", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: 42, - }), - }); - - expect(res.status).toBe(400); - }); + const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + + for (const level of allLevels) { + it(`forwards reasoningEffort="${level}" to orchestrator`, async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: level, + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBe(level); + }); + } + + it("omits reasoningEffort from orchestrator input when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBeUndefined(); + }); + + it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => { + let handleMessageCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + async handleMessage(input) { + handleMessageCalled = true; + return createFakeOrchestrator([]).handleMessage(input); + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: "banana", + }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + expect(handleMessageCalled).toBe(false); + }); + + it("returns 400 for non-string reasoningEffort", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: 42, + }), + }); + + expect(res.status).toBe(400); + }); }); describe("GET /conversations/:id/reasoning-effort", () => { - it("returns null when never set", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.reasoningEffort).toBeNull(); - }); - - it("returns the level after PUT", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "xhigh" }), - }); - - const res = await app.request("/conversations/conv1/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.reasoningEffort).toBe("xhigh"); - }); - - it("returns null for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/unknown/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.conversationId).toBe("unknown"); - expect(body.reasoningEffort).toBeNull(); - }); + it("returns null when never set", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBeNull(); + }); + + it("returns the level after PUT", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "xhigh" }), + }); + + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.reasoningEffort).toBe("xhigh"); + }); + + it("returns null for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/unknown/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("unknown"); + expect(body.reasoningEffort).toBeNull(); + }); }); describe("PUT /conversations/:id/reasoning-effort", () => { - it("persists a valid level and returns it", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "low" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.reasoningEffort).toBe("low"); - }); - - it("returns 400 for an invalid level", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "banana" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("reasoningEffort"); - }); - - it("returns 400 when reasoningEffort is missing from body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("does not call store on validation failure", async () => { - let storeCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setReasoningEffort() { - storeCalled = true; - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "invalid" }), - }); - expect(res.status).toBe(400); - expect(storeCalled).toBe(false); - }); + it("persists a valid level and returns it", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "low" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBe("low"); + }); + + it("returns 400 for an invalid level", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "banana" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + }); + + it("returns 400 when reasoningEffort is missing from body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("does not call store on validation failure", async () => { + let storeCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setReasoningEffort() { + storeCalled = true; + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "invalid" }), + }); + expect(res.status).toBe(400); + expect(storeCalled).toBe(false); + }); }); describe("GET /conversations/:id/model", () => { - it("returns null when never set", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.model).toBeNull(); - }); - - it("returns the model after PUT", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "umans/umans-glm-5.2" }), - }); - - const res = await app.request("/conversations/conv1/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBe("umans/umans-glm-5.2"); - }); - - it("returns null for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/unknown/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("unknown"); - expect(body.model).toBeNull(); - }); + it("returns null when never set", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.model).toBeNull(); + }); + + it("returns the model after PUT", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "umans/umans-glm-5.2" }), + }); + + const res = await app.request("/conversations/conv1/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBe("umans/umans-glm-5.2"); + }); + + it("returns null for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/unknown/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("unknown"); + expect(body.model).toBeNull(); + }); }); describe("PUT /conversations/:id/model", () => { - it("persists a non-empty model and returns it", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "umans/umans-glm-5.2" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.model).toBe("umans/umans-glm-5.2"); - - // A subsequent GET reflects the persisted value. - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBe("umans/umans-glm-5.2"); - }); - - it("clears the model when model is null and GET returns null", async () => { - const modelStore = new Map([["conv1", "umans/umans-glm-5.2"]]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - modelStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - // Preconditions: a model is set. - const before = await app.request("/conversations/conv1/model"); - expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2"); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: null }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBeNull(); - - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBeNull(); - }); - - it("clears the model when model is an empty string and GET returns null", async () => { - const modelStore = new Map([["conv1", "umans/umans-glm-5.2"]]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - modelStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBeNull(); - - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBeNull(); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when model field is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("model"); - }); - - it("returns 400 when model is a non-string non-null type", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: 42 }), - }); - expect(res.status).toBe(400); - }); - - it("does not call store on validation failure", async () => { - let storeCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setModel() { - storeCalled = true; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(storeCalled).toBe(false); - }); + it("persists a non-empty model and returns it", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "umans/umans-glm-5.2" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.model).toBe("umans/umans-glm-5.2"); + + // A subsequent GET reflects the persisted value. + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBe("umans/umans-glm-5.2"); + }); + + it("clears the model when model is null and GET returns null", async () => { + const modelStore = new Map([["conv1", "umans/umans-glm-5.2"]]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + modelStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + // Preconditions: a model is set. + const before = await app.request("/conversations/conv1/model"); + expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2"); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBeNull(); + + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBeNull(); + }); + + it("clears the model when model is an empty string and GET returns null", async () => { + const modelStore = new Map([["conv1", "umans/umans-glm-5.2"]]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + modelStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBeNull(); + + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBeNull(); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when model field is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("model"); + }); + + it("returns 400 when model is a non-string non-null type", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: 42 }), + }); + expect(res.status).toBe(400); + }); + + it("does not call store on validation failure", async () => { + let storeCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setModel() { + storeCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(storeCalled).toBe(false); + }); }); describe("GET /conversations", () => { - const sampleConvos: ConversationMeta[] = [ - { - id: "conv-1", - createdAt: 1000, - lastActivityAt: 2000, - title: "First", - status: "idle", - workspaceId: "default", - }, - { - id: "conv-2", - createdAt: 1500, - lastActivityAt: 2500, - title: "Second", - status: "idle", - workspaceId: "default", - }, - { - id: "other-1", - createdAt: 3000, - lastActivityAt: 4000, - title: "Other", - status: "idle", - workspaceId: "default", - }, - ]; - - function appWithList(list: ConversationMeta[]) { - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations() { - return list; - }, - }; - return createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - } - - it("returns 200 with list", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]); - }); - - it("?q= filters by id prefix", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q=conv-"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(2); - expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]); - }); - - it("?q= returns all when q is empty", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q="); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - }); - - it("?q= with whitespace-only returns all (trimmed to empty)", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q=%20%20%20"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - }); - - it("returns 500 when listConversations throws", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations() { - throw new Error("db down"); - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to list conversations"); - }); + const sampleConvos: ConversationMeta[] = [ + { + id: "conv-1", + createdAt: 1000, + lastActivityAt: 2000, + title: "First", + status: "idle", + workspaceId: "default", + }, + { + id: "conv-2", + createdAt: 1500, + lastActivityAt: 2500, + title: "Second", + status: "idle", + workspaceId: "default", + }, + { + id: "other-1", + createdAt: 3000, + lastActivityAt: 4000, + title: "Other", + status: "idle", + workspaceId: "default", + }, + ]; + + function appWithList(list: ConversationMeta[]) { + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations() { + return list; + }, + }; + return createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + } + + it("returns 200 with list", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]); + }); + + it("?q= filters by id prefix", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q=conv-"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(2); + expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]); + }); + + it("?q= returns all when q is empty", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q="); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + }); + + it("?q= with whitespace-only returns all (trimmed to empty)", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q=%20%20%20"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + }); + + it("returns 500 when listConversations throws", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations() { + throw new Error("db down"); + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to list conversations"); + }); }); describe("GET /conversations/:id/last", () => { - function appWithMessages(messagesByConv: Map) { - const store: ConversationStore = { - ...createFakeConversationStore(), - async load(conversationId) { - return messagesByConv.get(conversationId) ?? []; - }, - }; - return createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - } - - it("returns last assistant text", async () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - { role: "user", chunks: [{ type: "text", text: "more" }] }, - { role: "assistant", chunks: [{ type: "text", text: "final reply" }] }, - ]; - const app = appWithMessages(new Map([["conv1", messages]])); - const res = await app.request("/conversations/conv1/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.content).toBe("final reply"); - expect(body.turnId).toBeUndefined(); - }); - - it("returns empty content for unknown conversation", async () => { - const app = appWithMessages(new Map()); - const res = await app.request("/conversations/unknown/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("unknown"); - expect(body.content).toBe(""); - expect(body.turnId).toBeUndefined(); - }); - - it("blocks until turn settles", async () => { - const turnId = "sealed-turn"; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - subscribe(conversationId, listener) { - const event = { type: "turn-sealed" as const, conversationId, turnId }; - setTimeout(() => listener(event), 0); - return () => {}; - }, - isActive() { - return true; - }, - }; - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "after seal" }] }, - ]; - const store: ConversationStore = { - ...createFakeConversationStore(), - async load() { - return messages; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.content).toBe("after seal"); - expect(body.turnId).toBe(turnId); - }); + function appWithMessages(messagesByConv: Map) { + const store: ConversationStore = { + ...createFakeConversationStore(), + async load(conversationId) { + return messagesByConv.get(conversationId) ?? []; + }, + }; + return createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + } + + it("returns last assistant text", async () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + { role: "user", chunks: [{ type: "text", text: "more" }] }, + { role: "assistant", chunks: [{ type: "text", text: "final reply" }] }, + ]; + const app = appWithMessages(new Map([["conv1", messages]])); + const res = await app.request("/conversations/conv1/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.content).toBe("final reply"); + expect(body.turnId).toBeUndefined(); + }); + + it("returns empty content for unknown conversation", async () => { + const app = appWithMessages(new Map()); + const res = await app.request("/conversations/unknown/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("unknown"); + expect(body.content).toBe(""); + expect(body.turnId).toBeUndefined(); + }); + + it("blocks until turn settles", async () => { + const turnId = "sealed-turn"; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + subscribe(conversationId, listener) { + const event = { type: "turn-sealed" as const, conversationId, turnId }; + setTimeout(() => listener(event), 0); + return () => {}; + }, + isActive() { + return true; + }, + }; + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "after seal" }] }, + ]; + const store: ConversationStore = { + ...createFakeConversationStore(), + async load() { + return messages; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.content).toBe("after seal"); + expect(body.turnId).toBe(turnId); + }); }); describe("POST /conversations/:id/open", () => { - it("returns 200", async () => { - const emit: HostAPI["emit"] = () => {}; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - emit, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string }; - expect(body.conversationId).toBe("conv1"); - }); - - it("calls emit with conversationOpened", async () => { - const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = []; - const emit: HostAPI["emit"] = (hook, payload) => { - emitCalls.push({ hook, payload }); - }; - // A store whose getWorkspaceId returns a non-default id, so the test - // proves the handler resolves and forwards the PERSISTED workspace id - // (not a hard-coded "default"). - const store = createFakeConversationStore(); - store.getWorkspaceId = async () => "open-workspace"; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - emit, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(200); - expect(emitCalls).toHaveLength(1); - expect(emitCalls[0]?.hook).toBe(conversationOpened); - expect(emitCalls[0]?.payload).toEqual({ - conversationId: "conv1", - workspaceId: "open-workspace", - }); - }); - - it("returns 500 when emit is absent", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("not available"); - }); + it("returns 200", async () => { + const emit: HostAPI["emit"] = () => {}; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + emit, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string }; + expect(body.conversationId).toBe("conv1"); + }); + + it("calls emit with conversationOpened", async () => { + const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = []; + const emit: HostAPI["emit"] = (hook, payload) => { + emitCalls.push({ hook, payload }); + }; + // A store whose getWorkspaceId returns a non-default id, so the test + // proves the handler resolves and forwards the PERSISTED workspace id + // (not a hard-coded "default"). + const store = createFakeConversationStore(); + store.getWorkspaceId = async () => "open-workspace"; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + emit, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(200); + expect(emitCalls).toHaveLength(1); + expect(emitCalls[0]?.hook).toBe(conversationOpened); + expect(emitCalls[0]?.payload).toEqual({ + conversationId: "conv1", + workspaceId: "open-workspace", + }); + }); + + it("returns 500 when emit is absent", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("not available"); + }); }); describe("PUT /conversations/:id/title", () => { - it("returns 200 with title", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "My Conversation" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; title: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.title).toBe("My Conversation"); - }); - - it("rejects empty title with 400", async () => { - let setTitleCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle() { - setTitleCalled = true; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: " " }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("title"); - expect(setTitleCalled).toBe(false); - }); - - it("returns 400 when title is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("title"); - }); - - it("forwards the trimmed title to setConversationTitle", async () => { - const calls: { conversationId: string; title: string }[] = []; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle(conversationId, title) { - calls.push({ conversationId, title }); - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: " trimmed title " }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; title: string }; - expect(body.title).toBe("trimmed title"); - expect(calls).toHaveLength(1); - expect(calls[0]?.conversationId).toBe("conv1"); - expect(calls[0]?.title).toBe("trimmed title"); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("JSON"); - }); + it("returns 200 with title", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "My Conversation" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; title: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.title).toBe("My Conversation"); + }); + + it("rejects empty title with 400", async () => { + let setTitleCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle() { + setTitleCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: " " }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + expect(setTitleCalled).toBe(false); + }); + + it("returns 400 when title is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + }); + + it("forwards the trimmed title to setConversationTitle", async () => { + const calls: { conversationId: string; title: string }[] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle(conversationId, title) { + calls.push({ conversationId, title }); + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: " trimmed title " }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; title: string }; + expect(body.title).toBe("trimmed title"); + expect(calls).toHaveLength(1); + expect(calls[0]?.conversationId).toBe("conv1"); + expect(calls[0]?.title).toBe("trimmed title"); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("JSON"); + }); }); describe("extractLastAssistantText", () => { - it("returns last assistant text chunk", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - { role: "user", chunks: [{ type: "text", text: "how are you?" }] }, - { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] }, - ]; - expect(extractLastAssistantText(messages)).toBe("I'm good!"); - }); - - it("returns empty string when no assistant message", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, - ]; - expect(extractLastAssistantText(messages)).toBe(""); - }); - - it("returns the LAST text chunk when an assistant message has multiple", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "first" }, - { type: "thinking", text: "internal reasoning" }, - { type: "text", text: "second" }, - ], - }, - ]; - expect(extractLastAssistantText(messages)).toBe("second"); - }); - - it("returns empty string when the last assistant message has no text chunk", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp" }, - }, - ], - }, - ]; - expect(extractLastAssistantText(messages)).toBe(""); - }); - - it("returns empty string for an empty message list", () => { - expect(extractLastAssistantText([])).toBe(""); - }); + it("returns last assistant text chunk", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + { role: "user", chunks: [{ type: "text", text: "how are you?" }] }, + { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] }, + ]; + expect(extractLastAssistantText(messages)).toBe("I'm good!"); + }); + + it("returns empty string when no assistant message", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, + ]; + expect(extractLastAssistantText(messages)).toBe(""); + }); + + it("returns the LAST text chunk when an assistant message has multiple", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "first" }, + { type: "thinking", text: "internal reasoning" }, + { type: "text", text: "second" }, + ], + }, + ]; + expect(extractLastAssistantText(messages)).toBe("second"); + }); + + it("returns empty string when the last assistant message has no text chunk", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp" }, + }, + ], + }, + ]; + expect(extractLastAssistantText(messages)).toBe(""); + }); + + it("returns empty string for an empty message list", () => { + expect(extractLastAssistantText([])).toBe(""); + }); }); describe("Workspaces", () => { - const sampleWorkspace: Workspace = { - id: "proj", - title: "proj", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 2000, - }; - - it("GET /workspaces returns list", async () => { - const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }]; - const store: ConversationStore = { - ...createFakeConversationStore(), - async listWorkspaces() { - return workspaceEntries; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces"); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceListResponse; - expect(body.workspaces).toEqual(workspaceEntries); - }); - - it("PUT /workspaces/:id creates on miss", async () => { - let ensured = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace(id, opts) { - ensured = true; - return { ...sampleWorkspace, id, ...opts }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }), - }); - expect(res.status).toBe(200); - expect(ensured).toBe(true); - const body = (await res.json()) as WorkspaceResponse; - expect(body.id).toBe("proj"); - expect(body.title).toBe("Project"); - expect(body.defaultCwd).toBe("/home/proj"); - }); - - it("PUT /workspaces/:id returns existing", async () => { - const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" }; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace() { - return existing; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "New Title" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.title).toBe("Existing"); - expect(body.defaultCwd).toBe("/old"); - }); - - it("PUT /workspaces/:id rejects invalid slug", async () => { - let ensured = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace() { - ensured = true; - return sampleWorkspace; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/Bad Slug!", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(ensured).toBe(false); - }); - - it("GET /workspaces/:id returns 404 for missing", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async getWorkspace() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/unknown"); - expect(res.status).toBe(404); - }); - - it("PUT /workspaces/:id/title renames", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceTitle(id, title) { - return { ...sampleWorkspace, id, title }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "Renamed" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.title).toBe("Renamed"); - }); - - it("PUT /workspaces/:id/default-cwd sets", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { ...sampleWorkspace, id, defaultCwd }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultCwd: "/new/cwd" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultCwd).toBe("/new/cwd"); - }); - - it("DELETE /workspaces/:id closes conversations", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async deleteWorkspace() { - return { closedCount: 3 }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { method: "DELETE" }); - expect(res.status).toBe(200); - const body = (await res.json()) as DeleteWorkspaceResponse; - expect(body.workspaceId).toBe("proj"); - expect(body.closedCount).toBe(3); - }); - - it("DELETE /workspaces/default returns 409", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/default", { method: "DELETE" }); - expect(res.status).toBe(409); - }); + const sampleWorkspace: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("GET /workspaces returns list", async () => { + const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }]; + const store: ConversationStore = { + ...createFakeConversationStore(), + async listWorkspaces() { + return workspaceEntries; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces"); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceListResponse; + expect(body.workspaces).toEqual(workspaceEntries); + }); + + it("PUT /workspaces/:id creates on miss", async () => { + let ensured = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace(id, opts) { + ensured = true; + return { ...sampleWorkspace, id, ...opts }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }), + }); + expect(res.status).toBe(200); + expect(ensured).toBe(true); + const body = (await res.json()) as WorkspaceResponse; + expect(body.id).toBe("proj"); + expect(body.title).toBe("Project"); + expect(body.defaultCwd).toBe("/home/proj"); + }); + + it("PUT /workspaces/:id returns existing", async () => { + const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" }; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace() { + return existing; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "New Title" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.title).toBe("Existing"); + expect(body.defaultCwd).toBe("/old"); + }); + + it("PUT /workspaces/:id rejects invalid slug", async () => { + let ensured = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace() { + ensured = true; + return sampleWorkspace; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/Bad Slug!", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(ensured).toBe(false); + }); + + it("GET /workspaces/:id returns 404 for missing", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async getWorkspace() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/unknown"); + expect(res.status).toBe(404); + }); + + it("PUT /workspaces/:id/title renames", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceTitle(id, title) { + return { ...sampleWorkspace, id, title }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Renamed" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.title).toBe("Renamed"); + }); + + it("PUT /workspaces/:id/default-cwd sets", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { ...sampleWorkspace, id, defaultCwd }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultCwd: "/new/cwd" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultCwd).toBe("/new/cwd"); + }); + + it("DELETE /workspaces/:id closes conversations", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async deleteWorkspace() { + return { closedCount: 3 }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { method: "DELETE" }); + expect(res.status).toBe(200); + const body = (await res.json()) as DeleteWorkspaceResponse; + expect(body.workspaceId).toBe("proj"); + expect(body.closedCount).toBe(3); + }); + + it("DELETE /workspaces/default returns 409", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/default", { method: "DELETE" }); + expect(res.status).toBe(409); + }); }); it("POST /chat threads workspaceId", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.workspaceId).toBe("proj"); + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.workspaceId).toBe("proj"); }); it("GET /conversations?workspaceId= filters", async () => { - const calls: Parameters[0][] = []; - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations(filter) { - calls.push(filter); - return []; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations?workspaceId=proj"); - expect(res.status).toBe(200); - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ workspaceId: "proj" }); + const calls: Parameters[0][] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations(filter) { + calls.push(filter); + return []; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations?workspaceId=proj"); + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ workspaceId: "proj" }); }); it("GET /conversations/:id/lsp uses effective cwd", async () => { - let effectiveCwdCalled = false; - let getCwdCalled = false; - let lspCwd: string | null = null; - const store: ConversationStore = { - ...createFakeConversationStore(), - async getEffectiveCwd(_conversationId) { - effectiveCwdCalled = true; - return "/effective"; - }, - async getCwd(_conversationId) { - getCwdCalled = true; - return "/explicit"; - }, - }; - const lsp: LspService = { - async status(cwd) { - lspCwd = cwd; - return []; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - expect(effectiveCwdCalled).toBe(true); - expect(getCwdCalled).toBe(true); // gated on persisted cwd first - expect(lspCwd).toBe("/effective"); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.cwd).toBe("/effective"); + let effectiveCwdCalled = false; + let getCwdCalled = false; + let lspCwd: string | null = null; + const store: ConversationStore = { + ...createFakeConversationStore(), + async getEffectiveCwd(_conversationId) { + effectiveCwdCalled = true; + return "/effective"; + }, + async getCwd(_conversationId) { + getCwdCalled = true; + return "/explicit"; + }, + }; + const lsp: LspService = { + async status(cwd) { + lspCwd = cwd; + return []; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + expect(effectiveCwdCalled).toBe(true); + expect(getCwdCalled).toBe(true); // gated on persisted cwd first + expect(lspCwd).toBe("/effective"); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.cwd).toBe("/effective"); }); describe("GET /system-prompt", () => { - it("returns stored template", async () => { - const service = createFakeSystemPromptService("custom template"); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt"); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe("custom template"); - expect(service.getTemplateCalls).toBe(1); - }); - - it("returns default when service unavailable", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt"); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe(DEFAULT_TEMPLATE); - }); + it("returns stored template", async () => { + const service = createFakeSystemPromptService("custom template"); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt"); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe("custom template"); + expect(service.getTemplateCalls).toBe(1); + }); + + it("returns default when service unavailable", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt"); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe(DEFAULT_TEMPLATE); + }); }); describe("PUT /system-prompt", () => { - it("sets template", async () => { - const service = createFakeSystemPromptService(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template: "new" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe("new"); - expect(service.setTemplateCalls).toEqual(["new"]); - }); - - it("missing template → 400", async () => { - const service = createFakeSystemPromptService(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(service.setTemplateCalls).toEqual([]); - }); - - it("service unavailable → 503", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template: "new" }), - }); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("System prompt service not available"); - }); + it("sets template", async () => { + const service = createFakeSystemPromptService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template: "new" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe("new"); + expect(service.setTemplateCalls).toEqual(["new"]); + }); + + it("missing template → 400", async () => { + const service = createFakeSystemPromptService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(service.setTemplateCalls).toEqual([]); + }); + + it("service unavailable → 503", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template: "new" }), + }); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("System prompt service not available"); + }); }); describe("GET /system-prompt/variables", () => { - it("returns catalog", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt/variables"); - expect(res.status).toBe(200); - const body = (await res.json()) as { variables: readonly SystemPromptVariable[] }; - expect(Array.isArray(body.variables)).toBe(true); - // Contains at least system:time, prompt:cwd, and a dynamic file:. - const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time"); - const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd"); - const fileEntry = body.variables.find((v) => v.type === "file"); - expect(hasSystemTime).toBe(true); - expect(hasPromptCwd).toBe(true); - expect(fileEntry).toBeDefined(); - expect(fileEntry?.dynamic).toBe(true); - }); + it("returns catalog", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt/variables"); + expect(res.status).toBe(200); + const body = (await res.json()) as { variables: readonly SystemPromptVariable[] }; + expect(Array.isArray(body.variables)).toBe(true); + // Contains at least system:time, prompt:cwd, and a dynamic file:. + const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time"); + const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd"); + const fileEntry = body.variables.find((v) => v.type === "file"); + expect(hasSystemTime).toBe(true); + expect(hasPromptCwd).toBe(true); + expect(fileEntry).toBeDefined(); + expect(fileEntry?.dynamic).toBe(true); + }); }); // ─── Computers (mirrors the cwd / workspace routes) ───────────────────────── const sampleComputer: Computer = { - alias: "myserver", - hostName: "10.0.0.5", - port: 22, - user: "deploy", - identityFile: "/home/user/.ssh/id_ed25519", - knownHost: true, + alias: "myserver", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, }; describe("GET /computers", () => { - it("returns [] when no ComputerService is wired (graceful degrade)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers"); - expect(res.status).toBe(200); - const body = (await res.json()) as { computers: readonly ComputerEntry[] }; - expect(body.computers).toEqual([]); - }); - - it("delegates to the ComputerService when wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), - logger: noopLogger, - }); - const res = await app.request("/computers"); - expect(res.status).toBe(200); - const body = (await res.json()) as { computers: readonly ComputerEntry[] }; - expect(body.computers).toHaveLength(1); - expect(body.computers[0]?.alias).toBe("myserver"); - expect(body.computers[0]?.usageCount).toBe(2); - }); + it("returns [] when no ComputerService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toEqual([]); + }); + + it("delegates to the ComputerService when wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toHaveLength(1); + expect(body.computers[0]?.alias).toBe("myserver"); + expect(body.computers[0]?.usageCount).toBe(2); + }); }); describe("GET /computers/:alias", () => { - it("returns the computer when the alias is configured", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver"); - expect(res.status).toBe(200); - const body = (await res.json()) as Computer; - expect(body.alias).toBe("myserver"); - expect(body.hostName).toBe("10.0.0.5"); - }); - - it("returns 404 when the alias is not in the config", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([]), - logger: noopLogger, - }); - const res = await app.request("/computers/unknown"); - expect(res.status).toBe(404); - }); - - it("returns 404 when no ComputerService is wired (no ssh)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver"); - expect(res.status).toBe(404); - }); + it("returns the computer when the alias is configured", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(200); + const body = (await res.json()) as Computer; + expect(body.alias).toBe("myserver"); + expect(body.hostName).toBe("10.0.0.5"); + }); + + it("returns 404 when the alias is not in the config", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([]), + logger: noopLogger, + }); + const res = await app.request("/computers/unknown"); + expect(res.status).toBe(404); + }); + + it("returns 404 when no ComputerService is wired (no ssh)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(404); + }); }); describe("GET /computers/:alias/status", () => { - it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver/status"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - alias: string; - state: string; - knownHost: boolean; - }; - expect(body.alias).toBe("myserver"); - expect(body.state).toBe("disconnected"); - expect(body.knownHost).toBe(false); - }); + it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + alias: string; + state: string; + knownHost: boolean; + }; + expect(body.alias).toBe("myserver"); + expect(body.state).toBe("disconnected"); + expect(body.knownHost).toBe(false); + }); }); describe("POST /computers/:alias/test", () => { - it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver/test", { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; - expect(body.alias).toBe("myserver"); - expect(body.ok).toBe(false); - expect(body.error).toBe("SSH not configured"); - }); + it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/test", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; + expect(body.alias).toBe("myserver"); + expect(body.ok).toBe(false); + expect(body.error).toBe("SSH not configured"); + }); }); describe("GET then PUT then GET /conversations/:id/computer", () => { - it("round-trips the value", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const get0 = await app.request("/conversations/conv1/computer"); - expect(get0.status).toBe(200); - const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; - expect(get0Body.conversationId).toBe("conv1"); - expect(get0Body.computerId).toBeNull(); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; - expect(putBody.conversationId).toBe("conv1"); - expect(putBody.computerId).toBe("myserver"); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; - expect(getBody.computerId).toBe("myserver"); - }); + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const get0 = await app.request("/conversations/conv1/computer"); + expect(get0.status).toBe(200); + const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; + expect(get0Body.conversationId).toBe("conv1"); + expect(get0Body.computerId).toBeNull(); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.computerId).toBe("myserver"); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; + expect(getBody.computerId).toBe("myserver"); + }); }); describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => { - it("PUT null clears a previously-set computer", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - - const clearRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: null }), - }); - expect(clearRes.status).toBe(200); - const clearBody = (await clearRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(clearBody.computerId).toBeNull(); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { computerId: string | null }; - expect(getBody.computerId).toBeNull(); - }); + it("PUT null clears a previously-set computer", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const clearRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(clearRes.status).toBe(200); + const clearBody = (await clearRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(clearBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); }); describe("DELETE /conversations/:id/computer", () => { - it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.computerId).toBeNull(); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { computerId: string | null }; - expect(getBody.computerId).toBeNull(); - }); - - it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(deleteBody.computerId).toBeNull(); - }); - - it("does NOT affect other conversations' computers (isolation)", async () => { - const computerStore = new Map([ - ["conv1", "myserver"], - ["conv2", "otherbox"], - ]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - new Map(), - computerStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - - const get1 = await app.request("/conversations/conv1/computer"); - expect(get1.status).toBe(200); - expect((await get1.json()).computerId).toBeNull(); - - const get2 = await app.request("/conversations/conv2/computer"); - expect(get2.status).toBe(200); - expect((await get2.json()).computerId).toBe("otherbox"); - }); + it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); + + it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.computerId).toBeNull(); + }); + + it("does NOT affect other conversations' computers (isolation)", async () => { + const computerStore = new Map([ + ["conv1", "myserver"], + ["conv2", "otherbox"], + ]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + new Map(), + computerStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1 = await app.request("/conversations/conv1/computer"); + expect(get1.status).toBe(200); + expect((await get1.json()).computerId).toBeNull(); + + const get2 = await app.request("/conversations/conv2/computer"); + expect(get2.status).toBe(200); + expect((await get2.json()).computerId).toBe("otherbox"); + }); }); describe("PUT /conversations/:id/computer validation", () => { - it("with missing computerId returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("computerId"); - }); - - it("with empty-string computerId returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "" }), - }); - expect(res.status).toBe(400); - }); + it("with missing computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("computerId"); + }); + + it("with empty-string computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "" }), + }); + expect(res.status).toBe(400); + }); }); describe("PUT /workspaces/:id/default-computer", () => { - const wsSample: Workspace = { - id: "proj", - title: "proj", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 2000, - }; - - it("sets the default computer", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...wsSample, id, defaultComputerId }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultComputerId).toBe("myserver"); - }); - - it("clears the default computer with null", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...wsSample, id, defaultComputerId }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: null }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultComputerId).toBeNull(); - }); + const wsSample: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("sets the default computer", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBe("myserver"); + }); + + it("clears the default computer with null", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBeNull(); + }); }); describe("POST /chat threads computerId", () => { - it("forwards computerId into the orchestrator input when present", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - computerId: "myserver", - }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.conversationId).toBe("conv1"); - expect(cap.received?.computerId).toBe("myserver"); - }); - - it("omits computerId when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.computerId).toBeUndefined(); - }); + it("forwards computerId into the orchestrator input when present", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + computerId: "myserver", + }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.computerId).toBe("myserver"); + }); + + it("omits computerId when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.computerId).toBeUndefined(); + }); }); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 2e81c46..9f61e4c 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,1433 +1,1433 @@ import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt"; import type { - CloseConversationResponse, - CompactPercentResponse, - CompactResponse, - ComputerListResponse, - ComputerResponse, - ComputerStatusResponse, - ConversationComputerResponse, - ConversationHistoryResponse, - ConversationListResponse, - ConversationMetricsResponse, - ConversationStatusResponse, - CwdResponse, - DeleteWorkspaceResponse, - LastMessageResponse, - LspServerInfo, - LspStatusResponse, - McpServerInfo, - McpStatusResponse, - ModelResponse, - ModelsResponse, - OpenConversationResponse, - QueueResponse, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetConversationComputerRequest, - SetSystemPromptTemplateRequest, - SetWorkspaceDefaultComputerRequest, - SystemPromptTemplateResponse, - SystemPromptVariablesResponse, - TestComputerResponse, - ThroughputResponse, - TitleResponse, - WarmResponse, - WorkspaceListResponse, - WorkspaceResponse, + CloseConversationResponse, + CompactPercentResponse, + CompactResponse, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, + ConversationHistoryResponse, + ConversationListResponse, + ConversationMetricsResponse, + ConversationStatusResponse, + CwdResponse, + DeleteWorkspaceResponse, + LastMessageResponse, + LspServerInfo, + LspStatusResponse, + McpServerInfo, + McpStatusResponse, + ModelResponse, + ModelsResponse, + OpenConversationResponse, + QueueResponse, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConversationComputerRequest, + SetSystemPromptTemplateRequest, + SetWorkspaceDefaultComputerRequest, + SystemPromptTemplateResponse, + SystemPromptVariablesResponse, + TestComputerResponse, + ThroughputResponse, + TitleResponse, + WarmResponse, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { - computeCachePct, - computeExpectedCacheRate, - extractLastAssistantText, - isModelParseError, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isWindowParamError, - parseChatBody, - parseModelBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseStatusFilter, - parseWarmBody, - parseWindowParam, - serializeEventLine, + computeCachePct, + computeExpectedCacheRate, + extractLastAssistantText, + isModelParseError, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isWindowParamError, + parseChatBody, + parseModelBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseStatusFilter, + parseWarmBody, + parseWindowParam, + serializeEventLine, } from "./logic.js"; import { - type CompactionService, - type ComputerService, - type ConversationStore, - type CredentialStore, - conversationOpened, - isValidWorkspaceSlug, - type LspServerStatus, - type LspService, - type McpServerStatus, - type McpService, - type SessionOrchestrator, - type SystemPromptService, - ThroughputQueryError, - type ThroughputStore, - type WarmService, + type CompactionService, + type ComputerService, + type ConversationStore, + type CredentialStore, + conversationOpened, + isValidWorkspaceSlug, + type LspServerStatus, + type LspService, + type McpServerStatus, + type McpService, + type SessionOrchestrator, + type SystemPromptService, + ThroughputQueryError, + type ThroughputStore, + type WarmService, } from "./seam.js"; export interface CreateServerOptions { - readonly conversationStore: ConversationStore; - readonly orchestrator: SessionOrchestrator; - readonly credentialStore: CredentialStore; - readonly warmService?: WarmService; - readonly compactionService?: CompactionService; - readonly lspService?: LspService; - readonly mcpService?: McpService; - /** Optional — system prompt builder service (GET/PUT template). */ - readonly systemPromptService?: SystemPromptService; - /** - * Optional — computer discovery + live connection service (provided by the - * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes - * degrade: list returns `[]`, status returns "disconnected", test returns - * a not-configured result. The per-conversation / workspace-default computer - * endpoints work regardless (they only touch the conversation store). - */ - readonly computerService?: ComputerService; - /** Optional — defaults to a no-op store (recording disabled, empty reports). */ - readonly throughputStore?: ThroughputStore; - readonly logger?: Logger; - readonly generateId?: () => string; - /** Injectable clock for sample timestamps (default Date.now). */ - readonly now?: () => number; - /** - * Fire-and-forget event-bus emit (bound `host.emit`). Required by - * `POST /conversations/:id/open` to signal the frontend. When absent, - * that endpoint responds `500 { error: "not available" }`. - */ - readonly emit?: HostAPI["emit"]; - /** - * Directory containing built frontend static files. When set, unmatched GET - * requests fall through to static file serving (SPA fallback to index.html). - * When absent, no static serving (API-only — backward compatible). - */ - readonly webDir?: string; + readonly conversationStore: ConversationStore; + readonly orchestrator: SessionOrchestrator; + readonly credentialStore: CredentialStore; + readonly warmService?: WarmService; + readonly compactionService?: CompactionService; + readonly lspService?: LspService; + readonly mcpService?: McpService; + /** Optional — system prompt builder service (GET/PUT template). */ + readonly systemPromptService?: SystemPromptService; + /** + * Optional — computer discovery + live connection service (provided by the + * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes + * degrade: list returns `[]`, status returns "disconnected", test returns + * a not-configured result. The per-conversation / workspace-default computer + * endpoints work regardless (they only touch the conversation store). + */ + readonly computerService?: ComputerService; + /** Optional — defaults to a no-op store (recording disabled, empty reports). */ + readonly throughputStore?: ThroughputStore; + readonly logger?: Logger; + readonly generateId?: () => string; + /** Injectable clock for sample timestamps (default Date.now). */ + readonly now?: () => number; + /** + * Fire-and-forget event-bus emit (bound `host.emit`). Required by + * `POST /conversations/:id/open` to signal the frontend. When absent, + * that endpoint responds `500 { error: "not available" }`. + */ + readonly emit?: HostAPI["emit"]; + /** + * Directory containing built frontend static files. When set, unmatched GET + * requests fall through to static file serving (SPA fallback to index.html). + * When absent, no static serving (API-only — backward compatible). + */ + readonly webDir?: string; } const noopLogger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return noopLogger; - }, - span() { - return { - id: "noop-span", - log: noopLogger, - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return noopLogger; + }, + span() { + return { + id: "noop-span", + log: noopLogger, + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, }; const noopThroughputStore: ThroughputStore = { - record: async () => {}, - aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }), + record: async () => {}, + aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }), }; export function createApp(opts: CreateServerOptions): Hono { - const app = new Hono(); - const log = opts.logger ?? noopLogger; - const generateId = opts.generateId ?? (() => crypto.randomUUID()); - const now = opts.now ?? (() => Date.now()); - const throughputStore = opts.throughputStore ?? noopThroughputStore; - - async function recordThroughput( - turnEvents: readonly AgentEvent[], - model: string | undefined, - ): Promise { - if (model === undefined) return; // no model selected → nothing to attribute - let genMs = 0; - let outputTokens = 0; - for (const e of turnEvents) { - if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs; - if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens; - } - if (genMs <= 0) return; // no generation time → can't compute tok/s - try { - await throughputStore.record({ model, ts: now(), outputTokens, genMs }); - log.info("throughput: turn recorded", { - model, - outputTokens, - genMs, - tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100, - }); - } catch (err) { - log.warn("throughput: failed to record sample", { - error: err instanceof Error ? err.message : String(err), - }); - } - } - - app.use( - "*", - cors({ - origin: "*", - allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowHeaders: ["Content-Type"], - }), - ); - - app.get("/health", (c) => c.json({ ok: true })); - - app.get("/conversations/:id/metrics", async (c) => { - const conversationId = c.req.param("id"); - - try { - const turns = await opts.conversationStore.loadMetrics(conversationId); - log.info("conversations: metrics read", { - conversationId, - count: turns.length, - }); - const body: ConversationMetricsResponse = { turns }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: metrics store failure", { err }); - return c.json({ error: "Failed to load conversation metrics" }, 500); - } - }); - - app.get("/conversations/:id", async (c) => { - const conversationId = c.req.param("id"); - const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); - if (isSinceSeqError(sinceSeqResult)) { - log.warn("conversations: invalid sinceSeq", { - conversationId, - error: sinceSeqResult.error, - }); - return c.json({ error: sinceSeqResult.error }, 400); - } - - // `limit` / `beforeSeq` are optional positive-integer history-window - // params. The store is deliberately forgiving (a 0/negative bound is - // treated as ABSENT), so we MUST reject malformed values here and never - // forward an invalid window. - const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq"); - if (isWindowParamError(beforeSeqResult)) { - log.warn("conversations: invalid beforeSeq", { - conversationId, - error: beforeSeqResult.error, - }); - return c.json({ error: beforeSeqResult.error }, 400); - } - const limitResult = parseWindowParam(c.req.query("limit"), "limit"); - if (isWindowParamError(limitResult)) { - log.warn("conversations: invalid limit", { - conversationId, - error: limitResult.error, - }); - return c.json({ error: limitResult.error }, 400); - } - - // Include only the fields actually provided (exactOptionalPropertyTypes), - // and omit the window argument entirely when neither was given — keeping - // the pre-windowing call shape byte-identical for existing callers. - const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined = - beforeSeqResult !== undefined || limitResult !== undefined - ? { - ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}), - ...(limitResult !== undefined ? { limit: limitResult } : {}), - } - : undefined; - - try { - const chunks = - window !== undefined - ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window) - : await opts.conversationStore.loadSince(conversationId, sinceSeqResult); - const latestSeq = - chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; - log.info("conversations: read", { - conversationId, - sinceSeq: sinceSeqResult, - count: chunks.length, - }); - const body: ConversationHistoryResponse = { chunks, latestSeq }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: store failure", { err }); - return c.json({ error: "Failed to load conversation" }, 500); - } - }); - - app.get("/conversations/:id/status", async (c) => { - const conversationId = c.req.param("id"); - const isActive = opts.orchestrator.isActive(conversationId); - const status = await opts.conversationStore.getConversationStatus(conversationId); - if (status === null) { - return c.json({ error: "Conversation not found" }, 404); - } - const body: ConversationStatusResponse = { conversationId, isActive, status }; - return c.json(body, 200); - }); - - app.get("/models", async (c) => { - try { - const models = await opts.credentialStore.listCatalog(); - const modelInfo: Record = {}; - for (const modelName of models) { - const info = await opts.credentialStore.getModelInfo(modelName); - if (info?.contextWindow !== undefined) { - modelInfo[modelName] = { contextWindow: info.contextWindow }; - } - } - const body: ModelsResponse = { - models, - ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}), - }; - return c.json(body, 200); - } catch (err) { - log.error("models: failed to retrieve catalog", { err }); - return c.json({ error: "Failed to retrieve model catalog" }, 502); - } - }); - - // ─── Computers (discovery + live state) ─────────────────────────────────── - // Read-only discovery + connection state is delegated to the ComputerService - // (provided by the `ssh` extension). When ssh is NOT loaded the routes - // degrade: list → empty, status → "disconnected", test → not-configured. - - app.get("/computers", async (c) => { - if (opts.computerService === undefined) { - // Graceful: no ssh configured → no computers discovered. - const body: ComputerListResponse = { computers: [] }; - return c.json(body, 200); - } - try { - const computers = await opts.computerService.listComputers(); - log.info("computers: list", { count: computers.length }); - const body: ComputerListResponse = { computers }; - return c.json(body, 200); - } catch (err) { - log.error("computers: list failure", { err }); - return c.json({ error: "Failed to list computers" }, 500); - } - }); - - app.get("/computers/:alias", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - // No ssh configured → no computer resolves this alias. - return c.json({ error: "Computer not found" }, 404); - } - try { - const computer = await opts.computerService.getComputer(alias); - if (computer === null) { - return c.json({ error: "Computer not found" }, 404); - } - const body: ComputerResponse = computer; - return c.json(body, 200); - } catch (err) { - log.error("computers: get failure", { err, alias }); - return c.json({ error: "Failed to read computer" }, 500); - } - }); - - app.get("/computers/:alias/status", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; - return c.json(body, 200); - } - try { - const body = await opts.computerService.getStatus(alias); - return c.json(body, 200); - } catch (err) { - log.error("computers: status failure", { err, alias }); - return c.json({ error: "Failed to read computer status" }, 500); - } - }); - - app.post("/computers/:alias/test", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; - return c.json(body, 200); - } - try { - const body = await opts.computerService.test(alias); - return c.json(body, 200); - } catch (err) { - log.error("computers: test failure", { err, alias }); - return c.json({ error: "Failed to test computer" }, 500); - } - }); - - app.post("/chat", async (c) => { - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("chat: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const result = parseChatBody(body, generateId); - if (isParseError(result)) { - log.warn("chat: validation failed", { reason: result.error }); - return c.json({ error: result.error }, 400); - } - - const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = - result; - log.info("chat: request accepted", { - conversationId, - hasModel: model !== undefined, - hasCwd: cwd !== undefined, - hasComputerId: computerId !== undefined, - hasReasoningEffort: reasoningEffort !== undefined, - hasWorkspaceId: workspaceId !== undefined, - }); - - const events: AgentEvent[] = []; - let controllerRef: ReadableStreamDefaultController | undefined; - let streamClosed = false; - - const stream = new ReadableStream({ - start(controller) { - controllerRef = controller; - }, - }); - - function safeEnqueue(data: Uint8Array): void { - if (streamClosed) return; - try { - controllerRef?.enqueue(data); - } catch (err) { - streamClosed = true; - log.warn("chat: stream enqueue failed", { - conversationId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - function safeClose(): void { - if (streamClosed) return; - streamClosed = true; - try { - controllerRef?.close(); - } catch (err) { - log.warn("chat: stream close failed", { - conversationId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - const orchestratorInput: Parameters[0] = { - conversationId, - text: message, - onEvent: (event) => { - events.push(event); - safeEnqueue(new TextEncoder().encode(serializeEventLine(event))); - }, - ...(model !== undefined ? { modelName: model } : {}), - ...(cwd !== undefined ? { cwd } : {}), - ...(computerId !== undefined ? { computerId } : {}), - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - }; - - opts.orchestrator - .handleMessage(orchestratorInput) - .then(async () => { - safeClose(); - await recordThroughput(events, model); - }) - .catch((err) => { - log.error("chat: turn failed", { err }); - const errorEvent: AgentEvent = { - type: "error", - conversationId, - turnId: "", - message: err instanceof Error ? err.message : String(err), - }; - safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); - safeClose(); - }); - - return new Response(stream, { - status: 200, - headers: { - "Content-Type": "application/x-ndjson", - "X-Conversation-Id": conversationId, - "Transfer-Encoding": "chunked", - }, - }); - }); - - app.post("/chat/warm", async (c) => { - if (opts.warmService === undefined) { - return c.json({ error: "Warm service not available" }, 503); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("chat/warm: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseWarmBody(body); - if ("error" in parsed) { - log.warn("chat/warm: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - const { conversationId, model, cwd } = parsed; - log.info("chat/warm: request accepted", { - conversationId, - hasModel: model !== undefined, - hasCwd: cwd !== undefined, - }); - - const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined = - model !== undefined || cwd !== undefined - ? { - ...(cwd !== undefined ? { cwd } : {}), - ...(model !== undefined ? { modelName: model } : {}), - } - : undefined; - - const result = await opts.warmService.warm(conversationId, warmOpts); - - if ("error" in result) { - log.warn("chat/warm: service returned error", { conversationId, error: result.error }); - return c.json({ error: result.error }, 409); - } - - const response: WarmResponse = { - inputTokens: result.inputTokens, - outputTokens: result.outputTokens, - cacheReadTokens: result.cacheReadTokens, - cacheWriteTokens: result.cacheWriteTokens, - cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens), - expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens), - }; - return c.json(response, 200); - }); - - app.get("/metrics/throughput", async (c) => { - const period = c.req.query("period"); - const date = c.req.query("date"); - if (period !== "day" && period !== "week" && period !== "month") { - return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400); - } - if (date === undefined || date === "") { - return c.json({ error: "query param 'date' is required" }, 400); - } - try { - // Typed against the wire contract: if the store's report shape ever - // drifts from ThroughputResponse, this assignment fails to compile. - const body: ThroughputResponse = await throughputStore.aggregate({ period, date }); - return c.json(body); - } catch (err) { - if (err instanceof ThroughputQueryError) { - return c.json({ error: err.message }, 400); - } - log.error("throughput: aggregate failed", { err }); - return c.json({ error: "Failed to aggregate throughput" }, 502); - } - }); - - app.post("/conversations/:id/close", (c) => { - const conversationId = c.req.param("id"); - const { abortedTurn } = opts.orchestrator.closeConversation(conversationId); - log.info("conversations: closed", { conversationId, abortedTurn }); - const body: CloseConversationResponse = { conversationId, abortedTurn }; - return c.json(body, 200); - }); - - app.post("/conversations/:id/stop", (c) => { - const conversationId = c.req.param("id"); - const { abortedTurn } = opts.orchestrator.stopTurn(conversationId); - log.info("conversations: stop", { conversationId, abortedTurn }); - return c.json({ conversationId, abortedTurn }, 200); - }); - - app.post("/conversations/:id/queue", async (c) => { - const conversationId = c.req.param("id"); - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/queue: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseQueueBody(body); - if (isParseError(parsed)) { - log.warn("conversations/queue: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - // `enqueue` is synchronous and owns the idle→startTurn vs active→queue - // decision (no separate `isActive` race) — it does not throw for an - // unknown/idle conversation, which instead starts a turn. Mirrors the - // direct sync call used by `POST /conversations/:id/close`. - const { startedTurn, queue } = opts.orchestrator.enqueue({ - conversationId, - text: parsed.text, - ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}), - }); - log.info("conversations: enqueued", { - conversationId, - startedTurn, - queueLength: queue.length, - }); - const response: QueueResponse = { conversationId, startedTurn, queue }; - return c.json(response, 200); - }); - - app.get("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - try { - const cwd = await opts.conversationStore.getCwd(conversationId); - log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null }); - const body: CwdResponse = { conversationId, cwd }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: cwd read failure", { err }); - return c.json({ error: "Failed to read conversation cwd" }, 500); - } - }); - - app.put("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/cwd: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record; - if (typeof obj.cwd !== "string" || obj.cwd.length === 0) { - return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); - } - - // When a workspaceId is provided, assign the conversation to that - // workspace BEFORE persisting the cwd — so a subsequent - // GET /conversations/:id/lsp resolves a relative cwd against the - // workspace's defaultCwd (not the server default). Omit for unchanged - // workspace assignment (backward compatible). - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { - return c.json({ error: "Invalid workspaceId" }, 400); - } - } - - try { - if (typeof obj.workspaceId === "string") { - await opts.conversationStore.ensureWorkspace(obj.workspaceId); - await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); - } - await opts.conversationStore.setCwd(conversationId, obj.cwd); - log.info("conversations: cwd set", { conversationId }); - const response: CwdResponse = { conversationId, cwd: obj.cwd }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: cwd set failure", { err }); - return c.json({ error: "Failed to set conversation cwd" }, 500); - } - }); - - app.delete("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - try { - await opts.conversationStore.clearCwd(conversationId); - log.info("conversations: cwd cleared", { conversationId }); - const response: CwdResponse = { conversationId, cwd: null }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: cwd clear failure", { err }); - return c.json({ error: "Failed to clear conversation cwd" }, 500); - } - }); - - // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── - - app.get("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - try { - const computerId = await opts.conversationStore.getComputerId(conversationId); - log.info("conversations: computer read", { - conversationId, - hasComputerId: computerId !== null, - }); - const body: ConversationComputerResponse = { conversationId, computerId }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: computer read failure", { err }); - return c.json({ error: "Failed to read conversation computer" }, 500); - } - }); - - app.put("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/computer: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record; - // `computerId` must be a string (the SSH alias) or null (clear → inherit - // the workspace defaultComputerId → local). An empty string is rejected - // (unlike cwd, an alias is never "empty"); null is the explicit clear. - if ( - obj.computerId !== null && - (typeof obj.computerId !== "string" || obj.computerId.length === 0) - ) { - return c.json( - { error: "Field 'computerId' is required and must be a non-empty string or null" }, - 400, - ); - } - const { computerId } = obj as unknown as SetConversationComputerRequest; - - // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, - // assign the conversation to that workspace BEFORE persisting the - // computer, so a subsequent effective-computer resolution reads the - // workspace's defaultComputerId. Omit for unchanged workspace assignment. - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { - return c.json({ error: "Invalid workspaceId" }, 400); - } - } - - try { - if (typeof obj.workspaceId === "string") { - await opts.conversationStore.ensureWorkspace(obj.workspaceId); - await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); - } - // null → clear (inherit/local); string → persist the alias. - await opts.conversationStore.setComputerId(conversationId, computerId); - log.info("conversations: computer set", { conversationId }); - const response: ConversationComputerResponse = { conversationId, computerId }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: computer set failure", { err }); - return c.json({ error: "Failed to set conversation computer" }, 500); - } - }); - - app.delete("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - try { - await opts.conversationStore.clearComputerId(conversationId); - log.info("conversations: computer cleared", { conversationId }); - const response: ConversationComputerResponse = { conversationId, computerId: null }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: computer clear failure", { err }); - return c.json({ error: "Failed to clear conversation computer" }, 500); - } - }); - - app.get("/conversations/:id/reasoning-effort", async (c) => { - const conversationId = c.req.param("id"); - try { - const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId); - log.info("conversations: reasoning-effort read", { - conversationId, - hasEffort: reasoningEffort !== null, - }); - const body: ReasoningEffortResponse = { conversationId, reasoningEffort }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: reasoning-effort read failure", { err }); - return c.json({ error: "Failed to read conversation reasoning effort" }, 500); - } - }); - - app.put("/conversations/:id/reasoning-effort", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/reasoning-effort: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseReasoningEffortBody(body); - if (isReasoningEffortParseError(parsed)) { - log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - try { - await opts.conversationStore.setReasoningEffort(conversationId, parsed); - log.info("conversations: reasoning-effort set", { conversationId }); - const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: reasoning-effort set failure", { err }); - return c.json({ error: "Failed to set conversation reasoning effort" }, 500); - } - }); - - app.get("/conversations/:id/model", async (c) => { - const conversationId = c.req.param("id"); - try { - const model = await opts.conversationStore.getModel(conversationId); - log.info("conversations: model read", { - conversationId, - hasModel: model !== null, - }); - const body: ModelResponse = { conversationId, model }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: model read failure", { err }); - return c.json({ error: "Failed to read conversation model" }, 500); - } - }); - - app.put("/conversations/:id/model", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/model: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseModelBody(body); - if (isModelParseError(parsed)) { - log.warn("conversations/model: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - // A non-null non-empty model persists the selection; `null` or an empty - // string clears the key (the store treats an empty string as "delete"). - // The response carries the resulting value: the model name, or null when - // cleared (mirroring how `getModel` returns null after a clear). - const resultModel = parsed !== null && parsed.length > 0 ? parsed : null; - const persistedValue = resultModel !== null ? resultModel : ""; - - try { - await opts.conversationStore.setModel(conversationId, persistedValue); - log.debug("conversations: model set", { conversationId, model: resultModel }); - const response: ModelResponse = { conversationId, model: resultModel }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: model set failure", { err }); - return c.json({ error: "Failed to set conversation model" }, 500); - } - }); - - app.get("/conversations/:id/lsp", async (c) => { - const conversationId = c.req.param("id"); - try { - // Gate on the PERSISTED cwd first: when no cwd has been set for the - // conversation, the LSP does NOT connect (return null + empty servers) - // rather than falling through to the server default (process.cwd()). - const persistedCwd = await opts.conversationStore.getCwd(conversationId); - if (persistedCwd === null) { - log.info("conversations: lsp status read (no cwd)", { conversationId }); - const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd - // resolved against the workspace defaultCwd; absolute → as-is). - const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); - if (effectiveCwd === null) { - // Edge case: persisted cwd exists but resolution returned null. - log.info("conversations: lsp status read (no effective cwd)", { conversationId }); - const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - if (opts.lspService === undefined) { - log.warn("conversations: lsp service not available", { conversationId }); - return c.json({ error: "LSP service not available" }, 503); - } - - const statuses = await opts.lspService.status(effectiveCwd); - const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { - const info: LspServerInfo = { - id: s.id, - name: s.name, - root: s.root, - extensions: s.extensions, - state: s.state, - ...(s.error !== undefined ? { error: s.error } : {}), - ...(s.configSource !== undefined ? { configSource: s.configSource } : {}), - }; - return info; - }); - log.info("conversations: lsp status read", { - conversationId, - cwd: effectiveCwd, - serverCount: servers.length, - }); - const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: lsp status failure", { err }); - return c.json({ error: "Failed to read LSP status" }, 500); - } - }); - - // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd, - // 503 when no MCP service, map McpServerStatus → McpServerInfo. - app.get("/conversations/:id/mcp", async (c) => { - const conversationId = c.req.param("id"); - try { - const persistedCwd = await opts.conversationStore.getCwd(conversationId); - if (persistedCwd === null) { - log.info("conversations: mcp status read (no cwd)", { conversationId }); - const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); - if (effectiveCwd === null) { - log.info("conversations: mcp status read (no effective cwd)", { conversationId }); - const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - if (opts.mcpService === undefined) { - log.warn("conversations: mcp service not available", { conversationId }); - return c.json({ error: "MCP service not available" }, 503); - } - - const statuses = await opts.mcpService.status(effectiveCwd); - const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => { - const info: McpServerInfo = { - id: s.id, - state: s.state, - toolCount: s.toolCount, - ...(s.error !== undefined ? { error: s.error } : {}), - }; - return info; - }); - log.info("conversations: mcp status read", { - conversationId, - cwd: effectiveCwd, - serverCount: servers.length, - }); - const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: mcp status failure", { err }); - return c.json({ error: "Failed to read MCP status" }, 500); - } - }); - - app.get("/conversations", async (c) => { - try { - // Optional `?status=` comma-separated filter (e.g. "active,idle"). - // Default: all statuses. Invalid values are silently ignored. - const rawStatus = c.req.query("status"); - const statusFilter = parseStatusFilter(rawStatus); - // Optional `?workspaceId=` filter. A missing/empty/whitespace-only - // value is ignored → return all workspaces. Composable with `?status=` - // and `?q=`. - const rawWorkspaceId = c.req.query("workspaceId"); - const workspaceId = - rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0 - ? rawWorkspaceId.trim() - : undefined; - const filter: Parameters[0] = - statusFilter !== undefined || workspaceId !== undefined - ? { - ...(statusFilter !== undefined ? { status: statusFilter } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - } - : undefined; - const all = await opts.conversationStore.listConversations(filter); - // Optional `?q=` filters by id prefix (short-id resolution). A - // missing/empty/whitespace-only `q` is ignored → return all. - const rawQ = c.req.query("q"); - const q = rawQ?.trim() ?? ""; - const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all; - log.info("conversations: list", { - count: conversations.length, - ...(q.length > 0 ? { q } : {}), - ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - }); - const body: ConversationListResponse = { conversations }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: list failure", { err }); - return c.json({ error: "Failed to list conversations" }, 500); - } - }); - - app.get("/conversations/:id/last", async (c) => { - const conversationId = c.req.param("id"); - - // Subscribe BEFORE checking isActive — closes the race where a seal - // fires between the check and the subscribe (we'd miss it). If idle, - // unsubscribe immediately; if active, wait for a `turn-sealed` event - // (or a 60s timeout, then proceed regardless of what's available). - let turnId: string | undefined; - let unsubscribe: (() => void) | undefined; - try { - await new Promise((resolve) => { - let settled = false; - let timer: ReturnType | undefined; - const finish = (): void => { - if (settled) return; - settled = true; - if (timer !== undefined) clearTimeout(timer); - resolve(); - }; - unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => { - if (event.type === "turn-sealed") { - turnId = event.turnId; - finish(); - } - }); - if (!opts.orchestrator.isActive(conversationId)) { - finish(); - return; - } - // A seal may have fired synchronously during subscribe (the - // real orchestrator never does this, but a fake might) — don't - // arm a 60s timer for an already-settled promise. - if (settled) return; - timer = setTimeout(finish, 60_000); - }); - } finally { - unsubscribe?.(); - } - - let content = ""; - try { - const messages = await opts.conversationStore.load(conversationId); - content = extractLastAssistantText(messages); - } catch (err) { - log.error("conversations: last message load failure", { err }); - return c.json({ error: "Failed to load conversation" }, 500); - } - - log.info("conversations: last read", { - conversationId, - hasContent: content.length > 0, - }); - const body: LastMessageResponse = { - conversationId, - content, - ...(turnId !== undefined ? { turnId } : {}), - }; - return c.json(body, 200); - }); - - app.post("/conversations/:id/open", async (c) => { - const conversationId = c.req.param("id"); - if (opts.emit === undefined) { - log.warn("conversations: open requested but emit is not available", { - conversationId, - }); - return c.json({ error: "not available" }, 500); - } - // Resolve the conversation's persisted workspace id so the frontend can - // open/focus the tab in the correct workspace. The store falls back to - // `"default"` when no workspaceId is persisted (or the conversation is - // unknown), so this never throws for a missing conversation. - const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId); - opts.emit(conversationOpened, { conversationId, workspaceId }); - log.info("conversations: opened", { conversationId, workspaceId }); - const body: OpenConversationResponse = { conversationId }; - return c.json(body, 200); - }); - - app.put("/conversations/:id/title", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/title: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record; - if (typeof obj.title !== "string" || obj.title.trim().length === 0) { - return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); - } - // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody` - // forward trimmed text), so a title never carries surrounding whitespace. - const title = obj.title.trim(); - - try { - await opts.conversationStore.setConversationTitle(conversationId, title); - log.info("conversations: title set", { conversationId }); - const response: TitleResponse = { conversationId, title }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: title set failure", { err }); - return c.json({ error: "Failed to set conversation title" }, 500); - } - }); - - // ─── Compaction ────────────────────────────────────────────────────────── - - app.post("/conversations/:id/compact", async (c) => { - if (opts.compactionService === undefined) { - return c.json({ error: "Compaction service not available" }, 503); - } - const conversationId = c.req.param("id"); - let body: unknown = {}; - try { - body = await c.req.json(); - } catch { - // No body is fine — use defaults. - } - const obj = body as Record; - const keepLastN = - typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0 - ? Math.floor(obj.keepLastN) - : undefined; - const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined; - - log.info("conversations: compact request", { conversationId }); - - const result = await opts.compactionService.compact(conversationId, { - ...(keepLastN !== undefined ? { keepLastN } : {}), - ...(modelName !== undefined ? { modelName } : {}), - }); - - if ("error" in result) { - log.warn("conversations: compact returned error", { - conversationId, - error: result.error, - }); - return c.json({ error: result.error }, 409); - } - - const response: CompactResponse = { - conversationId, - newConversationId: result.newConversationId, - messagesSummarized: result.messagesSummarized, - messagesKept: result.messagesKept, - }; - return c.json(response, 200); - }); - - app.get("/conversations/:id/compact-percent", async (c) => { - const conversationId = c.req.param("id"); - const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0; - const response: CompactPercentResponse = { conversationId, threshold }; - return c.json(response, 200); - }); - - app.put("/conversations/:id/compact-percent", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Invalid JSON body" }, 400); - } - const parsed = body as SetCompactPercentRequest; - if ( - typeof parsed.threshold !== "number" || - !Number.isFinite(parsed.threshold) || - parsed.threshold < 0 - ) { - return c.json({ error: "threshold must be a non-negative number" }, 400); - } - const threshold = Math.floor(parsed.threshold); - await opts.conversationStore.setCompactPercent(conversationId, threshold); - log.info("conversations: compact-percent set", { conversationId, threshold }); - const response: CompactPercentResponse = { conversationId, threshold }; - return c.json(response, 200); - }); - - // ─── Workspaces ────────────────────────────────────────────────────────── - - app.get("/workspaces", async (c) => { - try { - const workspaces = await opts.conversationStore.listWorkspaces(); - log.info("workspaces: list", { count: workspaces.length }); - const body: WorkspaceListResponse = { workspaces }; - return c.json(body, 200); - } catch (err) { - log.error("workspaces: list failure", { err }); - return c.json({ error: "Failed to list workspaces" }, 500); - } - }); - - app.put("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - if (!isValidWorkspaceSlug(workspaceId)) { - return c.json( - { - error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)", - }, - 400, - ); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record; - const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {}; - if (typeof obj.title === "string") { - (opts_ as { title?: string }).title = obj.title; - } - if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) { - (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd; - } - - try { - const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_); - log.info("workspaces: ensured", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: ensure failure", { err }); - return c.json({ error: "Failed to ensure workspace" }, 500); - } - }); - - app.get("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - try { - const workspace = await opts.conversationStore.getWorkspace(workspaceId); - if (workspace === null) { - return c.json({ error: "Workspace not found" }, 404); - } - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: get failure", { err }); - return c.json({ error: "Failed to read workspace" }, 500); - } - }); - - app.put("/workspaces/:id/title", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("workspaces/title: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record; - if (typeof obj.title !== "string" || obj.title.trim().length === 0) { - return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); - } - const title = obj.title.trim(); - - try { - const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title); - log.info("workspaces: title set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: title set failure", { err }); - return c.json({ error: "Failed to set workspace title" }, 500); - } - }); - - app.put("/workspaces/:id/default-cwd", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record; - const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null; - - try { - const workspace = await opts.conversationStore.setWorkspaceDefaultCwd( - workspaceId, - defaultCwd, - ); - log.info("workspaces: default-cwd set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: default-cwd set failure", { err }); - return c.json({ error: "Failed to set workspace default cwd" }, 500); - } - }); - - // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). - app.put("/workspaces/:id/default-computer", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record; - // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; - // anything else (null/absent/non-string) → clear (local). - const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = - typeof obj.computerId === "string" ? obj.computerId : null; - - try { - const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( - workspaceId, - defaultComputerId, - ); - log.info("workspaces: default-computer set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: default-computer set failure", { err }); - return c.json({ error: "Failed to set workspace default computer" }, 500); - } - }); - - app.delete("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - if (workspaceId === "default") { - return c.json({ error: 'The "default" workspace cannot be deleted' }, 409); - } - - try { - const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId); - log.info("workspaces: deleted", { workspaceId, closedCount }); - const response: DeleteWorkspaceResponse = { workspaceId, closedCount }; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: delete failure", { err }); - return c.json({ error: "Failed to delete workspace" }, 500); - } - }); - - // ─── System prompt template ─────────────────────────────────────────────── - - app.get("/system-prompt/variables", (c) => { - // Static catalog — no service call needed. Always available. - const variables = getVariableCatalog(); - const body: SystemPromptVariablesResponse = { variables }; - return c.json(body, 200); - }); - - app.get("/system-prompt", async (c) => { - if (opts.systemPromptService === undefined) { - // FE always gets something useful — the built-in default template. - const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE }; - return c.json(body, 200); - } - const template = await opts.systemPromptService.getTemplate(); - const body: SystemPromptTemplateResponse = { template }; - return c.json(body, 200); - }); - - app.put("/system-prompt", async (c) => { - if (opts.systemPromptService === undefined) { - return c.json({ error: "System prompt service not available" }, 503); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("system-prompt: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record; - // `template` must be a string; empty string is valid ("no system prompt"). - if (typeof obj.template !== "string") { - return c.json({ error: "Field 'template' is required and must be a string" }, 400); - } - - const { template } = obj as unknown as SetSystemPromptTemplateRequest; - await opts.systemPromptService.setTemplate(template); - log.info("system-prompt: template set"); - const response: SystemPromptTemplateResponse = { template }; - return c.json(response, 200); - }); - - // ─── Static frontend serving (catch-all, API routes take precedence) ────── - if (opts.webDir !== undefined) { - const webDir = opts.webDir; - const MIME: Record = { - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".txt": "text/plain; charset=utf-8", - ".wasm": "application/wasm", - }; - app.get("*", async (c) => { - const urlPath = new URL(c.req.url).pathname; - const filePath = `${webDir}${urlPath}`; - const file = Bun.file(filePath); - if (await file.exists()) { - const ext = filePath.slice(filePath.lastIndexOf(".")); - const contentType = MIME[ext] ?? "application/octet-stream"; - return new Response(file, { - headers: { "Content-Type": contentType }, - }); - } - // SPA fallback: serve index.html for client-side routing - const indexFile = Bun.file(`${webDir}/index.html`); - if (await indexFile.exists()) { - return new Response(indexFile, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - return c.json({ error: "Not found" }, 404); - }); - } - - return app; + const app = new Hono(); + const log = opts.logger ?? noopLogger; + const generateId = opts.generateId ?? (() => crypto.randomUUID()); + const now = opts.now ?? (() => Date.now()); + const throughputStore = opts.throughputStore ?? noopThroughputStore; + + async function recordThroughput( + turnEvents: readonly AgentEvent[], + model: string | undefined, + ): Promise { + if (model === undefined) return; // no model selected → nothing to attribute + let genMs = 0; + let outputTokens = 0; + for (const e of turnEvents) { + if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs; + if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens; + } + if (genMs <= 0) return; // no generation time → can't compute tok/s + try { + await throughputStore.record({ model, ts: now(), outputTokens, genMs }); + log.info("throughput: turn recorded", { + model, + outputTokens, + genMs, + tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100, + }); + } catch (err) { + log.warn("throughput: failed to record sample", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + app.use( + "*", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type"], + }), + ); + + app.get("/health", (c) => c.json({ ok: true })); + + app.get("/conversations/:id/metrics", async (c) => { + const conversationId = c.req.param("id"); + + try { + const turns = await opts.conversationStore.loadMetrics(conversationId); + log.info("conversations: metrics read", { + conversationId, + count: turns.length, + }); + const body: ConversationMetricsResponse = { turns }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: metrics store failure", { err }); + return c.json({ error: "Failed to load conversation metrics" }, 500); + } + }); + + app.get("/conversations/:id", async (c) => { + const conversationId = c.req.param("id"); + const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); + if (isSinceSeqError(sinceSeqResult)) { + log.warn("conversations: invalid sinceSeq", { + conversationId, + error: sinceSeqResult.error, + }); + return c.json({ error: sinceSeqResult.error }, 400); + } + + // `limit` / `beforeSeq` are optional positive-integer history-window + // params. The store is deliberately forgiving (a 0/negative bound is + // treated as ABSENT), so we MUST reject malformed values here and never + // forward an invalid window. + const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq"); + if (isWindowParamError(beforeSeqResult)) { + log.warn("conversations: invalid beforeSeq", { + conversationId, + error: beforeSeqResult.error, + }); + return c.json({ error: beforeSeqResult.error }, 400); + } + const limitResult = parseWindowParam(c.req.query("limit"), "limit"); + if (isWindowParamError(limitResult)) { + log.warn("conversations: invalid limit", { + conversationId, + error: limitResult.error, + }); + return c.json({ error: limitResult.error }, 400); + } + + // Include only the fields actually provided (exactOptionalPropertyTypes), + // and omit the window argument entirely when neither was given — keeping + // the pre-windowing call shape byte-identical for existing callers. + const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined = + beforeSeqResult !== undefined || limitResult !== undefined + ? { + ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}), + ...(limitResult !== undefined ? { limit: limitResult } : {}), + } + : undefined; + + try { + const chunks = + window !== undefined + ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window) + : await opts.conversationStore.loadSince(conversationId, sinceSeqResult); + const latestSeq = + chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; + log.info("conversations: read", { + conversationId, + sinceSeq: sinceSeqResult, + count: chunks.length, + }); + const body: ConversationHistoryResponse = { chunks, latestSeq }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: store failure", { err }); + return c.json({ error: "Failed to load conversation" }, 500); + } + }); + + app.get("/conversations/:id/status", async (c) => { + const conversationId = c.req.param("id"); + const isActive = opts.orchestrator.isActive(conversationId); + const status = await opts.conversationStore.getConversationStatus(conversationId); + if (status === null) { + return c.json({ error: "Conversation not found" }, 404); + } + const body: ConversationStatusResponse = { conversationId, isActive, status }; + return c.json(body, 200); + }); + + app.get("/models", async (c) => { + try { + const models = await opts.credentialStore.listCatalog(); + const modelInfo: Record = {}; + for (const modelName of models) { + const info = await opts.credentialStore.getModelInfo(modelName); + if (info?.contextWindow !== undefined) { + modelInfo[modelName] = { contextWindow: info.contextWindow }; + } + } + const body: ModelsResponse = { + models, + ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}), + }; + return c.json(body, 200); + } catch (err) { + log.error("models: failed to retrieve catalog", { err }); + return c.json({ error: "Failed to retrieve model catalog" }, 502); + } + }); + + // ─── Computers (discovery + live state) ─────────────────────────────────── + // Read-only discovery + connection state is delegated to the ComputerService + // (provided by the `ssh` extension). When ssh is NOT loaded the routes + // degrade: list → empty, status → "disconnected", test → not-configured. + + app.get("/computers", async (c) => { + if (opts.computerService === undefined) { + // Graceful: no ssh configured → no computers discovered. + const body: ComputerListResponse = { computers: [] }; + return c.json(body, 200); + } + try { + const computers = await opts.computerService.listComputers(); + log.info("computers: list", { count: computers.length }); + const body: ComputerListResponse = { computers }; + return c.json(body, 200); + } catch (err) { + log.error("computers: list failure", { err }); + return c.json({ error: "Failed to list computers" }, 500); + } + }); + + app.get("/computers/:alias", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + // No ssh configured → no computer resolves this alias. + return c.json({ error: "Computer not found" }, 404); + } + try { + const computer = await opts.computerService.getComputer(alias); + if (computer === null) { + return c.json({ error: "Computer not found" }, 404); + } + const body: ComputerResponse = computer; + return c.json(body, 200); + } catch (err) { + log.error("computers: get failure", { err, alias }); + return c.json({ error: "Failed to read computer" }, 500); + } + }); + + app.get("/computers/:alias/status", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.getStatus(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: status failure", { err, alias }); + return c.json({ error: "Failed to read computer status" }, 500); + } + }); + + app.post("/computers/:alias/test", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.test(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: test failure", { err, alias }); + return c.json({ error: "Failed to test computer" }, 500); + } + }); + + app.post("/chat", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("chat: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const result = parseChatBody(body, generateId); + if (isParseError(result)) { + log.warn("chat: validation failed", { reason: result.error }); + return c.json({ error: result.error }, 400); + } + + const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = + result; + log.info("chat: request accepted", { + conversationId, + hasModel: model !== undefined, + hasCwd: cwd !== undefined, + hasComputerId: computerId !== undefined, + hasReasoningEffort: reasoningEffort !== undefined, + hasWorkspaceId: workspaceId !== undefined, + }); + + const events: AgentEvent[] = []; + let controllerRef: ReadableStreamDefaultController | undefined; + let streamClosed = false; + + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + }, + }); + + function safeEnqueue(data: Uint8Array): void { + if (streamClosed) return; + try { + controllerRef?.enqueue(data); + } catch (err) { + streamClosed = true; + log.warn("chat: stream enqueue failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + function safeClose(): void { + if (streamClosed) return; + streamClosed = true; + try { + controllerRef?.close(); + } catch (err) { + log.warn("chat: stream close failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const orchestratorInput: Parameters[0] = { + conversationId, + text: message, + onEvent: (event) => { + events.push(event); + safeEnqueue(new TextEncoder().encode(serializeEventLine(event))); + }, + ...(model !== undefined ? { modelName: model } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }; + + opts.orchestrator + .handleMessage(orchestratorInput) + .then(async () => { + safeClose(); + await recordThroughput(events, model); + }) + .catch((err) => { + log.error("chat: turn failed", { err }); + const errorEvent: AgentEvent = { + type: "error", + conversationId, + turnId: "", + message: err instanceof Error ? err.message : String(err), + }; + safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); + safeClose(); + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "application/x-ndjson", + "X-Conversation-Id": conversationId, + "Transfer-Encoding": "chunked", + }, + }); + }); + + app.post("/chat/warm", async (c) => { + if (opts.warmService === undefined) { + return c.json({ error: "Warm service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("chat/warm: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseWarmBody(body); + if ("error" in parsed) { + log.warn("chat/warm: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + const { conversationId, model, cwd } = parsed; + log.info("chat/warm: request accepted", { + conversationId, + hasModel: model !== undefined, + hasCwd: cwd !== undefined, + }); + + const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined = + model !== undefined || cwd !== undefined + ? { + ...(cwd !== undefined ? { cwd } : {}), + ...(model !== undefined ? { modelName: model } : {}), + } + : undefined; + + const result = await opts.warmService.warm(conversationId, warmOpts); + + if ("error" in result) { + log.warn("chat/warm: service returned error", { conversationId, error: result.error }); + return c.json({ error: result.error }, 409); + } + + const response: WarmResponse = { + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + cacheReadTokens: result.cacheReadTokens, + cacheWriteTokens: result.cacheWriteTokens, + cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens), + expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens), + }; + return c.json(response, 200); + }); + + app.get("/metrics/throughput", async (c) => { + const period = c.req.query("period"); + const date = c.req.query("date"); + if (period !== "day" && period !== "week" && period !== "month") { + return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400); + } + if (date === undefined || date === "") { + return c.json({ error: "query param 'date' is required" }, 400); + } + try { + // Typed against the wire contract: if the store's report shape ever + // drifts from ThroughputResponse, this assignment fails to compile. + const body: ThroughputResponse = await throughputStore.aggregate({ period, date }); + return c.json(body); + } catch (err) { + if (err instanceof ThroughputQueryError) { + return c.json({ error: err.message }, 400); + } + log.error("throughput: aggregate failed", { err }); + return c.json({ error: "Failed to aggregate throughput" }, 502); + } + }); + + app.post("/conversations/:id/close", (c) => { + const conversationId = c.req.param("id"); + const { abortedTurn } = opts.orchestrator.closeConversation(conversationId); + log.info("conversations: closed", { conversationId, abortedTurn }); + const body: CloseConversationResponse = { conversationId, abortedTurn }; + return c.json(body, 200); + }); + + app.post("/conversations/:id/stop", (c) => { + const conversationId = c.req.param("id"); + const { abortedTurn } = opts.orchestrator.stopTurn(conversationId); + log.info("conversations: stop", { conversationId, abortedTurn }); + return c.json({ conversationId, abortedTurn }, 200); + }); + + app.post("/conversations/:id/queue", async (c) => { + const conversationId = c.req.param("id"); + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/queue: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseQueueBody(body); + if (isParseError(parsed)) { + log.warn("conversations/queue: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + // `enqueue` is synchronous and owns the idle→startTurn vs active→queue + // decision (no separate `isActive` race) — it does not throw for an + // unknown/idle conversation, which instead starts a turn. Mirrors the + // direct sync call used by `POST /conversations/:id/close`. + const { startedTurn, queue } = opts.orchestrator.enqueue({ + conversationId, + text: parsed.text, + ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}), + }); + log.info("conversations: enqueued", { + conversationId, + startedTurn, + queueLength: queue.length, + }); + const response: QueueResponse = { conversationId, startedTurn, queue }; + return c.json(response, 200); + }); + + app.get("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + try { + const cwd = await opts.conversationStore.getCwd(conversationId); + log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null }); + const body: CwdResponse = { conversationId, cwd }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: cwd read failure", { err }); + return c.json({ error: "Failed to read conversation cwd" }, 500); + } + }); + + app.put("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/cwd: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record; + if (typeof obj.cwd !== "string" || obj.cwd.length === 0) { + return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); + } + + // When a workspaceId is provided, assign the conversation to that + // workspace BEFORE persisting the cwd — so a subsequent + // GET /conversations/:id/lsp resolves a relative cwd against the + // workspace's defaultCwd (not the server default). Omit for unchanged + // workspace assignment (backward compatible). + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + await opts.conversationStore.setCwd(conversationId, obj.cwd); + log.info("conversations: cwd set", { conversationId }); + const response: CwdResponse = { conversationId, cwd: obj.cwd }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd set failure", { err }); + return c.json({ error: "Failed to set conversation cwd" }, 500); + } + }); + + app.delete("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearCwd(conversationId); + log.info("conversations: cwd cleared", { conversationId }); + const response: CwdResponse = { conversationId, cwd: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd clear failure", { err }); + return c.json({ error: "Failed to clear conversation cwd" }, 500); + } + }); + + // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── + + app.get("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + const computerId = await opts.conversationStore.getComputerId(conversationId); + log.info("conversations: computer read", { + conversationId, + hasComputerId: computerId !== null, + }); + const body: ConversationComputerResponse = { conversationId, computerId }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: computer read failure", { err }); + return c.json({ error: "Failed to read conversation computer" }, 500); + } + }); + + app.put("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/computer: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record; + // `computerId` must be a string (the SSH alias) or null (clear → inherit + // the workspace defaultComputerId → local). An empty string is rejected + // (unlike cwd, an alias is never "empty"); null is the explicit clear. + if ( + obj.computerId !== null && + (typeof obj.computerId !== "string" || obj.computerId.length === 0) + ) { + return c.json( + { error: "Field 'computerId' is required and must be a non-empty string or null" }, + 400, + ); + } + const { computerId } = obj as unknown as SetConversationComputerRequest; + + // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, + // assign the conversation to that workspace BEFORE persisting the + // computer, so a subsequent effective-computer resolution reads the + // workspace's defaultComputerId. Omit for unchanged workspace assignment. + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + // null → clear (inherit/local); string → persist the alias. + await opts.conversationStore.setComputerId(conversationId, computerId); + log.info("conversations: computer set", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer set failure", { err }); + return c.json({ error: "Failed to set conversation computer" }, 500); + } + }); + + app.delete("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearComputerId(conversationId); + log.info("conversations: computer cleared", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer clear failure", { err }); + return c.json({ error: "Failed to clear conversation computer" }, 500); + } + }); + + app.get("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + try { + const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId); + log.info("conversations: reasoning-effort read", { + conversationId, + hasEffort: reasoningEffort !== null, + }); + const body: ReasoningEffortResponse = { conversationId, reasoningEffort }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: reasoning-effort read failure", { err }); + return c.json({ error: "Failed to read conversation reasoning effort" }, 500); + } + }); + + app.put("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/reasoning-effort: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseReasoningEffortBody(body); + if (isReasoningEffortParseError(parsed)) { + log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + try { + await opts.conversationStore.setReasoningEffort(conversationId, parsed); + log.info("conversations: reasoning-effort set", { conversationId }); + const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: reasoning-effort set failure", { err }); + return c.json({ error: "Failed to set conversation reasoning effort" }, 500); + } + }); + + app.get("/conversations/:id/model", async (c) => { + const conversationId = c.req.param("id"); + try { + const model = await opts.conversationStore.getModel(conversationId); + log.info("conversations: model read", { + conversationId, + hasModel: model !== null, + }); + const body: ModelResponse = { conversationId, model }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: model read failure", { err }); + return c.json({ error: "Failed to read conversation model" }, 500); + } + }); + + app.put("/conversations/:id/model", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/model: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseModelBody(body); + if (isModelParseError(parsed)) { + log.warn("conversations/model: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + // A non-null non-empty model persists the selection; `null` or an empty + // string clears the key (the store treats an empty string as "delete"). + // The response carries the resulting value: the model name, or null when + // cleared (mirroring how `getModel` returns null after a clear). + const resultModel = parsed !== null && parsed.length > 0 ? parsed : null; + const persistedValue = resultModel !== null ? resultModel : ""; + + try { + await opts.conversationStore.setModel(conversationId, persistedValue); + log.debug("conversations: model set", { conversationId, model: resultModel }); + const response: ModelResponse = { conversationId, model: resultModel }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: model set failure", { err }); + return c.json({ error: "Failed to set conversation model" }, 500); + } + }); + + app.get("/conversations/:id/lsp", async (c) => { + const conversationId = c.req.param("id"); + try { + // Gate on the PERSISTED cwd first: when no cwd has been set for the + // conversation, the LSP does NOT connect (return null + empty servers) + // rather than falling through to the server default (process.cwd()). + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { + log.info("conversations: lsp status read (no cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd + // resolved against the workspace defaultCwd; absolute → as-is). + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + // Edge case: persisted cwd exists but resolution returned null. + log.info("conversations: lsp status read (no effective cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.lspService === undefined) { + log.warn("conversations: lsp service not available", { conversationId }); + return c.json({ error: "LSP service not available" }, 503); + } + + const statuses = await opts.lspService.status(effectiveCwd); + const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { + const info: LspServerInfo = { + id: s.id, + name: s.name, + root: s.root, + extensions: s.extensions, + state: s.state, + ...(s.error !== undefined ? { error: s.error } : {}), + ...(s.configSource !== undefined ? { configSource: s.configSource } : {}), + }; + return info; + }); + log.info("conversations: lsp status read", { + conversationId, + cwd: effectiveCwd, + serverCount: servers.length, + }); + const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: lsp status failure", { err }); + return c.json({ error: "Failed to read LSP status" }, 500); + } + }); + + // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd, + // 503 when no MCP service, map McpServerStatus → McpServerInfo. + app.get("/conversations/:id/mcp", async (c) => { + const conversationId = c.req.param("id"); + try { + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { + log.info("conversations: mcp status read (no cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + log.info("conversations: mcp status read (no effective cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.mcpService === undefined) { + log.warn("conversations: mcp service not available", { conversationId }); + return c.json({ error: "MCP service not available" }, 503); + } + + const statuses = await opts.mcpService.status(effectiveCwd); + const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => { + const info: McpServerInfo = { + id: s.id, + state: s.state, + toolCount: s.toolCount, + ...(s.error !== undefined ? { error: s.error } : {}), + }; + return info; + }); + log.info("conversations: mcp status read", { + conversationId, + cwd: effectiveCwd, + serverCount: servers.length, + }); + const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: mcp status failure", { err }); + return c.json({ error: "Failed to read MCP status" }, 500); + } + }); + + app.get("/conversations", async (c) => { + try { + // Optional `?status=` comma-separated filter (e.g. "active,idle"). + // Default: all statuses. Invalid values are silently ignored. + const rawStatus = c.req.query("status"); + const statusFilter = parseStatusFilter(rawStatus); + // Optional `?workspaceId=` filter. A missing/empty/whitespace-only + // value is ignored → return all workspaces. Composable with `?status=` + // and `?q=`. + const rawWorkspaceId = c.req.query("workspaceId"); + const workspaceId = + rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0 + ? rawWorkspaceId.trim() + : undefined; + const filter: Parameters[0] = + statusFilter !== undefined || workspaceId !== undefined + ? { + ...(statusFilter !== undefined ? { status: statusFilter } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + } + : undefined; + const all = await opts.conversationStore.listConversations(filter); + // Optional `?q=` filters by id prefix (short-id resolution). A + // missing/empty/whitespace-only `q` is ignored → return all. + const rawQ = c.req.query("q"); + const q = rawQ?.trim() ?? ""; + const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all; + log.info("conversations: list", { + count: conversations.length, + ...(q.length > 0 ? { q } : {}), + ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }); + const body: ConversationListResponse = { conversations }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: list failure", { err }); + return c.json({ error: "Failed to list conversations" }, 500); + } + }); + + app.get("/conversations/:id/last", async (c) => { + const conversationId = c.req.param("id"); + + // Subscribe BEFORE checking isActive — closes the race where a seal + // fires between the check and the subscribe (we'd miss it). If idle, + // unsubscribe immediately; if active, wait for a `turn-sealed` event + // (or a 60s timeout, then proceed regardless of what's available). + let turnId: string | undefined; + let unsubscribe: (() => void) | undefined; + try { + await new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(); + }; + unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => { + if (event.type === "turn-sealed") { + turnId = event.turnId; + finish(); + } + }); + if (!opts.orchestrator.isActive(conversationId)) { + finish(); + return; + } + // A seal may have fired synchronously during subscribe (the + // real orchestrator never does this, but a fake might) — don't + // arm a 60s timer for an already-settled promise. + if (settled) return; + timer = setTimeout(finish, 60_000); + }); + } finally { + unsubscribe?.(); + } + + let content = ""; + try { + const messages = await opts.conversationStore.load(conversationId); + content = extractLastAssistantText(messages); + } catch (err) { + log.error("conversations: last message load failure", { err }); + return c.json({ error: "Failed to load conversation" }, 500); + } + + log.info("conversations: last read", { + conversationId, + hasContent: content.length > 0, + }); + const body: LastMessageResponse = { + conversationId, + content, + ...(turnId !== undefined ? { turnId } : {}), + }; + return c.json(body, 200); + }); + + app.post("/conversations/:id/open", async (c) => { + const conversationId = c.req.param("id"); + if (opts.emit === undefined) { + log.warn("conversations: open requested but emit is not available", { + conversationId, + }); + return c.json({ error: "not available" }, 500); + } + // Resolve the conversation's persisted workspace id so the frontend can + // open/focus the tab in the correct workspace. The store falls back to + // `"default"` when no workspaceId is persisted (or the conversation is + // unknown), so this never throws for a missing conversation. + const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId); + opts.emit(conversationOpened, { conversationId, workspaceId }); + log.info("conversations: opened", { conversationId, workspaceId }); + const body: OpenConversationResponse = { conversationId }; + return c.json(body, 200); + }); + + app.put("/conversations/:id/title", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/title: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record; + if (typeof obj.title !== "string" || obj.title.trim().length === 0) { + return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); + } + // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody` + // forward trimmed text), so a title never carries surrounding whitespace. + const title = obj.title.trim(); + + try { + await opts.conversationStore.setConversationTitle(conversationId, title); + log.info("conversations: title set", { conversationId }); + const response: TitleResponse = { conversationId, title }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: title set failure", { err }); + return c.json({ error: "Failed to set conversation title" }, 500); + } + }); + + // ─── Compaction ────────────────────────────────────────────────────────── + + app.post("/conversations/:id/compact", async (c) => { + if (opts.compactionService === undefined) { + return c.json({ error: "Compaction service not available" }, 503); + } + const conversationId = c.req.param("id"); + let body: unknown = {}; + try { + body = await c.req.json(); + } catch { + // No body is fine — use defaults. + } + const obj = body as Record; + const keepLastN = + typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0 + ? Math.floor(obj.keepLastN) + : undefined; + const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined; + + log.info("conversations: compact request", { conversationId }); + + const result = await opts.compactionService.compact(conversationId, { + ...(keepLastN !== undefined ? { keepLastN } : {}), + ...(modelName !== undefined ? { modelName } : {}), + }); + + if ("error" in result) { + log.warn("conversations: compact returned error", { + conversationId, + error: result.error, + }); + return c.json({ error: result.error }, 409); + } + + const response: CompactResponse = { + conversationId, + newConversationId: result.newConversationId, + messagesSummarized: result.messagesSummarized, + messagesKept: result.messagesKept, + }; + return c.json(response, 200); + }); + + app.get("/conversations/:id/compact-percent", async (c) => { + const conversationId = c.req.param("id"); + const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0; + const response: CompactPercentResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + + app.put("/conversations/:id/compact-percent", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const parsed = body as SetCompactPercentRequest; + if ( + typeof parsed.threshold !== "number" || + !Number.isFinite(parsed.threshold) || + parsed.threshold < 0 + ) { + return c.json({ error: "threshold must be a non-negative number" }, 400); + } + const threshold = Math.floor(parsed.threshold); + await opts.conversationStore.setCompactPercent(conversationId, threshold); + log.info("conversations: compact-percent set", { conversationId, threshold }); + const response: CompactPercentResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + + // ─── Workspaces ────────────────────────────────────────────────────────── + + app.get("/workspaces", async (c) => { + try { + const workspaces = await opts.conversationStore.listWorkspaces(); + log.info("workspaces: list", { count: workspaces.length }); + const body: WorkspaceListResponse = { workspaces }; + return c.json(body, 200); + } catch (err) { + log.error("workspaces: list failure", { err }); + return c.json({ error: "Failed to list workspaces" }, 500); + } + }); + + app.put("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + if (!isValidWorkspaceSlug(workspaceId)) { + return c.json( + { + error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)", + }, + 400, + ); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record; + const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {}; + if (typeof obj.title === "string") { + (opts_ as { title?: string }).title = obj.title; + } + if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) { + (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd; + } + + try { + const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_); + log.info("workspaces: ensured", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: ensure failure", { err }); + return c.json({ error: "Failed to ensure workspace" }, 500); + } + }); + + app.get("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + try { + const workspace = await opts.conversationStore.getWorkspace(workspaceId); + if (workspace === null) { + return c.json({ error: "Workspace not found" }, 404); + } + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: get failure", { err }); + return c.json({ error: "Failed to read workspace" }, 500); + } + }); + + app.put("/workspaces/:id/title", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("workspaces/title: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record; + if (typeof obj.title !== "string" || obj.title.trim().length === 0) { + return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); + } + const title = obj.title.trim(); + + try { + const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title); + log.info("workspaces: title set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: title set failure", { err }); + return c.json({ error: "Failed to set workspace title" }, 500); + } + }); + + app.put("/workspaces/:id/default-cwd", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record; + const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultCwd( + workspaceId, + defaultCwd, + ); + log.info("workspaces: default-cwd set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-cwd set failure", { err }); + return c.json({ error: "Failed to set workspace default cwd" }, 500); + } + }); + + // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). + app.put("/workspaces/:id/default-computer", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record; + // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; + // anything else (null/absent/non-string) → clear (local). + const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = + typeof obj.computerId === "string" ? obj.computerId : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( + workspaceId, + defaultComputerId, + ); + log.info("workspaces: default-computer set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-computer set failure", { err }); + return c.json({ error: "Failed to set workspace default computer" }, 500); + } + }); + + app.delete("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + if (workspaceId === "default") { + return c.json({ error: 'The "default" workspace cannot be deleted' }, 409); + } + + try { + const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId); + log.info("workspaces: deleted", { workspaceId, closedCount }); + const response: DeleteWorkspaceResponse = { workspaceId, closedCount }; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: delete failure", { err }); + return c.json({ error: "Failed to delete workspace" }, 500); + } + }); + + // ─── System prompt template ─────────────────────────────────────────────── + + app.get("/system-prompt/variables", (c) => { + // Static catalog — no service call needed. Always available. + const variables = getVariableCatalog(); + const body: SystemPromptVariablesResponse = { variables }; + return c.json(body, 200); + }); + + app.get("/system-prompt", async (c) => { + if (opts.systemPromptService === undefined) { + // FE always gets something useful — the built-in default template. + const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE }; + return c.json(body, 200); + } + const template = await opts.systemPromptService.getTemplate(); + const body: SystemPromptTemplateResponse = { template }; + return c.json(body, 200); + }); + + app.put("/system-prompt", async (c) => { + if (opts.systemPromptService === undefined) { + return c.json({ error: "System prompt service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("system-prompt: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record; + // `template` must be a string; empty string is valid ("no system prompt"). + if (typeof obj.template !== "string") { + return c.json({ error: "Field 'template' is required and must be a string" }, 400); + } + + const { template } = obj as unknown as SetSystemPromptTemplateRequest; + await opts.systemPromptService.setTemplate(template); + log.info("system-prompt: template set"); + const response: SystemPromptTemplateResponse = { template }; + return c.json(response, 200); + }); + + // ─── Static frontend serving (catch-all, API routes take precedence) ────── + if (opts.webDir !== undefined) { + const webDir = opts.webDir; + const MIME: Record = { + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + }; + app.get("*", async (c) => { + const urlPath = new URL(c.req.url).pathname; + const filePath = `${webDir}${urlPath}`; + const file = Bun.file(filePath); + if (await file.exists()) { + const ext = filePath.slice(filePath.lastIndexOf(".")); + const contentType = MIME[ext] ?? "application/octet-stream"; + return new Response(file, { + headers: { "Content-Type": contentType }, + }); + } + // SPA fallback: serve index.html for client-side routing + const indexFile = Bun.file(`${webDir}/index.html`); + if (await indexFile.exists()) { + return new Response(indexFile, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + return c.json({ error: "Not found" }, 404); + }); + } + + return app; } diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 4ab43ce..de2a344 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,140 +1,140 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { - type ComputerService, - cacheWarmHandle, - compactionHandle, - computerServiceHandle, - conversationStoreHandle, - credentialStoreHandle, - lspServiceHandle, - mcpServiceHandle, - sessionOrchestratorHandle, - systemPromptHandle, - throughputStoreHandle, + type ComputerService, + cacheWarmHandle, + compactionHandle, + computerServiceHandle, + conversationStoreHandle, + credentialStoreHandle, + lspServiceHandle, + mcpServiceHandle, + sessionOrchestratorHandle, + systemPromptHandle, + throughputStoreHandle, } from "./seam.js"; export const manifest: Manifest = { - id: "transport-http", - name: "Transport HTTP", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - dependsOn: [ - "conversation-store", - "credential-store", - "lsp", - "mcp", - "session-orchestrator", - "throughput-store", - ], - capabilities: { network: true }, - contributes: { - routes: [ - "/chat", - "/chat/warm", - "/computers", - "/computers/:alias", - "/computers/:alias/status", - "/computers/:alias/test", - "/conversations", - "/conversations/:id", - "/conversations/:id/close", - "/conversations/:id/compact", - "/conversations/:id/compact-percent", - "/conversations/:id/computer", - "/conversations/:id/cwd", - "/conversations/:id/last", - "/conversations/:id/lsp", - "/conversations/:id/mcp", - "/conversations/:id/open", - "/conversations/:id/queue", - "/conversations/:id/reasoning-effort", - "/conversations/:id/status", - "/conversations/:id/stop", - "/conversations/:id/title", - "/health", - "/models", - "/metrics/throughput", - "/system-prompt", - "/system-prompt/variables", - "/workspaces", - "/workspaces/:id", - "/workspaces/:id/title", - "/workspaces/:id/default-cwd", - "/workspaces/:id/default-computer", - ], - }, - activation: "eager", + id: "transport-http", + name: "Transport HTTP", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: [ + "conversation-store", + "credential-store", + "lsp", + "mcp", + "session-orchestrator", + "throughput-store", + ], + capabilities: { network: true }, + contributes: { + routes: [ + "/chat", + "/chat/warm", + "/computers", + "/computers/:alias", + "/computers/:alias/status", + "/computers/:alias/test", + "/conversations", + "/conversations/:id", + "/conversations/:id/close", + "/conversations/:id/compact", + "/conversations/:id/compact-percent", + "/conversations/:id/computer", + "/conversations/:id/cwd", + "/conversations/:id/last", + "/conversations/:id/lsp", + "/conversations/:id/mcp", + "/conversations/:id/open", + "/conversations/:id/queue", + "/conversations/:id/reasoning-effort", + "/conversations/:id/status", + "/conversations/:id/stop", + "/conversations/:id/title", + "/health", + "/models", + "/metrics/throughput", + "/system-prompt", + "/system-prompt/variables", + "/workspaces", + "/workspaces/:id", + "/workspaces/:id/title", + "/workspaces/:id/default-cwd", + "/workspaces/:id/default-computer", + ], + }, + activation: "eager", }; export function createTransportHttpExtension(): Extension & { - readonly _testServer: ReturnType | undefined; + readonly _testServer: ReturnType | undefined; } { - let server: ReturnType | undefined; + let server: ReturnType | undefined; - return { - get _testServer() { - return server; - }, - manifest, - async activate(host: HostAPI) { - const conversationStore = host.getService(conversationStoreHandle); - const orchestrator = host.getService(sessionOrchestratorHandle); - const credentialStore = host.getService(credentialStoreHandle); - const throughputStore = host.getService(throughputStoreHandle); - const warmService = host.getService(cacheWarmHandle); - const compactionService = host.getService(compactionHandle); - const lspService = host.getService(lspServiceHandle); - const mcpService = host.getService(mcpServiceHandle); - const systemPromptService = host.getService(systemPromptHandle); - // Optional: the `ssh` extension provides ComputerService. It is NOT in - // dependsOn (ssh may be absent), so resolve defensively — when no - // provider registered the handle, the computer routes degrade to - // empty/disconnected (see app.ts). Wrapped because getService throws - // for an unregistered handle. - let computerService: ComputerService | undefined; - try { - computerService = host.getService(computerServiceHandle); - } catch { - computerService = undefined; - } - const logger = host.logger; + return { + get _testServer() { + return server; + }, + manifest, + async activate(host: HostAPI) { + const conversationStore = host.getService(conversationStoreHandle); + const orchestrator = host.getService(sessionOrchestratorHandle); + const credentialStore = host.getService(credentialStoreHandle); + const throughputStore = host.getService(throughputStoreHandle); + const warmService = host.getService(cacheWarmHandle); + const compactionService = host.getService(compactionHandle); + const lspService = host.getService(lspServiceHandle); + const mcpService = host.getService(mcpServiceHandle); + const systemPromptService = host.getService(systemPromptHandle); + // Optional: the `ssh` extension provides ComputerService. It is NOT in + // dependsOn (ssh may be absent), so resolve defensively — when no + // provider registered the handle, the computer routes degrade to + // empty/disconnected (see app.ts). Wrapped because getService throws + // for an unregistered handle. + let computerService: ComputerService | undefined; + try { + computerService = host.getService(computerServiceHandle); + } catch { + computerService = undefined; + } + const logger = host.logger; - const app = createApp({ - conversationStore, - orchestrator, - credentialStore, - throughputStore, - warmService, - compactionService, - lspService, - mcpService, - systemPromptService, - ...(computerService !== undefined ? { computerService } : {}), - logger, - emit: host.emit.bind(host), - ...(process.env.DISPATCH_WEB_DIR !== undefined - ? { webDir: process.env.DISPATCH_WEB_DIR } - : {}), - }); + const app = createApp({ + conversationStore, + orchestrator, + credentialStore, + throughputStore, + warmService, + compactionService, + lspService, + mcpService, + systemPromptService, + ...(computerService !== undefined ? { computerService } : {}), + logger, + emit: host.emit.bind(host), + ...(process.env.DISPATCH_WEB_DIR !== undefined + ? { webDir: process.env.DISPATCH_WEB_DIR } + : {}), + }); - const port = host.config.get("httpPort") ?? 24203; + const port = host.config.get("httpPort") ?? 24203; - server = Bun.serve({ - port, - fetch: app.fetch, - idleTimeout: 0, - }); + server = Bun.serve({ + port, + fetch: app.fetch, + idleTimeout: 0, + }); - logger.info("transport-http: listening", { port }); - }, + logger.info("transport-http: listening", { port }); + }, - deactivate() { - if (server) { - server.stop(); - server = undefined; - } - }, - }; + deactivate() { + if (server) { + server.stop(); + server = undefined; + } + }, + }; } diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index 192b00c..47c06bd 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -2,45 +2,45 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; export { createTransportHttpExtension, manifest } from "./extension.js"; export type { - ChatCommand, - ParseError, - ParseResult, - QueueBodyParsed, - SinceSeqResult, - WarmBodyParsed, - WindowParamResult, + ChatCommand, + ParseError, + ParseResult, + QueueBodyParsed, + SinceSeqResult, + WarmBodyParsed, + WindowParamResult, } from "./logic.js"; export { - computeCachePct, - extractLastAssistantText, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isValidReasoningEffort, - isWindowParamError, - parseChatBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseWindowParam, - serializeEventLine, + computeCachePct, + extractLastAssistantText, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isValidReasoningEffort, + isWindowParamError, + parseChatBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseWindowParam, + serializeEventLine, } from "./logic.js"; export type { - ComputerService, - ConversationStore, - CredentialStore, - LspService, - SessionOrchestrator, - SystemPromptService, - WarmService, + ComputerService, + ConversationStore, + CredentialStore, + LspService, + SessionOrchestrator, + SystemPromptService, + WarmService, } from "./seam.js"; export { - cacheWarmHandle, - computerServiceHandle, - conversationStoreHandle, - credentialStoreHandle, - isValidWorkspaceSlug, - lspServiceHandle, - sessionOrchestratorHandle, - systemPromptHandle, + cacheWarmHandle, + computerServiceHandle, + conversationStoreHandle, + credentialStoreHandle, + isValidWorkspaceSlug, + lspServiceHandle, + sessionOrchestratorHandle, + systemPromptHandle, } from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index 40a82fd..fc8302e 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -1,428 +1,428 @@ import type { AgentEvent } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { - computeExpectedCacheRate, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isValidReasoningEffort, - isWindowParamError, - parseChatBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseWindowParam, - serializeEventLine, + computeExpectedCacheRate, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isValidReasoningEffort, + isWindowParamError, + parseChatBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseWindowParam, + serializeEventLine, } from "./logic.js"; describe("parseChatBody", () => { - const fakeId = () => "test-uuid"; - - it("returns error for null body", () => { - const result = parseChatBody(null, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("JSON object"); - } - }); - - it("returns error for non-object body", () => { - const result = parseChatBody("hello", fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is missing", () => { - const result = parseChatBody({ conversationId: "c1" }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("message"); - } - }); - - it("returns error when message is empty string", () => { - const result = parseChatBody({ message: "" }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is whitespace only", () => { - const result = parseChatBody({ message: " " }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is not a string", () => { - const result = parseChatBody({ message: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("generates conversationId when absent", () => { - const result = parseChatBody({ message: "hello" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("test-uuid"); - expect(result.message).toBe("hello"); - } - }); - - it("generates conversationId when empty string", () => { - const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("test-uuid"); - } - }); - - it("uses provided conversationId", () => { - const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("my-conv"); - } - }); - - it("trims message whitespace", () => { - const result = parseChatBody({ message: " hello world " }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.message).toBe("hello world"); - } - }); - - it("extracts model when present", () => { - const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBe("opencode/m1"); - } - }); - - it("extracts cwd when present", () => { - const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.cwd).toBe("/tmp"); - } - }); - - it("extracts both model and cwd", () => { - const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBe("openai/gpt-4"); - expect(result.cwd).toBe("/home"); - } - }); - - it("omits model when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBeUndefined(); - } - }); - - it("omits cwd when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.cwd).toBeUndefined(); - } - }); - - it("returns error when model is not a string", () => { - const result = parseChatBody({ message: "hi", model: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("model"); - } - }); - - it("returns error when cwd is not a string", () => { - const result = parseChatBody({ message: "hi", cwd: true }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("cwd"); - } - }); - - it("extracts reasoningEffort when present and valid", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBe("low"); - } - }); - - it("accepts all valid reasoningEffort levels", () => { - for (const level of ["low", "medium", "high", "xhigh", "max"]) { - const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBe(level); - } - } - }); - - it("returns error for invalid reasoningEffort", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns error for non-string reasoningEffort", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("omits reasoningEffort when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBeUndefined(); - } - }); + const fakeId = () => "test-uuid"; + + it("returns error for null body", () => { + const result = parseChatBody(null, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseChatBody("hello", fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is missing", () => { + const result = parseChatBody({ conversationId: "c1" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("message"); + } + }); + + it("returns error when message is empty string", () => { + const result = parseChatBody({ message: "" }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is whitespace only", () => { + const result = parseChatBody({ message: " " }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is not a string", () => { + const result = parseChatBody({ message: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("generates conversationId when absent", () => { + const result = parseChatBody({ message: "hello" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + expect(result.message).toBe("hello"); + } + }); + + it("generates conversationId when empty string", () => { + const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + } + }); + + it("uses provided conversationId", () => { + const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("my-conv"); + } + }); + + it("trims message whitespace", () => { + const result = parseChatBody({ message: " hello world " }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.message).toBe("hello world"); + } + }); + + it("extracts model when present", () => { + const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("opencode/m1"); + } + }); + + it("extracts cwd when present", () => { + const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBe("/tmp"); + } + }); + + it("extracts both model and cwd", () => { + const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("openai/gpt-4"); + expect(result.cwd).toBe("/home"); + } + }); + + it("omits model when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBeUndefined(); + } + }); + + it("omits cwd when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBeUndefined(); + } + }); + + it("returns error when model is not a string", () => { + const result = parseChatBody({ message: "hi", model: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("model"); + } + }); + + it("returns error when cwd is not a string", () => { + const result = parseChatBody({ message: "hi", cwd: true }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("cwd"); + } + }); + + it("extracts reasoningEffort when present and valid", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe("low"); + } + }); + + it("accepts all valid reasoningEffort levels", () => { + for (const level of ["low", "medium", "high", "xhigh", "max"]) { + const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe(level); + } + } + }); + + it("returns error for invalid reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns error for non-string reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("omits reasoningEffort when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBeUndefined(); + } + }); }); describe("parseSinceSeq", () => { - it("returns 0 when undefined", () => { - expect(parseSinceSeq(undefined)).toBe(0); - }); - - it("returns 0 when empty string", () => { - expect(parseSinceSeq("")).toBe(0); - }); - - it("parses valid non-negative integer", () => { - expect(parseSinceSeq("0")).toBe(0); - expect(parseSinceSeq("5")).toBe(5); - expect(parseSinceSeq("42")).toBe(42); - }); - - it("returns ParseError for non-integer string", () => { - const result = parseSinceSeq("abc"); - expect(isSinceSeqError(result)).toBe(true); - if (isSinceSeqError(result)) { - expect(result.error).toContain("sinceSeq"); - } - }); - - it("returns ParseError for float", () => { - const result = parseSinceSeq("3.14"); - expect(isSinceSeqError(result)).toBe(true); - }); - - it("returns ParseError for negative integer", () => { - const result = parseSinceSeq("-1"); - expect(isSinceSeqError(result)).toBe(true); - }); + it("returns 0 when undefined", () => { + expect(parseSinceSeq(undefined)).toBe(0); + }); + + it("returns 0 when empty string", () => { + expect(parseSinceSeq("")).toBe(0); + }); + + it("parses valid non-negative integer", () => { + expect(parseSinceSeq("0")).toBe(0); + expect(parseSinceSeq("5")).toBe(5); + expect(parseSinceSeq("42")).toBe(42); + }); + + it("returns ParseError for non-integer string", () => { + const result = parseSinceSeq("abc"); + expect(isSinceSeqError(result)).toBe(true); + if (isSinceSeqError(result)) { + expect(result.error).toContain("sinceSeq"); + } + }); + + it("returns ParseError for float", () => { + const result = parseSinceSeq("3.14"); + expect(isSinceSeqError(result)).toBe(true); + }); + + it("returns ParseError for negative integer", () => { + const result = parseSinceSeq("-1"); + expect(isSinceSeqError(result)).toBe(true); + }); }); describe("parseWindowParam", () => { - it("returns undefined (absent) when undefined", () => { - expect(parseWindowParam(undefined, "limit")).toBeUndefined(); - }); - - it("returns undefined (absent) when empty string", () => { - expect(parseWindowParam("", "limit")).toBeUndefined(); - }); - - it("parses a valid positive integer", () => { - expect(parseWindowParam("1", "limit")).toBe(1); - expect(parseWindowParam("42", "beforeSeq")).toBe(42); - }); - - it("returns ParseError for zero (store would treat it as absent)", () => { - const result = parseWindowParam("0", "limit"); - expect(isWindowParamError(result)).toBe(true); - if (isWindowParamError(result)) { - expect(result.error).toContain("limit"); - expect(result.error).toContain("positive integer"); - } - }); - - it("returns ParseError for a negative integer", () => { - expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true); - }); - - it("returns ParseError for a non-integer", () => { - expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true); - }); - - it("returns ParseError for a non-numeric string", () => { - const result = parseWindowParam("abc", "beforeSeq"); - expect(isWindowParamError(result)).toBe(true); - if (isWindowParamError(result)) { - expect(result.error).toContain("beforeSeq"); - } - }); - - it("names the param in the error message", () => { - const limit = parseWindowParam("0", "limit"); - const before = parseWindowParam("0", "beforeSeq"); - if (isWindowParamError(limit)) expect(limit.error).toContain("limit"); - if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq"); - }); - - it("isWindowParamError is false for absent and for a valid number", () => { - expect(isWindowParamError(undefined)).toBe(false); - expect(isWindowParamError(5)).toBe(false); - }); + it("returns undefined (absent) when undefined", () => { + expect(parseWindowParam(undefined, "limit")).toBeUndefined(); + }); + + it("returns undefined (absent) when empty string", () => { + expect(parseWindowParam("", "limit")).toBeUndefined(); + }); + + it("parses a valid positive integer", () => { + expect(parseWindowParam("1", "limit")).toBe(1); + expect(parseWindowParam("42", "beforeSeq")).toBe(42); + }); + + it("returns ParseError for zero (store would treat it as absent)", () => { + const result = parseWindowParam("0", "limit"); + expect(isWindowParamError(result)).toBe(true); + if (isWindowParamError(result)) { + expect(result.error).toContain("limit"); + expect(result.error).toContain("positive integer"); + } + }); + + it("returns ParseError for a negative integer", () => { + expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true); + }); + + it("returns ParseError for a non-integer", () => { + expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true); + }); + + it("returns ParseError for a non-numeric string", () => { + const result = parseWindowParam("abc", "beforeSeq"); + expect(isWindowParamError(result)).toBe(true); + if (isWindowParamError(result)) { + expect(result.error).toContain("beforeSeq"); + } + }); + + it("names the param in the error message", () => { + const limit = parseWindowParam("0", "limit"); + const before = parseWindowParam("0", "beforeSeq"); + if (isWindowParamError(limit)) expect(limit.error).toContain("limit"); + if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq"); + }); + + it("isWindowParamError is false for absent and for a valid number", () => { + expect(isWindowParamError(undefined)).toBe(false); + expect(isWindowParamError(5)).toBe(false); + }); }); describe("serializeEventLine", () => { - it("serializes an event as JSON followed by newline", () => { - const event: AgentEvent = { - type: "text-delta", - conversationId: "tab1", - turnId: "turn1", - delta: "hello", - }; - const line = serializeEventLine(event); - expect(line).toBe(`${JSON.stringify(event)}\n`); - }); - - it("serializes a done event", () => { - const event: AgentEvent = { - type: "done", - conversationId: "tab1", - turnId: "turn1", - reason: "stop", - }; - const line = serializeEventLine(event); - const parsed = JSON.parse(line.trim()); - expect(parsed.type).toBe("done"); - expect(parsed.reason).toBe("stop"); - }); + it("serializes an event as JSON followed by newline", () => { + const event: AgentEvent = { + type: "text-delta", + conversationId: "tab1", + turnId: "turn1", + delta: "hello", + }; + const line = serializeEventLine(event); + expect(line).toBe(`${JSON.stringify(event)}\n`); + }); + + it("serializes a done event", () => { + const event: AgentEvent = { + type: "done", + conversationId: "tab1", + turnId: "turn1", + reason: "stop", + }; + const line = serializeEventLine(event); + const parsed = JSON.parse(line.trim()); + expect(parsed.type).toBe("done"); + expect(parsed.reason).toBe("stop"); + }); }); describe("computeExpectedCacheRate", () => { - it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => { - expect(computeExpectedCacheRate(800, 200)).toBe(80); - }); - - it("returns 0 when cacheRead+cacheWrite is 0", () => { - expect(computeExpectedCacheRate(0, 0)).toBe(0); - }); - - it("returns 100 when all tokens are cacheRead", () => { - expect(computeExpectedCacheRate(500, 0)).toBe(100); - }); - - it("returns 0 when all tokens are cacheWrite", () => { - expect(computeExpectedCacheRate(0, 500)).toBe(0); - }); - - it("rounds to nearest integer", () => { - expect(computeExpectedCacheRate(1, 2)).toBe(33); - expect(computeExpectedCacheRate(2, 1)).toBe(67); - }); + it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => { + expect(computeExpectedCacheRate(800, 200)).toBe(80); + }); + + it("returns 0 when cacheRead+cacheWrite is 0", () => { + expect(computeExpectedCacheRate(0, 0)).toBe(0); + }); + + it("returns 100 when all tokens are cacheRead", () => { + expect(computeExpectedCacheRate(500, 0)).toBe(100); + }); + + it("returns 0 when all tokens are cacheWrite", () => { + expect(computeExpectedCacheRate(0, 500)).toBe(0); + }); + + it("rounds to nearest integer", () => { + expect(computeExpectedCacheRate(1, 2)).toBe(33); + expect(computeExpectedCacheRate(2, 1)).toBe(67); + }); }); describe("isValidReasoningEffort", () => { - it("returns true for all valid levels", () => { - expect(isValidReasoningEffort("low")).toBe(true); - expect(isValidReasoningEffort("medium")).toBe(true); - expect(isValidReasoningEffort("high")).toBe(true); - expect(isValidReasoningEffort("xhigh")).toBe(true); - expect(isValidReasoningEffort("max")).toBe(true); - }); - - it("returns false for invalid strings", () => { - expect(isValidReasoningEffort("banana")).toBe(false); - expect(isValidReasoningEffort("")).toBe(false); - expect(isValidReasoningEffort("LOW")).toBe(false); - }); - - it("returns false for non-strings", () => { - expect(isValidReasoningEffort(42)).toBe(false); - expect(isValidReasoningEffort(null)).toBe(false); - expect(isValidReasoningEffort(undefined)).toBe(false); - expect(isValidReasoningEffort(true)).toBe(false); - }); + it("returns true for all valid levels", () => { + expect(isValidReasoningEffort("low")).toBe(true); + expect(isValidReasoningEffort("medium")).toBe(true); + expect(isValidReasoningEffort("high")).toBe(true); + expect(isValidReasoningEffort("xhigh")).toBe(true); + expect(isValidReasoningEffort("max")).toBe(true); + }); + + it("returns false for invalid strings", () => { + expect(isValidReasoningEffort("banana")).toBe(false); + expect(isValidReasoningEffort("")).toBe(false); + expect(isValidReasoningEffort("LOW")).toBe(false); + }); + + it("returns false for non-strings", () => { + expect(isValidReasoningEffort(42)).toBe(false); + expect(isValidReasoningEffort(null)).toBe(false); + expect(isValidReasoningEffort(undefined)).toBe(false); + expect(isValidReasoningEffort(true)).toBe(false); + }); }); describe("parseReasoningEffortBody", () => { - it("returns the level for a valid body", () => { - expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low"); - expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max"); - }); - - it("returns ParseError for missing reasoningEffort", () => { - const result = parseReasoningEffortBody({}); - expect(isReasoningEffortParseError(result)).toBe(true); - if (isReasoningEffortParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns ParseError for invalid level", () => { - const result = parseReasoningEffortBody({ reasoningEffort: "banana" }); - expect(isReasoningEffortParseError(result)).toBe(true); - if (isReasoningEffortParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns ParseError for non-object body", () => { - expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true); - expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); - }); + it("returns the level for a valid body", () => { + expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low"); + expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max"); + }); + + it("returns ParseError for missing reasoningEffort", () => { + const result = parseReasoningEffortBody({}); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for invalid level", () => { + const result = parseReasoningEffortBody({ reasoningEffort: "banana" }); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for non-object body", () => { + expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true); + expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); + }); }); describe("parseQueueBody", () => { - it("returns error for null body", () => { - const result = parseQueueBody(null); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("JSON object"); - } - }); - - it("returns error for non-object body", () => { - const result = parseQueueBody("hello"); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is missing", () => { - const result = parseQueueBody({}); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("text"); - } - }); - - it("returns error when text is empty string", () => { - const result = parseQueueBody({ text: "" }); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is whitespace only", () => { - const result = parseQueueBody({ text: " " }); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is not a string", () => { - const result = parseQueueBody({ text: 42 }); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("text"); - } - }); - - it("returns the trimmed text for a valid body", () => { - const result = parseQueueBody({ text: "hello" }); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.text).toBe("hello"); - } - }); - - it("trims text whitespace", () => { - const result = parseQueueBody({ text: " hello world " }); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.text).toBe("hello world"); - } - }); + it("returns error for null body", () => { + const result = parseQueueBody(null); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseQueueBody("hello"); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is missing", () => { + const result = parseQueueBody({}); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns error when text is empty string", () => { + const result = parseQueueBody({ text: "" }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is whitespace only", () => { + const result = parseQueueBody({ text: " " }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is not a string", () => { + const result = parseQueueBody({ text: 42 }); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns the trimmed text for a valid body", () => { + const result = parseQueueBody({ text: "hello" }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello"); + } + }); + + it("trims text whitespace", () => { + const result = parseQueueBody({ text: " hello world " }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello world"); + } + }); }); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 4e099c4..97ad426 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -1,16 +1,16 @@ import type { - AgentEvent, - ChatMessage, - ConversationStatus, - ReasoningEffort, + AgentEvent, + ChatMessage, + ConversationStatus, + ReasoningEffort, } from "@dispatch/kernel"; const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"]; @@ -22,43 +22,43 @@ const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed * `undefined` (no filter — shows all). */ export function parseStatusFilter( - raw: string | undefined, + raw: string | undefined, ): readonly ConversationStatus[] | undefined { - if (raw === undefined) return undefined; - const trimmed = raw.trim(); - if (trimmed.length === 0) return undefined; - const parts = trimmed - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const valid = parts.filter((p): p is ConversationStatus => - VALID_STATUSES.includes(p as ConversationStatus), - ); - return valid.length > 0 ? valid : undefined; + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const parts = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const valid = parts.filter((p): p is ConversationStatus => + VALID_STATUSES.includes(p as ConversationStatus), + ); + return valid.length > 0 ? valid : undefined; } export function isValidReasoningEffort(value: unknown): value is ReasoningEffort { - return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); + return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); } export interface ChatCommand { - readonly conversationId: string; - readonly message: string; - readonly model?: string; - readonly cwd?: string; - /** - * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded - * to the orchestrator verbatim and never part of the model prompt. When - * absent, the orchestrator resolves the per-conversation → workspace - * default → local chain. - */ - readonly computerId?: string; - readonly reasoningEffort?: ReasoningEffort; - readonly workspaceId?: string; + readonly conversationId: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + /** + * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded + * to the orchestrator verbatim and never part of the model prompt. When + * absent, the orchestrator resolves the per-conversation → workspace + * default → local chain. + */ + readonly computerId?: string; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; } export interface ParseError { - readonly error: string; + readonly error: string; } export type ParseResult = ChatCommand | ParseError; @@ -66,83 +66,83 @@ export type ParseResult = ChatCommand | ParseError; export type SinceSeqResult = number | ParseError; export function parseChatBody(body: unknown, generateId: () => string): ParseResult { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record; - - const message = obj.message; - if (typeof message !== "string" || message.trim().length === 0) { - return { error: "Field 'message' is required and must be a non-empty string" }; - } - - const conversationId = - typeof obj.conversationId === "string" && obj.conversationId.length > 0 - ? obj.conversationId - : generateId(); - - const result: ChatCommand = { conversationId, message: message.trim() }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - (result as { model?: string }).model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - (result as { cwd?: string }).cwd = obj.cwd; - } - - if (obj.computerId !== undefined) { - if (typeof obj.computerId !== "string") { - return { error: "Field 'computerId' must be a string" }; - } - (result as { computerId?: string }).computerId = obj.computerId; - } - - if (obj.reasoningEffort !== undefined) { - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; - } - - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - (result as { workspaceId?: string }).workspaceId = obj.workspaceId; - } - - return result; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + const message = obj.message; + if (typeof message !== "string" || message.trim().length === 0) { + return { error: "Field 'message' is required and must be a non-empty string" }; + } + + const conversationId = + typeof obj.conversationId === "string" && obj.conversationId.length > 0 + ? obj.conversationId + : generateId(); + + const result: ChatCommand = { conversationId, message: message.trim() }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + (result as { model?: string }).model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + (result as { cwd?: string }).cwd = obj.cwd; + } + + if (obj.computerId !== undefined) { + if (typeof obj.computerId !== "string") { + return { error: "Field 'computerId' must be a string" }; + } + (result as { computerId?: string }).computerId = obj.computerId; + } + + if (obj.reasoningEffort !== undefined) { + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; + } + + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + (result as { workspaceId?: string }).workspaceId = obj.workspaceId; + } + + return result; } export function isParseError(result: T | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } export function serializeEventLine(event: AgentEvent): string { - return `${JSON.stringify(event)}\n`; + return `${JSON.stringify(event)}\n`; } export function parseSinceSeq(raw: string | undefined): SinceSeqResult { - if (raw === undefined || raw === "") return 0; - const n = Number(raw); - if (!Number.isInteger(n) || n < 0) { - return { error: "sinceSeq must be a non-negative integer" }; - } - return n; + if (raw === undefined || raw === "") return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: "sinceSeq must be a non-negative integer" }; + } + return n; } export function isSinceSeqError(result: SinceSeqResult): result is ParseError { - return typeof result === "object"; + return typeof result === "object"; } /** @@ -160,67 +160,67 @@ export type WindowParamResult = number | undefined | ParseError; * Absent (`undefined` / empty) is the valid "no window" case → `undefined`. */ export function parseWindowParam(raw: string | undefined, name: string): WindowParamResult { - if (raw === undefined || raw === "") return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) { - return { error: `${name} must be a positive integer` }; - } - return n; + if (raw === undefined || raw === "") return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + return { error: `${name} must be a positive integer` }; + } + return n; } export function isWindowParamError(result: WindowParamResult): result is ParseError { - return typeof result === "object" && result !== null; + return typeof result === "object" && result !== null; } export interface WarmBodyParsed { - readonly conversationId: string; - readonly model?: string; - readonly cwd?: string; + readonly conversationId: string; + readonly model?: string; + readonly cwd?: string; } export function parseWarmBody(body: unknown): WarmBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record; - - const conversationId = obj.conversationId; - if (typeof conversationId !== "string" || conversationId.length === 0) { - return { error: "Field 'conversationId' is required and must be a non-empty string" }; - } - - const result: Record = { conversationId }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - result.model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - result.cwd = obj.cwd; - } - - return result as unknown as WarmBodyParsed; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + const conversationId = obj.conversationId; + if (typeof conversationId !== "string" || conversationId.length === 0) { + return { error: "Field 'conversationId' is required and must be a non-empty string" }; + } + + const result: Record = { conversationId }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + result.model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + result.cwd = obj.cwd; + } + + return result as unknown as WarmBodyParsed; } export function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); + if (inputTokens <= 0) return 0; + return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); } export function computeExpectedCacheRate( - cacheReadTokens: number, - cacheWriteTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, ): number { - const denom = cacheReadTokens + cacheWriteTokens; - if (denom <= 0) return 0; - return Math.round((cacheReadTokens / denom) * 100); + const denom = cacheReadTokens + cacheWriteTokens; + if (denom <= 0) return 0; + return Math.round((cacheReadTokens / denom) * 100); } /** @@ -229,8 +229,8 @@ export function computeExpectedCacheRate( * is deliberately NOT part of this parse result. */ export interface QueueBodyParsed { - readonly text: string; - readonly workspaceId?: string; + readonly text: string; + readonly workspaceId?: string; } /** @@ -241,46 +241,46 @@ export interface QueueBodyParsed { * `message`. */ export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } - const obj = body as Record; + const obj = body as Record; - const text = obj.text; - if (typeof text !== "string" || text.trim().length === 0) { - return { error: "Field 'text' is required and must be a non-empty string" }; - } + const text = obj.text; + if (typeof text !== "string" || text.trim().length === 0) { + return { error: "Field 'text' is required and must be a non-empty string" }; + } - const result: QueueBodyParsed = { text: text.trim() }; + const result: QueueBodyParsed = { text: text.trim() }; - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - return { text: text.trim(), workspaceId: obj.workspaceId }; - } + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + return { text: text.trim(), workspaceId: obj.workspaceId }; + } - return result; + return result; } export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record; - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - return obj.reasoningEffort; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record; + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + return obj.reasoningEffort; } export function isReasoningEffortParseError( - result: ReasoningEffort | ParseError, + result: ReasoningEffort | ParseError, ): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -293,21 +293,21 @@ export function isReasoningEffortParseError( * Returns the validated `model` value (`string | null`) on success. */ export function parseModelBody(body: unknown): string | null | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record; - if (obj.model === undefined) { - return { error: "Field 'model' is required and must be a string or null" }; - } - if (obj.model !== null && typeof obj.model !== "string") { - return { error: "Field 'model' must be a string or null" }; - } - return obj.model as string | null; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record; + if (obj.model === undefined) { + return { error: "Field 'model' is required and must be a string or null" }; + } + if (obj.model !== null && typeof obj.model !== "string") { + return { error: "Field 'model' must be a string or null" }; + } + return obj.model as string | null; } export function isModelParseError(result: string | null | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -322,20 +322,20 @@ export function isModelParseError(result: string | null | ParseError): result is * Pure (input → output); zero I/O, so it tests directly without mocks. */ export function extractLastAssistantText(messages: readonly ChatMessage[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg === undefined || msg.role !== "assistant") continue; - // Found the last assistant message — scan its chunks from the end for - // the last `text` chunk. Stop here (do not keep scanning earlier - // assistant messages): the contract is "the last assistant message's - // last text chunk", not "the most recent text chunk anywhere". - for (let j = msg.chunks.length - 1; j >= 0; j--) { - const chunk = msg.chunks[j]; - if (chunk !== undefined && chunk.type === "text") { - return chunk.text; - } - } - return ""; - } - return ""; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg === undefined || msg.role !== "assistant") continue; + // Found the last assistant message — scan its chunks from the end for + // the last `text` chunk. Stop here (do not keep scanning earlier + // assistant messages): the contract is "the last assistant message's + // last text chunk", not "the most recent text chunk anywhere". + for (let j = msg.chunks.length - 1; j >= 0; j--) { + const chunk = msg.chunks[j]; + if (chunk !== undefined && chunk.type === "text") { + return chunk.text; + } + } + return ""; + } + return ""; } diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index ef28a09..bd207a2 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -11,15 +11,15 @@ export { lspServiceHandle } from "@dispatch/lsp"; export type { McpServerStatus, McpService } from "@dispatch/mcp"; export { mcpServiceHandle } from "@dispatch/mcp"; export type { - CompactionService, - SessionOrchestrator, - WarmService, + CompactionService, + SessionOrchestrator, + WarmService, } from "@dispatch/session-orchestrator"; export { - cacheWarmHandle, - compactionHandle, - conversationOpened, - sessionOrchestratorHandle, + cacheWarmHandle, + compactionHandle, + conversationOpened, + sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; export type { SystemPromptService } from "@dispatch/system-prompt"; export { systemPromptHandle } from "@dispatch/system-prompt"; @@ -45,14 +45,14 @@ export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughpu * — an ABSENT service (ssh extension not loaded) is the graceful-degrade path. */ export interface ComputerService { - /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ - readonly listComputers: () => Promise; - /** One computer by alias, or `null` when the alias isn't in the config. */ - readonly getComputer: (alias: string) => Promise; - /** Live connection state for a computer alias. */ - readonly getStatus: (alias: string) => Promise; - /** One-shot connectivity probe (open, run a trivial command, close). */ - readonly test: (alias: string) => Promise; + /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ + readonly listComputers: () => Promise; + /** One computer by alias, or `null` when the alias isn't in the config. */ + readonly getComputer: (alias: string) => Promise; + /** Live connection state for a computer alias. */ + readonly getStatus: (alias: string) => Promise; + /** One-shot connectivity probe (open, run a trivial command, close). */ + readonly test: (alias: string) => Promise; } /** @@ -60,4 +60,4 @@ export interface ComputerService { * consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`. */ export const computerServiceHandle: ServiceHandle = - defineService("ssh"); + defineService("ssh"); diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index f552507..f93e259 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -3,305 +3,305 @@ import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { createTransportHttpExtension } from "./index.js"; import type { - ConversationStore, - CredentialStore, - LspService, - SessionOrchestrator, + ConversationStore, + CredentialStore, + LspService, + SessionOrchestrator, } from "./seam.js"; function fakeLogger(): Logger { - return { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return fakeLogger(); - }, - span() { - return { - id: "fake-span", - log: fakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return fakeLogger(); + }, + span() { + return { + id: "fake-span", + log: fakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function fakeConversationStore(): ConversationStore { - return { - async append() {}, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; - }, - async setWorkspaceTitle() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; - }, - async setWorkspaceDefaultCwd() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; + return { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle() { + return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd() { + return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; } function fakeOrchestrator(): SessionOrchestrator { - return { - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage() {}, - }; + return { + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage() {}, + }; } function fakeCredentialStore(): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - return []; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + return []; + }, + }; } function fakeLspService(): LspService { - return { - async status() { - return []; - }, - }; + return { + async status() { + return []; + }, + }; } function fakeConfig(overrides: Record = {}): ConfigAccess { - return { - get(key: string): T | undefined { - return overrides[key] as T | undefined; - }, - getAll() { - return overrides; - }, - }; + return { + get(key: string): T | undefined { + return overrides[key] as T | undefined; + }, + getAll() { + return overrides; + }, + }; } const SERVICES = new Map([ - ["conversation-store/store", fakeConversationStore()], - ["session-orchestrator/orchestrator", fakeOrchestrator()], - ["credential-store/registry", fakeCredentialStore()], - ["lsp", fakeLspService()], + ["conversation-store/store", fakeConversationStore()], + ["session-orchestrator/orchestrator", fakeOrchestrator()], + ["credential-store/registry", fakeCredentialStore()], + ["lsp", fakeLspService()], ]); function createFakeHostAPI(configOverrides: Record = {}): HostAPI { - return { - defineTool() {}, - defineProvider() {}, - defineAuth() {}, - on() { - return () => {}; - }, - emit() {}, - addFilter() { - return () => {}; - }, - async applyFilters(_hook, value) { - return value; - }, - provideService() {}, - getService(handle) { - return SERVICES.get(handle.id) as never; - }, - storage() { - return { - get: async () => null, - set: async () => {}, - delete: async () => {}, - has: async () => false, - keys: async () => [], - }; - }, - config: fakeConfig(configOverrides), - secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, - permissions: { check: async () => ({ allowed: true }) }, - events: { emit() {} }, - logger: fakeLogger(), - getProviders() { - return new Map(); - }, - getTools() { - return new Map(); - }, - getAuthProviders() { - return new Map(); - }, - getAuthProvider() { - return undefined; - }, - getExtensions() { - return []; - }, - scheduler: { register() {} }, - }; + return { + defineTool() {}, + defineProvider() {}, + defineAuth() {}, + on() { + return () => {}; + }, + emit() {}, + addFilter() { + return () => {}; + }, + async applyFilters(_hook, value) { + return value; + }, + provideService() {}, + getService(handle) { + return SERVICES.get(handle.id) as never; + }, + storage() { + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + has: async () => false, + keys: async () => [], + }; + }, + config: fakeConfig(configOverrides), + secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, + permissions: { check: async () => ({ allowed: true }) }, + events: { emit() {} }, + logger: fakeLogger(), + getProviders() { + return new Map(); + }, + getTools() { + return new Map(); + }, + getAuthProviders() { + return new Map(); + }, + getAuthProvider() { + return undefined; + }, + getExtensions() { + return []; + }, + scheduler: { register() {} }, + }; } // ── Server helper (mirrors extension.ts logic for direct testing) ──────── function startServer(port = 0) { - const app = createApp({ - conversationStore: fakeConversationStore(), - orchestrator: fakeOrchestrator(), - credentialStore: fakeCredentialStore(), - }); - return Bun.serve({ port, fetch: app.fetch }); + const app = createApp({ + conversationStore: fakeConversationStore(), + orchestrator: fakeOrchestrator(), + credentialStore: fakeCredentialStore(), + }); + return Bun.serve({ port, fetch: app.fetch }); } // ── Tests ──────────────────────────────────────────────────────────────── describe("serves HTTP on the configured port", () => { - let server: ReturnType; - let port: number; + let server: ReturnType; + let port: number; - afterEach(() => { - server.stop(); - }); + afterEach(() => { + server.stop(); + }); - test("GET /health returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /health returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/health`); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - }); + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); - test("POST /chat returns NDJSON", async () => { - server = startServer(); - port = server.port as number; + test("POST /chat returns NDJSON", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); - }); + const res = await fetch(`http://localhost:${port}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + }); - test("GET /models returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /models returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/models`); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual([]); - }); + const res = await fetch(`http://localhost:${port}/models`); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); - test("GET /conversations/:id returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /conversations/:id returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/conversations/conv1`); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; - expect(body.chunks).toEqual([]); - expect(body.latestSeq).toBe(0); - }); + const res = await fetch(`http://localhost:${port}/conversations/conv1`); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; + expect(body.chunks).toEqual([]); + expect(body.latestSeq).toBe(0); + }); }); describe("extension lifecycle", () => { - test("activate starts server on config port and deactivate stops it", async () => { - const ext = createTransportHttpExtension(); - const host = createFakeHostAPI({ httpPort: 0 }); - await ext.activate(host); - const server = ext._testServer; - expect(server).toBeDefined(); - const port = server?.port as number; - expect(port).toBeGreaterThan(0); + test("activate starts server on config port and deactivate stops it", async () => { + const ext = createTransportHttpExtension(); + const host = createFakeHostAPI({ httpPort: 0 }); + await ext.activate(host); + const server = ext._testServer; + expect(server).toBeDefined(); + const port = server?.port as number; + expect(port).toBeGreaterThan(0); - const res = await fetch(`http://localhost:${port}/health`); - expect(res.status).toBe(200); + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); - ext.deactivate?.(); + ext.deactivate?.(); - try { - await fetch(`http://localhost:${port}/health`); - expect(true).toBe(false); - } catch { - // expected — server stopped - } - }); + try { + await fetch(`http://localhost:${port}/health`); + expect(true).toBe(false); + } catch { + // expected — server stopped + } + }); }); diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json index 8dd2439..6ee0a8f 100644 --- a/packages/transport-http/tsconfig.json +++ b/packages/transport-http/tsconfig.json @@ -1,15 +1,15 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../conversation-store" }, - { "path": "../credential-store" }, - { "path": "../kernel" }, - { "path": "../lsp" }, - { "path": "../session-orchestrator" }, - { "path": "../system-prompt" }, - { "path": "../throughput-store" }, - { "path": "../transport-contract" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../conversation-store" }, + { "path": "../credential-store" }, + { "path": "../kernel" }, + { "path": "../lsp" }, + { "path": "../session-orchestrator" }, + { "path": "../system-prompt" }, + { "path": "../throughput-store" }, + { "path": "../transport-contract" } + ] } diff --git a/packages/transport-ws/package.json b/packages/transport-ws/package.json index b600a98..6593218 100644 --- a/packages/transport-ws/package.json +++ b/packages/transport-ws/package.json @@ -1,15 +1,15 @@ { - "name": "@dispatch/transport-ws", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/transport-contract": "workspace:*", - "@dispatch/ui-contract": "workspace:*" - } + "name": "@dispatch/transport-ws", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/transport-contract": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } } diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 56bd8e2..3811ed7 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -9,10 +9,10 @@ import type { Extension, HostAPI } from "@dispatch/kernel"; import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; import { - conversationCompacted, - conversationOpened, - conversationStatusChanged, - sessionOrchestratorHandle, + conversationCompacted, + conversationOpened, + conversationStatusChanged, + sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import { surfaceRegistryHandle } from "@dispatch/surface-registry"; @@ -22,379 +22,379 @@ import { catalogMessage, routeClientMessage, subKey } from "./router.js"; /** Active provider subscriptions + chat subscription disposers for a single WS connection. */ interface ConnectionState { - readonly subs: Set; - readonly providerDisposers: Map void>; - /** Per-conversation chat subscription disposers (orchestrator.subscribe). */ - readonly chatSubscriptions: Map void>; + readonly subs: Set; + readonly providerDisposers: Map void>; + /** Per-conversation chat subscription disposers (orchestrator.subscribe). */ + readonly chatSubscriptions: Map void>; } type Ws = Bun.ServerWebSocket; export function createTransportWsExtension(): Extension { - let server: ReturnType> | undefined; - /** Every currently-connected WS client — used for global fan-out broadcasts. */ - const connections = new Set(); - /** Disposers for host hook subscriptions (drained on deactivate). */ - const disposers: Array<() => void> = []; + let server: ReturnType> | undefined; + /** Every currently-connected WS client — used for global fan-out broadcasts. */ + const connections = new Set(); + /** Disposers for host hook subscriptions (drained on deactivate). */ + const disposers: Array<() => void> = []; - return { - manifest, - async activate(host: HostAPI) { - const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle); - const orchestrator: SessionOrchestrator = host.getService(sessionOrchestratorHandle); - const logger = host.logger; - const port = host.config.get("surfaceWsPort") ?? 24205; + return { + manifest, + async activate(host: HostAPI) { + const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle); + const orchestrator: SessionOrchestrator = host.getService(sessionOrchestratorHandle); + const logger = host.logger; + const port = host.config.get("surfaceWsPort") ?? 24205; - function send(ws: Ws, msg: WsServerMessage): void { - try { - ws.send(JSON.stringify(msg)); - } catch { - // Connection may have been dropped; swallow. - } - } + function send(ws: Ws, msg: WsServerMessage): void { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection may have been dropped; swallow. + } + } - /** Broadcast a message to EVERY connected WS client (global fan-out). */ - function broadcast(msg: WsServerMessage): void { - for (const ws of connections) { - send(ws, msg); - } - } + /** Broadcast a message to EVERY connected WS client (global fan-out). */ + function broadcast(msg: WsServerMessage): void { + for (const ws of connections) { + send(ws, msg); + } + } - /** - * Ensure this connection is subscribed to a conversation's chat events. - * Idempotent — no-op if already subscribed. The orchestrator replays - * buffered events to new subscribers (late-join), then streams live. - */ - function ensureChatSubscribed(ws: Ws, state: ConnectionState, conversationId: string): void { - if (state.chatSubscriptions.has(conversationId)) { - return; - } - const unsubscribe = orchestrator.subscribe(conversationId, (event) => { - send(ws, { type: "chat.delta", event }); - }); - state.chatSubscriptions.set(conversationId, unsubscribe); - } + /** + * Ensure this connection is subscribed to a conversation's chat events. + * Idempotent — no-op if already subscribed. The orchestrator replays + * buffered events to new subscribers (late-join), then streams live. + */ + function ensureChatSubscribed(ws: Ws, state: ConnectionState, conversationId: string): void { + if (state.chatSubscriptions.has(conversationId)) { + return; + } + const unsubscribe = orchestrator.subscribe(conversationId, (event) => { + send(ws, { type: "chat.delta", event }); + }); + state.chatSubscriptions.set(conversationId, unsubscribe); + } - function subscribeToProvider( - ws: Ws, - provider: SurfaceProvider, - surfaceId: string, - conversationId: string | undefined, - state: ConnectionState, - ): void { - const key = subKey(surfaceId, conversationId); - if (!provider.subscribe || state.providerDisposers.has(key)) { - return; - } - const context: SurfaceContext | undefined = - conversationId !== undefined ? { conversationId } : undefined; - const dispose = provider.subscribe(() => { - try { - const spec = provider.getSpec(context); - if (spec instanceof Promise) { - spec - .then((s) => - send(ws, { - type: "update", - update: { - surfaceId, - spec: s, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - }), - ) - .catch(() => {}); - } else { - send(ws, { - type: "update", - update: { - surfaceId, - spec, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - }); - } - } catch { - // Provider threw — log but don't kill the connection. - } - }); - state.providerDisposers.set(key, dispose); - } + function subscribeToProvider( + ws: Ws, + provider: SurfaceProvider, + surfaceId: string, + conversationId: string | undefined, + state: ConnectionState, + ): void { + const key = subKey(surfaceId, conversationId); + if (!provider.subscribe || state.providerDisposers.has(key)) { + return; + } + const context: SurfaceContext | undefined = + conversationId !== undefined ? { conversationId } : undefined; + const dispose = provider.subscribe(() => { + try { + const spec = provider.getSpec(context); + if (spec instanceof Promise) { + spec + .then((s) => + send(ws, { + type: "update", + update: { + surfaceId, + spec: s, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }), + ) + .catch(() => {}); + } else { + send(ws, { + type: "update", + update: { + surfaceId, + spec, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }); + } + } catch { + // Provider threw — log but don't kill the connection. + } + }); + state.providerDisposers.set(key, dispose); + } - function unsubscribeFromProvider(state: ConnectionState, key: string): void { - const dispose = state.providerDisposers.get(key); - if (dispose) { - dispose(); - state.providerDisposers.delete(key); - } - } + function unsubscribeFromProvider(state: ConnectionState, key: string): void { + const dispose = state.providerDisposers.get(key); + if (dispose) { + dispose(); + state.providerDisposers.delete(key); + } + } - // Broadcast a `conversation.open` WS message to ALL connected clients - // whenever the orchestrator signals a conversation was opened (e.g. the - // CLI `--open` flag). The frontend decides whether to open/focus a tab — - // the backend just signals. This is a GLOBAL fan-out (like the catalog), - // NOT a per-conversation chat broadcast. The payload's `workspaceId` - // is the conversation's actual persisted workspace (resolved by the - // orchestrator from the store), so a frontend opens/focuses the tab in - // the correct workspace. - disposers.push( - host.on(conversationOpened, ({ conversationId, workspaceId }) => { - broadcast({ type: "conversation.open", conversationId, workspaceId }); - }), - ); + // Broadcast a `conversation.open` WS message to ALL connected clients + // whenever the orchestrator signals a conversation was opened (e.g. the + // CLI `--open` flag). The frontend decides whether to open/focus a tab — + // the backend just signals. This is a GLOBAL fan-out (like the catalog), + // NOT a per-conversation chat broadcast. The payload's `workspaceId` + // is the conversation's actual persisted workspace (resolved by the + // orchestrator from the store), so a frontend opens/focuses the tab in + // the correct workspace. + disposers.push( + host.on(conversationOpened, ({ conversationId, workspaceId }) => { + broadcast({ type: "conversation.open", conversationId, workspaceId }); + }), + ); - // Broadcast `conversation.statusChanged` to all connected clients so - // tabs sync across devices in real time. `workspaceId` is the - // conversation's actual persisted workspace (resolved by the - // orchestrator from the store), forwarded so a frontend syncs the tab - // in the correct workspace. - disposers.push( - host.on(conversationStatusChanged, ({ conversationId, status, workspaceId }) => { - broadcast({ - type: "conversation.statusChanged", - conversationId, - status, - workspaceId, - }); - }), - ); + // Broadcast `conversation.statusChanged` to all connected clients so + // tabs sync across devices in real time. `workspaceId` is the + // conversation's actual persisted workspace (resolved by the + // orchestrator from the store), forwarded so a frontend syncs the tab + // in the correct workspace. + disposers.push( + host.on(conversationStatusChanged, ({ conversationId, status, workspaceId }) => { + broadcast({ + type: "conversation.statusChanged", + conversationId, + status, + workspaceId, + }); + }), + ); - // Broadcast `conversation.compacted` to all connected clients so - // the FE reloads the conversation history after compaction. - disposers.push( - host.on( - conversationCompacted, - ({ conversationId, newConversationId, messagesSummarized, messagesKept }) => { - broadcast({ - type: "conversation.compacted", - conversationId, - newConversationId, - messagesSummarized, - messagesKept, - }); - }, - ), - ); + // Broadcast `conversation.compacted` to all connected clients so + // the FE reloads the conversation history after compaction. + disposers.push( + host.on( + conversationCompacted, + ({ conversationId, newConversationId, messagesSummarized, messagesKept }) => { + broadcast({ + type: "conversation.compacted", + conversationId, + newConversationId, + messagesSummarized, + messagesKept, + }); + }, + ), + ); - server = Bun.serve({ - port, - fetch(req, srv) { - const initial: ConnectionState = { - subs: new Set(), - providerDisposers: new Map(), - chatSubscriptions: new Map(), - }; - if (srv.upgrade(req, { data: initial })) return; - return new Response("expected websocket", { status: 426 }); - }, - websocket: { - open(ws) { - connections.add(ws); - logger.debug("transport-ws: connection open"); - send(ws, catalogMessage(registry)); - }, + server = Bun.serve({ + port, + fetch(req, srv) { + const initial: ConnectionState = { + subs: new Set(), + providerDisposers: new Map(), + chatSubscriptions: new Map(), + }; + if (srv.upgrade(req, { data: initial })) return; + return new Response("expected websocket", { status: 426 }); + }, + websocket: { + open(ws) { + connections.add(ws); + logger.debug("transport-ws: connection open"); + send(ws, catalogMessage(registry)); + }, - message(ws, message) { - const state = ws.data; - if (!state) return; + message(ws, message) { + const state = ws.data; + if (!state) return; - let parsed: WsClientMessage; - try { - parsed = JSON.parse(String(message)) as WsClientMessage; - } catch { - send(ws, { type: "error", message: "Invalid JSON" }); - return; - } + let parsed: WsClientMessage; + try { + parsed = JSON.parse(String(message)) as WsClientMessage; + } catch { + send(ws, { type: "error", message: "Invalid JSON" }); + return; + } - const result = routeClientMessage(registry, state.subs, parsed); + const result = routeClientMessage(registry, state.subs, parsed); - switch (result.kind) { - case "surface": { - // Log surface-op errors (unknown surface or invoke failure). - for (const reply of result.replies) { - if (reply.type === "error") { - logger.warn?.("transport-ws: surface-op error", { - ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}), - reason: reply.message, - }); - } - } + switch (result.kind) { + case "surface": { + // Log surface-op errors (unknown surface or invoke failure). + for (const reply of result.replies) { + if (reply.type === "error") { + logger.warn?.("transport-ws: surface-op error", { + ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}), + reason: reply.message, + }); + } + } - // Apply sub change. - if (result.subChange) { - const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); - if (result.subChange.op === "add") { - state.subs.add(key); - const provider = registry.getSurface(result.subChange.surfaceId); - if (provider) { - subscribeToProvider( - ws, - provider, - result.subChange.surfaceId, - result.subChange.conversationId, - state, - ); - } - } else { - state.subs.delete(key); - unsubscribeFromProvider(state, key); - } - } + // Apply sub change. + if (result.subChange) { + const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); + if (result.subChange.op === "add") { + state.subs.add(key); + const provider = registry.getSurface(result.subChange.surfaceId); + if (provider) { + subscribeToProvider( + ws, + provider, + result.subChange.surfaceId, + result.subChange.conversationId, + state, + ); + } + } else { + state.subs.delete(key); + unsubscribeFromProvider(state, key); + } + } - // Send replies. - for (const reply of result.replies) { - send(ws, reply); - } + // Send replies. + for (const reply of result.replies) { + send(ws, reply); + } - // Perform invoke if signalled. - if (result.invoke) { - const provider = registry.getSurface(result.invoke.surfaceId); - if (provider) { - const context: SurfaceContext | undefined = - result.invoke.conversationId !== undefined - ? { conversationId: result.invoke.conversationId } - : undefined; - try { - const r = provider.invoke( - result.invoke.actionId, - result.invoke.payload, - context, - ); - if (r instanceof Promise) { - r.catch(() => {}); - } - } catch (err: unknown) { - const reason = err instanceof Error ? err.message : "invoke failed"; - logger.warn?.("transport-ws: surface-op error", { - surfaceId: result.invoke.surfaceId, - actionId: result.invoke.actionId, - reason, - }); - } - } - } - break; - } + // Perform invoke if signalled. + if (result.invoke) { + const provider = registry.getSurface(result.invoke.surfaceId); + if (provider) { + const context: SurfaceContext | undefined = + result.invoke.conversationId !== undefined + ? { conversationId: result.invoke.conversationId } + : undefined; + try { + const r = provider.invoke( + result.invoke.actionId, + result.invoke.payload, + context, + ); + if (r instanceof Promise) { + r.catch(() => {}); + } + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : "invoke failed"; + logger.warn?.("transport-ws: surface-op error", { + surfaceId: result.invoke.surfaceId, + actionId: result.invoke.actionId, + reason, + }); + } + } + } + break; + } - case "chat": { - const resolvedId = result.conversationId ?? crypto.randomUUID(); - // Auto-subscribe the sender so it sees the turn's events. - ensureChatSubscribed(ws, state, resolvedId); - // Start the turn detached from this connection. - const startResult = orchestrator.startTurn({ - conversationId: resolvedId, - text: result.message, - ...(result.model !== undefined ? { modelName: result.model } : {}), - ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), - ...(result.reasoningEffort !== undefined - ? { reasoningEffort: result.reasoningEffort } - : {}), - ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), - ...(result.computerId !== undefined ? { computerId: result.computerId } : {}), - }); - if (!startResult.started) { - send(ws, { - type: "chat.error", - conversationId: resolvedId, - message: "a turn is already generating for this conversation", - }); - } else { - logger.info?.("transport-ws: chat.send accepted", { - conversationId: resolvedId, - model: result.model ?? null, - }); - } - break; - } + case "chat": { + const resolvedId = result.conversationId ?? crypto.randomUUID(); + // Auto-subscribe the sender so it sees the turn's events. + ensureChatSubscribed(ws, state, resolvedId); + // Start the turn detached from this connection. + const startResult = orchestrator.startTurn({ + conversationId: resolvedId, + text: result.message, + ...(result.model !== undefined ? { modelName: result.model } : {}), + ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), + ...(result.reasoningEffort !== undefined + ? { reasoningEffort: result.reasoningEffort } + : {}), + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + ...(result.computerId !== undefined ? { computerId: result.computerId } : {}), + }); + if (!startResult.started) { + send(ws, { + type: "chat.error", + conversationId: resolvedId, + message: "a turn is already generating for this conversation", + }); + } else { + logger.info?.("transport-ws: chat.send accepted", { + conversationId: resolvedId, + model: result.model ?? null, + }); + } + break; + } - case "chat-subscribe": { - ensureChatSubscribed(ws, state, result.conversationId); - break; - } + case "chat-subscribe": { + ensureChatSubscribed(ws, state, result.conversationId); + break; + } - case "chat-unsubscribe": { - const dispose = state.chatSubscriptions.get(result.conversationId); - if (dispose) { - dispose(); - state.chatSubscriptions.delete(result.conversationId); - } - break; - } + case "chat-unsubscribe": { + const dispose = state.chatSubscriptions.get(result.conversationId); + if (dispose) { + dispose(); + state.chatSubscriptions.delete(result.conversationId); + } + break; + } - case "chat-queue": { - // Fire-and-forget: success is confirmed by the message-queue - // SURFACE updating (startedTurn:false) or by streaming - // chat.deltas (startedTurn:true), NOT by a reply here. On - // startedTurn:true the sender is auto-subscribed so the new - // turn's events stream to it (same as chat.send); on - // startedTurn:false (queued for steering) we emit NOTHING - // back and do not auto-subscribe. - const enqueueResult = orchestrator.enqueue({ - conversationId: result.conversationId, - text: result.text, - ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), - }); - if (enqueueResult.startedTurn) { - ensureChatSubscribed(ws, state, result.conversationId); - } - logger.info?.("transport-ws: chat.queue accepted", { - conversationId: result.conversationId, - startedTurn: enqueueResult.startedTurn, - }); - break; - } + case "chat-queue": { + // Fire-and-forget: success is confirmed by the message-queue + // SURFACE updating (startedTurn:false) or by streaming + // chat.deltas (startedTurn:true), NOT by a reply here. On + // startedTurn:true the sender is auto-subscribed so the new + // turn's events stream to it (same as chat.send); on + // startedTurn:false (queued for steering) we emit NOTHING + // back and do not auto-subscribe. + const enqueueResult = orchestrator.enqueue({ + conversationId: result.conversationId, + text: result.text, + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + }); + if (enqueueResult.startedTurn) { + ensureChatSubscribed(ws, state, result.conversationId); + } + logger.info?.("transport-ws: chat.queue accepted", { + conversationId: result.conversationId, + startedTurn: enqueueResult.startedTurn, + }); + break; + } - case "chat-error": { - logger.warn?.("transport-ws: malformed chat.send", { - reason: result.errorMessage, - ...(result.conversationId !== undefined - ? { conversationId: result.conversationId } - : {}), - }); - send(ws, { - type: "chat.error", - ...(result.conversationId !== undefined - ? { conversationId: result.conversationId } - : {}), - message: result.errorMessage, - }); - break; - } - } - }, + case "chat-error": { + logger.warn?.("transport-ws: malformed chat.send", { + reason: result.errorMessage, + ...(result.conversationId !== undefined + ? { conversationId: result.conversationId } + : {}), + }); + send(ws, { + type: "chat.error", + ...(result.conversationId !== undefined + ? { conversationId: result.conversationId } + : {}), + message: result.errorMessage, + }); + break; + } + } + }, - close(ws) { - connections.delete(ws); - const state = ws.data; - if (state) { - // Dispose all chat subscriptions (does NOT abort turns). - for (const dispose of state.chatSubscriptions.values()) { - dispose(); - } - state.chatSubscriptions.clear(); - // Dispose surface provider subscriptions. - for (const dispose of state.providerDisposers.values()) { - dispose(); - } - } - logger.debug("transport-ws: connection close"); - }, - }, - }); + close(ws) { + connections.delete(ws); + const state = ws.data; + if (state) { + // Dispose all chat subscriptions (does NOT abort turns). + for (const dispose of state.chatSubscriptions.values()) { + dispose(); + } + state.chatSubscriptions.clear(); + // Dispose surface provider subscriptions. + for (const dispose of state.providerDisposers.values()) { + dispose(); + } + } + logger.debug("transport-ws: connection close"); + }, + }, + }); - logger.info?.("transport-ws: surface WebSocket listening", { port }); - }, + logger.info?.("transport-ws: surface WebSocket listening", { port }); + }, - deactivate() { - for (const dispose of disposers) { - dispose(); - } - disposers.length = 0; - connections.clear(); - if (server) { - server.stop(); - server = undefined; - } - }, - }; + deactivate() { + for (const dispose of disposers) { + dispose(); + } + disposers.length = 0; + connections.clear(); + if (server) { + server.stop(); + server = undefined; + } + }, + }; } diff --git a/packages/transport-ws/src/index.ts b/packages/transport-ws/src/index.ts index e0cc66b..5a7c3f9 100644 --- a/packages/transport-ws/src/index.ts +++ b/packages/transport-ws/src/index.ts @@ -1,12 +1,12 @@ export { createTransportWsExtension } from "./extension.js"; export { manifest } from "./manifest.js"; export type { - ChatQueueRouteResult, - ChatRouteError, - ChatRouteResult, - ChatSubscribeRouteResult, - ChatUnsubscribeRouteResult, - RouteResult, - SurfaceRouteResult, + ChatQueueRouteResult, + ChatRouteError, + ChatRouteResult, + ChatSubscribeRouteResult, + ChatUnsubscribeRouteResult, + RouteResult, + SurfaceRouteResult, } from "./router.js"; export { catalogMessage, routeClientMessage, subKey } from "./router.js"; diff --git a/packages/transport-ws/src/manifest.ts b/packages/transport-ws/src/manifest.ts index 5058311..eef6f45 100644 --- a/packages/transport-ws/src/manifest.ts +++ b/packages/transport-ws/src/manifest.ts @@ -1,13 +1,13 @@ import type { Manifest } from "@dispatch/kernel"; export const manifest: Manifest = { - id: "transport-ws", - name: "Transport WebSocket", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - dependsOn: ["surface-registry", "session-orchestrator"], - capabilities: { network: true }, - contributes: { routes: ["/ws/surfaces"] }, - activation: "eager", + id: "transport-ws", + name: "Transport WebSocket", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: ["surface-registry", "session-orchestrator"], + capabilities: { network: true }, + contributes: { routes: ["/ws/surfaces"] }, + activation: "eager", }; diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index 6d01823..3c3e70b 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -7,677 +7,677 @@ import { catalogMessage, type RouteResult, routeClientMessage, subKey } from "./ // ── Fake in-memory registry (no mocks — just a plain implementation) ──────── interface FakeProviderOpts { - readonly id: string; - readonly title?: string; - readonly actions?: readonly string[]; - /** Called with the context that getSpec receives — for test assertions. */ - readonly onGetSpec?: (context: SurfaceContext | undefined) => void; - /** Called with the context that invoke receives — for test assertions. */ - readonly onInvoke?: ( - actionId: string, - payload: unknown, - context: SurfaceContext | undefined, - ) => void; + readonly id: string; + readonly title?: string; + readonly actions?: readonly string[]; + /** Called with the context that getSpec receives — for test assertions. */ + readonly onGetSpec?: (context: SurfaceContext | undefined) => void; + /** Called with the context that invoke receives — for test assertions. */ + readonly onInvoke?: ( + actionId: string, + payload: unknown, + context: SurfaceContext | undefined, + ) => void; } function fakeProvider( - idOrOpts: string | FakeProviderOpts, - title?: string, - actions?: readonly string[], + idOrOpts: string | FakeProviderOpts, + title?: string, + actions?: readonly string[], ): SurfaceProvider { - const opts: FakeProviderOpts = - typeof idOrOpts === "string" - ? { - id: idOrOpts, - ...(title !== undefined ? { title } : {}), - ...(actions !== undefined ? { actions } : {}), - } - : idOrOpts; - const catalogEntry: SurfaceCatalogEntry = { - id: opts.id, - region: "default", - title: opts.title ?? `Surface ${opts.id}`, - }; - return { - catalogEntry, - getSpec(context?: SurfaceContext): SurfaceSpec { - opts.onGetSpec?.(context); - return { - id: opts.id, - region: "default", - title: catalogEntry.title, - fields: - opts.actions?.map((a) => ({ - kind: "button" as const, - label: a, - action: { actionId: a }, - })) ?? [], - }; - }, - invoke(actionId: string, _payload?: unknown, context?: SurfaceContext) { - opts.onInvoke?.(actionId, _payload, context); - }, - }; + const opts: FakeProviderOpts = + typeof idOrOpts === "string" + ? { + id: idOrOpts, + ...(title !== undefined ? { title } : {}), + ...(actions !== undefined ? { actions } : {}), + } + : idOrOpts; + const catalogEntry: SurfaceCatalogEntry = { + id: opts.id, + region: "default", + title: opts.title ?? `Surface ${opts.id}`, + }; + return { + catalogEntry, + getSpec(context?: SurfaceContext): SurfaceSpec { + opts.onGetSpec?.(context); + return { + id: opts.id, + region: "default", + title: catalogEntry.title, + fields: + opts.actions?.map((a) => ({ + kind: "button" as const, + label: a, + action: { actionId: a }, + })) ?? [], + }; + }, + invoke(actionId: string, _payload?: unknown, context?: SurfaceContext) { + opts.onInvoke?.(actionId, _payload, context); + }, + }; } function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry { - const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); - return { - register(_provider: SurfaceProvider) { - return () => {}; - }, - getCatalog() { - return [...map.values()].map((p) => p.catalogEntry); - }, - getSurface(id: string) { - return map.get(id); - }, - }; + const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); + return { + register(_provider: SurfaceProvider) { + return () => {}; + }, + getCatalog() { + return [...map.values()].map((p) => p.catalogEntry); + }, + getSurface(id: string) { + return map.get(id); + }, + }; } // ── Tests ─────────────────────────────────────────────────────────────────── describe("routeClientMessage", () => { - describe("subscribe", () => { - it("replies with `surface` and tracks the subscription", () => { - const provider = fakeProvider("a", "Surface A"); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "subscribe", - surfaceId: "a", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(1); - expect(result.replies[0]).toEqual({ - type: "surface", - spec: { - id: "a", - region: "default", - title: "Surface A", - fields: [], - }, - }); - expect(result.subChange).toEqual({ op: "add", surfaceId: "a" }); - }); - - it("is idempotent — subscribing twice does not duplicate the subChange", () => { - const provider = fakeProvider("a"); - const registry = fakeRegistry([provider]); - const connSubs = new Set([subKey("a")]); // already subscribed (global) - - const result = routeClientMessage(registry, connSubs, { - type: "subscribe", - surfaceId: "a", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(1); - expect(result.replies[0]?.type).toBe("surface"); - expect(result.subChange).toBeUndefined(); - }); - - it("returns `error` for an unknown surface id", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "subscribe", - surfaceId: "nonexistent", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(1); - expect(result.replies[0]).toEqual({ - type: "error", - surfaceId: "nonexistent", - message: "Unknown surface: nonexistent", - }); - expect(result.subChange).toBeUndefined(); - }); - - it("subscribe with conversationId fetches the provider spec for that conversation and tags the reply", () => { - let receivedContext: SurfaceContext | undefined; - const provider = fakeProvider({ - id: "cache-warm", - title: "Cache Warming", - onGetSpec(ctx) { - receivedContext = ctx; - }, - }); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "subscribe", - surfaceId: "cache-warm", - conversationId: "conv-42", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(receivedContext).toEqual({ conversationId: "conv-42" }); - expect(result.replies).toHaveLength(1); - const reply = result.replies[0]; - if (reply?.type !== "surface") throw new Error("expected surface reply"); - expect(reply.conversationId).toBe("conv-42"); - expect(reply.spec.id).toBe("cache-warm"); - expect(result.subChange).toEqual({ - op: "add", - surfaceId: "cache-warm", - conversationId: "conv-42", - }); - }); - - it("subscribe without conversationId behaves as before (global surface unaffected)", () => { - let receivedContext: SurfaceContext | undefined; - const provider = fakeProvider({ - id: "global-surf", - title: "Global Surface", - onGetSpec(ctx) { - receivedContext = ctx; - }, - }); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "subscribe", - surfaceId: "global-surf", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(receivedContext).toBeUndefined(); - const reply = result.replies[0]; - if (reply?.type !== "surface") throw new Error("expected surface reply"); - expect(reply.conversationId).toBeUndefined(); - expect(result.subChange).toEqual({ op: "add", surfaceId: "global-surf" }); - }); - }); - - describe("unsubscribe", () => { - it("emits a remove subChange and no replies", () => { - const registry = fakeRegistry([]); - const connSubs = new Set([subKey("a")]); - - const result = routeClientMessage(registry, connSubs, { - type: "unsubscribe", - surfaceId: "a", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(0); - expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); - }); - - it("emits remove even if not currently subscribed (idempotent)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "unsubscribe", - surfaceId: "a", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(0); - expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); - }); - }); - - describe("invoke", () => { - it("signals the invoke effect for a known surface", () => { - const provider = fakeProvider("a", "Surface A", ["toggle"]); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "invoke", - surfaceId: "a", - actionId: "toggle", - payload: true, - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(0); - expect(result.invoke).toEqual({ - surfaceId: "a", - actionId: "toggle", - payload: true, - }); - }); - - it("returns `error` for an unknown surface id", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "invoke", - surfaceId: "nonexistent", - actionId: "toggle", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.replies).toHaveLength(1); - expect(result.replies[0]).toEqual({ - type: "error", - surfaceId: "nonexistent", - message: "Unknown surface: nonexistent", - }); - expect(result.invoke).toBeUndefined(); - }); - - it("invoke forwards the conversationId to the provider", () => { - let _receivedContext: SurfaceContext | undefined; - const provider = fakeProvider({ - id: "cache-warm", - title: "Cache Warming", - actions: ["warm"], - onInvoke(_actionId, _payload, ctx) { - _receivedContext = ctx; - }, - }); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "invoke", - surfaceId: "cache-warm", - actionId: "warm", - payload: { force: true }, - conversationId: "conv-99", - }); - - expect(result.kind).toBe("surface"); - if (result.kind !== "surface") throw new Error("expected surface"); - expect(result.invoke).toEqual({ - surfaceId: "cache-warm", - actionId: "warm", - payload: { force: true }, - conversationId: "conv-99", - }); - }); - }); - - describe("chat.send", () => { - it("classifies a chat.send message", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result.message).toBe("hello"); - expect(result.conversationId).toBeUndefined(); - expect(result.model).toBeUndefined(); - expect(result.cwd).toBeUndefined(); - }); - - it("passes through optional fields", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - conversationId: "conv-123", - message: "follow up", - model: "gpt-4", - cwd: "/tmp", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result.conversationId).toBe("conv-123"); - expect(result.message).toBe("follow up"); - expect(result.model).toBe("gpt-4"); - expect(result.cwd).toBe("/tmp"); - }); - - it("chat.send threads workspaceId", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - conversationId: "conv-ws", - message: "hello workspace", - workspaceId: "my-workspace", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result.workspaceId).toBe("my-workspace"); - }); - - it("chat.send defaults workspaceId when omitted", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello no workspace", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - // workspaceId is absent (undefined) — the orchestrator receives no - // workspaceId and applies its own "default" resolution. - expect(result).not.toHaveProperty("workspaceId"); - }); - - it("chat.send threads computerId", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - conversationId: "conv-cid", - message: "hello computer", - computerId: "dev-box", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result.computerId).toBe("dev-box"); - }); - - it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello no computer", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - // computerId is absent (undefined) — the orchestrator receives no - // computerId and resolves the inherited chain (conversation → - // workspace defaultComputerId → local). Mirrors workspaceId. - expect(result).not.toHaveProperty("computerId"); - }); - - it("rejects a malformed chat.send (empty message)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "", - }); - - expect(result.kind).toBe("chat-error"); - if (result.kind !== "chat-error") throw new Error("expected chat-error"); - expect(result.errorMessage).toContain("non-empty string"); - }); - - it("rejects a malformed chat.send (missing message)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: undefined as unknown as string, - }); - - expect(result.kind).toBe("chat-error"); - if (result.kind !== "chat-error") throw new Error("expected chat-error"); - expect(result.errorMessage).toContain("non-empty string"); - }); - - it("threads each valid reasoningEffort level through to the result", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - const levels = ["low", "medium", "high", "xhigh", "max"] as const; - - for (const level of levels) { - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello", - reasoningEffort: level, - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result.reasoningEffort).toBe(level); - } - }); - - it("omits reasoningEffort from result when not provided by client", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello", - }); - - expect(result.kind).toBe("chat"); - if (result.kind !== "chat") throw new Error("expected chat"); - expect(result).not.toHaveProperty("reasoningEffort"); - }); - - it("rejects an invalid reasoningEffort value with a chat-error", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.send", - message: "hello", - reasoningEffort: "turbo" as unknown as "low", - }); - - expect(result.kind).toBe("chat-error"); - if (result.kind !== "chat-error") throw new Error("expected chat-error"); - expect(result.errorMessage).toContain("invalid reasoningEffort"); - expect(result.errorMessage).toContain("turbo"); - }); - }); - - describe("chat.subscribe", () => { - it("routes chat.subscribe → { kind: 'chat-subscribe', conversationId }", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.subscribe", - conversationId: "conv-abc", - }); - - expect(result).toEqual({ kind: "chat-subscribe", conversationId: "conv-abc" }); - }); - }); - - describe("chat.unsubscribe", () => { - it("routes chat.unsubscribe → { kind: 'chat-unsubscribe', conversationId }", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.unsubscribe", - conversationId: "conv-abc", - }); - - expect(result).toEqual({ kind: "chat-unsubscribe", conversationId: "conv-abc" }); - }); - }); - - describe("chat.queue", () => { - it("routes a valid chat.queue → { kind: 'chat-queue', conversationId, text } (what the shell passes to orchestrator.enqueue)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.queue", - conversationId: "conv-1", - text: "steer here", - }); - - expect(result).toEqual({ - kind: "chat-queue", - conversationId: "conv-1", - text: "steer here", - }); - }); - - it("chat.queue threads workspaceId", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.queue", - conversationId: "conv-ws", - text: "steer here", - workspaceId: "my-workspace", - }); - - expect(result.kind).toBe("chat-queue"); - if (result.kind !== "chat-queue") throw new Error("expected chat-queue"); - expect(result.workspaceId).toBe("my-workspace"); - }); - - it("rejects empty/whitespace text → chat-error (no enqueue signal)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - for (const text of ["", " ", "\t\n"]) { - const result = routeClientMessage(registry, connSubs, { - type: "chat.queue", - conversationId: "conv-1", - text, - }); - - expect(result.kind).toBe("chat-error"); - if (result.kind !== "chat-error") throw new Error("expected chat-error"); - expect(result.conversationId).toBe("conv-1"); - expect(result.errorMessage).toContain("non-empty string"); - expect(result.errorMessage).toContain("text"); - } - }); - - it("rejects missing text → chat-error (no enqueue signal)", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - const result = routeClientMessage(registry, connSubs, { - type: "chat.queue", - conversationId: "conv-1", - text: undefined as unknown as string, - }); - - expect(result.kind).toBe("chat-error"); - if (result.kind !== "chat-error") throw new Error("expected chat-error"); - expect(result.errorMessage).toContain("non-empty string"); - }); - - it("does not trim the stored text — passes the original through to the shell", () => { - const registry = fakeRegistry([]); - const connSubs = new Set(); - - // Non-empty after trim (so valid), but the value carries surrounding - // whitespace: the router passes it through unchanged (validation uses - // trim; the orchestrator receives the original text). - const result = routeClientMessage(registry, connSubs, { - type: "chat.queue", - conversationId: "conv-1", - text: " steer ", - }); - - expect(result.kind).toBe("chat-queue"); - if (result.kind !== "chat-queue") throw new Error("expected chat-queue"); - expect(result.text).toBe(" steer "); - }); - }); - - describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => { - // Every WsClientMessage variant must route to a defined result with a - // known kind — no fall-through / undefined return. If the union is - // widened again, `tsc` catches the missing case (the switch is - // exhaustive); this test guards the runtime side of that contract. - it("routes every WsClientMessage variant to a defined RouteResult", () => { - const provider = fakeProvider("a", "Surface A", ["toggle"]); - const registry = fakeRegistry([provider]); - const connSubs = new Set(); - - const samples: WsClientMessage[] = [ - { type: "subscribe", surfaceId: "a" }, - { type: "unsubscribe", surfaceId: "a" }, - { type: "invoke", surfaceId: "a", actionId: "toggle", payload: true }, - { type: "chat.send", message: "hi" }, - { type: "chat.subscribe", conversationId: "c1" }, - { type: "chat.unsubscribe", conversationId: "c1" }, - { type: "chat.queue", conversationId: "c1", text: "steer" }, - ]; - - const validKinds = new Set([ - "surface", - "chat", - "chat-error", - "chat-subscribe", - "chat-unsubscribe", - "chat-queue", - ]); - - for (const msg of samples) { - const result = routeClientMessage(registry, connSubs, msg); - expect(result).toBeDefined(); - expect(validKinds.has(result.kind)).toBe(true); - } - }); - }); + describe("subscribe", () => { + it("replies with `surface` and tracks the subscription", () => { + const provider = fakeProvider("a", "Surface A"); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "a", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "surface", + spec: { + id: "a", + region: "default", + title: "Surface A", + fields: [], + }, + }); + expect(result.subChange).toEqual({ op: "add", surfaceId: "a" }); + }); + + it("is idempotent — subscribing twice does not duplicate the subChange", () => { + const provider = fakeProvider("a"); + const registry = fakeRegistry([provider]); + const connSubs = new Set([subKey("a")]); // already subscribed (global) + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "a", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(1); + expect(result.replies[0]?.type).toBe("surface"); + expect(result.subChange).toBeUndefined(); + }); + + it("returns `error` for an unknown surface id", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "nonexistent", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "error", + surfaceId: "nonexistent", + message: "Unknown surface: nonexistent", + }); + expect(result.subChange).toBeUndefined(); + }); + + it("subscribe with conversationId fetches the provider spec for that conversation and tags the reply", () => { + let receivedContext: SurfaceContext | undefined; + const provider = fakeProvider({ + id: "cache-warm", + title: "Cache Warming", + onGetSpec(ctx) { + receivedContext = ctx; + }, + }); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "cache-warm", + conversationId: "conv-42", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(receivedContext).toEqual({ conversationId: "conv-42" }); + expect(result.replies).toHaveLength(1); + const reply = result.replies[0]; + if (reply?.type !== "surface") throw new Error("expected surface reply"); + expect(reply.conversationId).toBe("conv-42"); + expect(reply.spec.id).toBe("cache-warm"); + expect(result.subChange).toEqual({ + op: "add", + surfaceId: "cache-warm", + conversationId: "conv-42", + }); + }); + + it("subscribe without conversationId behaves as before (global surface unaffected)", () => { + let receivedContext: SurfaceContext | undefined; + const provider = fakeProvider({ + id: "global-surf", + title: "Global Surface", + onGetSpec(ctx) { + receivedContext = ctx; + }, + }); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "subscribe", + surfaceId: "global-surf", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(receivedContext).toBeUndefined(); + const reply = result.replies[0]; + if (reply?.type !== "surface") throw new Error("expected surface reply"); + expect(reply.conversationId).toBeUndefined(); + expect(result.subChange).toEqual({ op: "add", surfaceId: "global-surf" }); + }); + }); + + describe("unsubscribe", () => { + it("emits a remove subChange and no replies", () => { + const registry = fakeRegistry([]); + const connSubs = new Set([subKey("a")]); + + const result = routeClientMessage(registry, connSubs, { + type: "unsubscribe", + surfaceId: "a", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(0); + expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); + }); + + it("emits remove even if not currently subscribed (idempotent)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "unsubscribe", + surfaceId: "a", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(0); + expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" }); + }); + }); + + describe("invoke", () => { + it("signals the invoke effect for a known surface", () => { + const provider = fakeProvider("a", "Surface A", ["toggle"]); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "invoke", + surfaceId: "a", + actionId: "toggle", + payload: true, + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(0); + expect(result.invoke).toEqual({ + surfaceId: "a", + actionId: "toggle", + payload: true, + }); + }); + + it("returns `error` for an unknown surface id", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "invoke", + surfaceId: "nonexistent", + actionId: "toggle", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.replies).toHaveLength(1); + expect(result.replies[0]).toEqual({ + type: "error", + surfaceId: "nonexistent", + message: "Unknown surface: nonexistent", + }); + expect(result.invoke).toBeUndefined(); + }); + + it("invoke forwards the conversationId to the provider", () => { + let _receivedContext: SurfaceContext | undefined; + const provider = fakeProvider({ + id: "cache-warm", + title: "Cache Warming", + actions: ["warm"], + onInvoke(_actionId, _payload, ctx) { + _receivedContext = ctx; + }, + }); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "invoke", + surfaceId: "cache-warm", + actionId: "warm", + payload: { force: true }, + conversationId: "conv-99", + }); + + expect(result.kind).toBe("surface"); + if (result.kind !== "surface") throw new Error("expected surface"); + expect(result.invoke).toEqual({ + surfaceId: "cache-warm", + actionId: "warm", + payload: { force: true }, + conversationId: "conv-99", + }); + }); + }); + + describe("chat.send", () => { + it("classifies a chat.send message", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.message).toBe("hello"); + expect(result.conversationId).toBeUndefined(); + expect(result.model).toBeUndefined(); + expect(result.cwd).toBeUndefined(); + }); + + it("passes through optional fields", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-123", + message: "follow up", + model: "gpt-4", + cwd: "/tmp", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.conversationId).toBe("conv-123"); + expect(result.message).toBe("follow up"); + expect(result.model).toBe("gpt-4"); + expect(result.cwd).toBe("/tmp"); + }); + + it("chat.send threads workspaceId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-ws", + message: "hello workspace", + workspaceId: "my-workspace", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.workspaceId).toBe("my-workspace"); + }); + + it("chat.send defaults workspaceId when omitted", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello no workspace", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + // workspaceId is absent (undefined) — the orchestrator receives no + // workspaceId and applies its own "default" resolution. + expect(result).not.toHaveProperty("workspaceId"); + }); + + it("chat.send threads computerId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-cid", + message: "hello computer", + computerId: "dev-box", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.computerId).toBe("dev-box"); + }); + + it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello no computer", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + // computerId is absent (undefined) — the orchestrator receives no + // computerId and resolves the inherited chain (conversation → + // workspace defaultComputerId → local). Mirrors workspaceId. + expect(result).not.toHaveProperty("computerId"); + }); + + it("rejects a malformed chat.send (empty message)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "", + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("non-empty string"); + }); + + it("rejects a malformed chat.send (missing message)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: undefined as unknown as string, + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("non-empty string"); + }); + + it("threads each valid reasoningEffort level through to the result", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + const levels = ["low", "medium", "high", "xhigh", "max"] as const; + + for (const level of levels) { + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + reasoningEffort: level, + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.reasoningEffort).toBe(level); + } + }); + + it("omits reasoningEffort from result when not provided by client", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result).not.toHaveProperty("reasoningEffort"); + }); + + it("rejects an invalid reasoningEffort value with a chat-error", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + reasoningEffort: "turbo" as unknown as "low", + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("invalid reasoningEffort"); + expect(result.errorMessage).toContain("turbo"); + }); + }); + + describe("chat.subscribe", () => { + it("routes chat.subscribe → { kind: 'chat-subscribe', conversationId }", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.subscribe", + conversationId: "conv-abc", + }); + + expect(result).toEqual({ kind: "chat-subscribe", conversationId: "conv-abc" }); + }); + }); + + describe("chat.unsubscribe", () => { + it("routes chat.unsubscribe → { kind: 'chat-unsubscribe', conversationId }", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.unsubscribe", + conversationId: "conv-abc", + }); + + expect(result).toEqual({ kind: "chat-unsubscribe", conversationId: "conv-abc" }); + }); + }); + + describe("chat.queue", () => { + it("routes a valid chat.queue → { kind: 'chat-queue', conversationId, text } (what the shell passes to orchestrator.enqueue)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-1", + text: "steer here", + }); + + expect(result).toEqual({ + kind: "chat-queue", + conversationId: "conv-1", + text: "steer here", + }); + }); + + it("chat.queue threads workspaceId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-ws", + text: "steer here", + workspaceId: "my-workspace", + }); + + expect(result.kind).toBe("chat-queue"); + if (result.kind !== "chat-queue") throw new Error("expected chat-queue"); + expect(result.workspaceId).toBe("my-workspace"); + }); + + it("rejects empty/whitespace text → chat-error (no enqueue signal)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + for (const text of ["", " ", "\t\n"]) { + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-1", + text, + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.conversationId).toBe("conv-1"); + expect(result.errorMessage).toContain("non-empty string"); + expect(result.errorMessage).toContain("text"); + } + }); + + it("rejects missing text → chat-error (no enqueue signal)", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-1", + text: undefined as unknown as string, + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("non-empty string"); + }); + + it("does not trim the stored text — passes the original through to the shell", () => { + const registry = fakeRegistry([]); + const connSubs = new Set(); + + // Non-empty after trim (so valid), but the value carries surrounding + // whitespace: the router passes it through unchanged (validation uses + // trim; the orchestrator receives the original text). + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-1", + text: " steer ", + }); + + expect(result.kind).toBe("chat-queue"); + if (result.kind !== "chat-queue") throw new Error("expected chat-queue"); + expect(result.text).toBe(" steer "); + }); + }); + + describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => { + // Every WsClientMessage variant must route to a defined result with a + // known kind — no fall-through / undefined return. If the union is + // widened again, `tsc` catches the missing case (the switch is + // exhaustive); this test guards the runtime side of that contract. + it("routes every WsClientMessage variant to a defined RouteResult", () => { + const provider = fakeProvider("a", "Surface A", ["toggle"]); + const registry = fakeRegistry([provider]); + const connSubs = new Set(); + + const samples: WsClientMessage[] = [ + { type: "subscribe", surfaceId: "a" }, + { type: "unsubscribe", surfaceId: "a" }, + { type: "invoke", surfaceId: "a", actionId: "toggle", payload: true }, + { type: "chat.send", message: "hi" }, + { type: "chat.subscribe", conversationId: "c1" }, + { type: "chat.unsubscribe", conversationId: "c1" }, + { type: "chat.queue", conversationId: "c1", text: "steer" }, + ]; + + const validKinds = new Set([ + "surface", + "chat", + "chat-error", + "chat-subscribe", + "chat-unsubscribe", + "chat-queue", + ]); + + for (const msg of samples) { + const result = routeClientMessage(registry, connSubs, msg); + expect(result).toBeDefined(); + expect(validKinds.has(result.kind)).toBe(true); + } + }); + }); }); describe("catalogMessage", () => { - it("returns the catalog from the registry", () => { - const providerA = fakeProvider("a", "Surface A"); - const providerB = fakeProvider("b", "Surface B"); - const registry = fakeRegistry([providerA, providerB]); + it("returns the catalog from the registry", () => { + const providerA = fakeProvider("a", "Surface A"); + const providerB = fakeProvider("b", "Surface B"); + const registry = fakeRegistry([providerA, providerB]); - const msg = catalogMessage(registry); + const msg = catalogMessage(registry); - expect(msg).toEqual({ - type: "catalog", - catalog: [ - { id: "a", region: "default", title: "Surface A" }, - { id: "b", region: "default", title: "Surface B" }, - ], - }); - }); + expect(msg).toEqual({ + type: "catalog", + catalog: [ + { id: "a", region: "default", title: "Surface A" }, + { id: "b", region: "default", title: "Surface B" }, + ], + }); + }); - it("returns an empty catalog when no providers are registered", () => { - const registry = fakeRegistry([]); + it("returns an empty catalog when no providers are registered", () => { + const registry = fakeRegistry([]); - const msg = catalogMessage(registry); + const msg = catalogMessage(registry); - expect(msg).toEqual({ type: "catalog", catalog: [] }); - }); + expect(msg).toEqual({ type: "catalog", catalog: [] }); + }); }); describe("subKey", () => { - it("builds a global key when conversationId is undefined", () => { - expect(subKey("surf-a")).toBe("surf-a::"); - }); + it("builds a global key when conversationId is undefined", () => { + expect(subKey("surf-a")).toBe("surf-a::"); + }); - it("builds a conversation-scoped key when conversationId is provided", () => { - expect(subKey("surf-a", "conv-42")).toBe("surf-a::conv-42"); - }); + it("builds a conversation-scoped key when conversationId is provided", () => { + expect(subKey("surf-a", "conv-42")).toBe("surf-a::conv-42"); + }); - it("global and conversation-scoped keys are distinct", () => { - expect(subKey("surf-a")).not.toBe(subKey("surf-a", "conv-42")); - }); + it("global and conversation-scoped keys are distinct", () => { + expect(subKey("surf-a")).not.toBe(subKey("surf-a", "conv-42")); + }); }); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index 7e9ba77..a33aa5a 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -9,12 +9,12 @@ import type { SurfaceContext, SurfaceRegistry } from "@dispatch/surface-registry"; import type { - ChatQueueMessage, - ChatSendMessage, - ChatSubscribeMessage, - ChatUnsubscribeMessage, - ReasoningEffort, - WsClientMessage, + ChatQueueMessage, + ChatSendMessage, + ChatSubscribeMessage, + ChatUnsubscribeMessage, + ReasoningEffort, + WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -22,61 +22,61 @@ import type { SurfaceServerMessage } from "@dispatch/ui-contract"; /** The effect a surface client message should produce. */ export interface SurfaceRouteResult { - readonly kind: "surface"; - /** Server messages to send back to this connection. */ - readonly replies: readonly SurfaceServerMessage[]; - /** Whether to add or remove the surface id from connSubs. */ - readonly subChange?: { - readonly op: "add" | "remove"; - readonly surfaceId: string; - readonly conversationId?: string; - }; - /** If set, the shell must call `provider.invoke(actionId, payload, context)`. */ - readonly invoke?: { - readonly surfaceId: string; - readonly actionId: string; - readonly payload?: unknown; - readonly conversationId?: string; - }; + readonly kind: "surface"; + /** Server messages to send back to this connection. */ + readonly replies: readonly SurfaceServerMessage[]; + /** Whether to add or remove the surface id from connSubs. */ + readonly subChange?: { + readonly op: "add" | "remove"; + readonly surfaceId: string; + readonly conversationId?: string; + }; + /** If set, the shell must call `provider.invoke(actionId, payload, context)`. */ + readonly invoke?: { + readonly surfaceId: string; + readonly actionId: string; + readonly payload?: unknown; + readonly conversationId?: string; + }; } /** The effect a validated chat.send should produce. */ export interface ChatRouteResult { - readonly kind: "chat"; - readonly conversationId: string | undefined; - readonly message: string; - readonly model: string | undefined; - readonly cwd: string | undefined; - readonly reasoningEffort?: ReasoningEffort; - readonly workspaceId?: string; - /** - * The computer (SSH config alias) to run this turn's tools on — forwarded - * verbatim to the orchestrator's `startTurn` (which resolves it via - * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn - * override, unvalidated here (validation happens at SSH connect time). - * Absent when the client omits it (the orchestrator then inherits the - * conversation → workspace → local chain). - */ - readonly computerId?: string; + readonly kind: "chat"; + readonly conversationId: string | undefined; + readonly message: string; + readonly model: string | undefined; + readonly cwd: string | undefined; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; + /** + * The computer (SSH config alias) to run this turn's tools on — forwarded + * verbatim to the orchestrator's `startTurn` (which resolves it via + * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn + * override, unvalidated here (validation happens at SSH connect time). + * Absent when the client omits it (the orchestrator then inherits the + * conversation → workspace → local chain). + */ + readonly computerId?: string; } /** A malformed chat.send that should yield a chat.error reply. */ export interface ChatRouteError { - readonly kind: "chat-error"; - readonly conversationId: string | undefined; - readonly errorMessage: string; + readonly kind: "chat-error"; + readonly conversationId: string | undefined; + readonly errorMessage: string; } /** The effect a chat.subscribe should produce. */ export interface ChatSubscribeRouteResult { - readonly kind: "chat-subscribe"; - readonly conversationId: string; + readonly kind: "chat-subscribe"; + readonly conversationId: string; } /** The effect a chat.unsubscribe should produce. */ export interface ChatUnsubscribeRouteResult { - readonly kind: "chat-unsubscribe"; - readonly conversationId: string; + readonly kind: "chat-unsubscribe"; + readonly conversationId: string; } /** @@ -87,20 +87,20 @@ export interface ChatUnsubscribeRouteResult { * (startedTurn:true — the shell auto-subscribes the sender, same as chat.send). */ export interface ChatQueueRouteResult { - readonly kind: "chat-queue"; - readonly conversationId: string; - readonly text: string; - readonly workspaceId?: string; + readonly kind: "chat-queue"; + readonly conversationId: string; + readonly text: string; + readonly workspaceId?: string; } /** The effect any client WS message should produce. */ export type RouteResult = - | SurfaceRouteResult - | ChatRouteResult - | ChatRouteError - | ChatSubscribeRouteResult - | ChatUnsubscribeRouteResult - | ChatQueueRouteResult; + | SurfaceRouteResult + | ChatRouteResult + | ChatRouteError + | ChatSubscribeRouteResult + | ChatUnsubscribeRouteResult + | ChatQueueRouteResult; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -109,12 +109,12 @@ export type RouteResult = * The shell uses this same function so both layers agree on key format. */ export function subKey(surfaceId: string, conversationId?: string): string { - return conversationId !== undefined ? `${surfaceId}::${conversationId}` : `${surfaceId}::`; + return conversationId !== undefined ? `${surfaceId}::${conversationId}` : `${surfaceId}::`; } /** Build the catalog `SurfaceServerMessage` from the registry. */ export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage { - return { type: "catalog", catalog: registry.getCatalog() }; + return { type: "catalog", catalog: registry.getCatalog() }; } // ── Router ────────────────────────────────────────────────────────────────── @@ -127,71 +127,71 @@ export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage * @param msg The parsed client message (surface or chat). */ export function routeClientMessage( - registry: SurfaceRegistry, - connSubs: ReadonlySet, - msg: WsClientMessage, + registry: SurfaceRegistry, + connSubs: ReadonlySet, + msg: WsClientMessage, ): RouteResult { - switch (msg.type) { - case "subscribe": - return handleSubscribe(registry, connSubs, msg.surfaceId, msg.conversationId); - case "unsubscribe": - return handleUnsubscribe(msg.surfaceId, msg.conversationId); - case "invoke": - return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload, msg.conversationId); - case "chat.send": - return handleChatSend(msg); - case "chat.subscribe": - return handleChatSubscribe(msg); - case "chat.unsubscribe": - return handleChatUnsubscribe(msg); - case "chat.queue": - return handleChatQueue(msg); - } + switch (msg.type) { + case "subscribe": + return handleSubscribe(registry, connSubs, msg.surfaceId, msg.conversationId); + case "unsubscribe": + return handleUnsubscribe(msg.surfaceId, msg.conversationId); + case "invoke": + return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload, msg.conversationId); + case "chat.send": + return handleChatSend(msg); + case "chat.subscribe": + return handleChatSubscribe(msg); + case "chat.unsubscribe": + return handleChatUnsubscribe(msg); + case "chat.queue": + return handleChatQueue(msg); + } } // ── Chat validation ───────────────────────────────────────────────────────── const VALID_REASONING_EFFORT: ReadonlySet = new Set([ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]); function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError { - if (typeof msg.message !== "string" || msg.message.length === 0) { - return { - kind: "chat-error", - conversationId: msg.conversationId, - errorMessage: "chat.send requires a non-empty string `message`", - }; - } - if (msg.reasoningEffort !== undefined && !VALID_REASONING_EFFORT.has(msg.reasoningEffort)) { - return { - kind: "chat-error", - conversationId: msg.conversationId, - errorMessage: `chat.send: invalid reasoningEffort "${msg.reasoningEffort}" — must be one of: low, medium, high, xhigh, max`, - }; - } - return { - kind: "chat", - conversationId: msg.conversationId, - message: msg.message, - model: msg.model, - cwd: msg.cwd, - ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), - ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), - ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}), - }; + if (typeof msg.message !== "string" || msg.message.length === 0) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: "chat.send requires a non-empty string `message`", + }; + } + if (msg.reasoningEffort !== undefined && !VALID_REASONING_EFFORT.has(msg.reasoningEffort)) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: `chat.send: invalid reasoningEffort "${msg.reasoningEffort}" — must be one of: low, medium, high, xhigh, max`, + }; + } + return { + kind: "chat", + conversationId: msg.conversationId, + message: msg.message, + model: msg.model, + cwd: msg.cwd, + ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), + ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), + ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}), + }; } function handleChatSubscribe(msg: ChatSubscribeMessage): ChatSubscribeRouteResult { - return { kind: "chat-subscribe", conversationId: msg.conversationId }; + return { kind: "chat-subscribe", conversationId: msg.conversationId }; } function handleChatUnsubscribe(msg: ChatUnsubscribeMessage): ChatUnsubscribeRouteResult { - return { kind: "chat-unsubscribe", conversationId: msg.conversationId }; + return { kind: "chat-unsubscribe", conversationId: msg.conversationId }; } /** @@ -201,105 +201,105 @@ function handleChatUnsubscribe(msg: ChatUnsubscribeMessage): ChatUnsubscribeRout * called). Valid → `chat-queue` (the shell calls `orchestrator.enqueue`). */ function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRouteError { - if (typeof msg.text !== "string" || msg.text.trim().length === 0) { - return { - kind: "chat-error", - conversationId: msg.conversationId, - errorMessage: "chat.queue requires a non-empty string `text`", - }; - } - return { - kind: "chat-queue", - conversationId: msg.conversationId, - text: msg.text, - ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), - }; + if (typeof msg.text !== "string" || msg.text.trim().length === 0) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: "chat.queue requires a non-empty string `text`", + }; + } + return { + kind: "chat-queue", + conversationId: msg.conversationId, + text: msg.text, + ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), + }; } // ── Per-message handlers ──────────────────────────────────────────────────── function handleSubscribe( - registry: SurfaceRegistry, - connSubs: ReadonlySet, - surfaceId: string, - conversationId?: string, + registry: SurfaceRegistry, + connSubs: ReadonlySet, + surfaceId: string, + conversationId?: string, ): SurfaceRouteResult { - const provider = registry.getSurface(surfaceId); - if (!provider) { - return { - kind: "surface", - replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], - }; - } + const provider = registry.getSurface(surfaceId); + if (!provider) { + return { + kind: "surface", + replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], + }; + } - const context: SurfaceContext | undefined = - conversationId !== undefined ? { conversationId } : undefined; - const spec = provider.getSpec(context); + const context: SurfaceContext | undefined = + conversationId !== undefined ? { conversationId } : undefined; + const spec = provider.getSpec(context); - // getSpec may be sync or async — the pure core treats it as a value the - // shell will resolve. We return the spec directly (it's a SurfaceSpec). - // If it's a Promise the shell awaits it; if it's sync it's already the value. - // For the pure core we just pass it through — the shell handles the resolution. - const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec; + // getSpec may be sync or async — the pure core treats it as a value the + // shell will resolve. We return the spec directly (it's a SurfaceSpec). + // If it's a Promise the shell awaits it; if it's sync it's already the value. + // For the pure core we just pass it through — the shell handles the resolution. + const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec; - const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [ - { - type: "surface", - spec: specValue, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - ]; + const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [ + { + type: "surface", + spec: specValue, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + ]; - // Idempotent: only emit subChange if not already subscribed. - const key = subKey(surfaceId, conversationId); - if (!connSubs.has(key)) { - return { - kind: "surface", - replies, - subChange: { - op: "add", - surfaceId, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - }; - } - return { kind: "surface", replies }; + // Idempotent: only emit subChange if not already subscribed. + const key = subKey(surfaceId, conversationId); + if (!connSubs.has(key)) { + return { + kind: "surface", + replies, + subChange: { + op: "add", + surfaceId, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }; + } + return { kind: "surface", replies }; } function handleUnsubscribe(surfaceId: string, conversationId?: string): SurfaceRouteResult { - return { - kind: "surface", - replies: [], - subChange: { - op: "remove", - surfaceId, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - }; + return { + kind: "surface", + replies: [], + subChange: { + op: "remove", + surfaceId, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }; } function handleInvoke( - registry: SurfaceRegistry, - surfaceId: string, - actionId: string, - payload?: unknown, - conversationId?: string, + registry: SurfaceRegistry, + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, ): SurfaceRouteResult { - const provider = registry.getSurface(surfaceId); - if (!provider) { - return { - kind: "surface", - replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], - }; - } - return { - kind: "surface", - replies: [], - invoke: { - surfaceId, - actionId, - payload, - ...(conversationId !== undefined ? { conversationId } : {}), - }, - }; + const provider = registry.getSurface(surfaceId); + if (!provider) { + return { + kind: "surface", + replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }], + }; + } + return { + kind: "surface", + replies: [], + invoke: { + surfaceId, + actionId, + payload, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }; } diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts index e24aa6b..6b64f37 100644 --- a/packages/transport-ws/src/server.bun.test.ts +++ b/packages/transport-ws/src/server.bun.test.ts @@ -9,1202 +9,1202 @@ import { catalogMessage, routeClientMessage, subKey } from "./router.js"; // ── Fake Logger (captures records for assertions) ─────────────────────────── interface LogEntry { - readonly level: "debug" | "info" | "warn" | "error"; - readonly msg: string; - readonly attrs?: Attributes | ErrorAttributes; + readonly level: "debug" | "info" | "warn" | "error"; + readonly msg: string; + readonly attrs?: Attributes | ErrorAttributes; } function fakeLogger(): Logger & { readonly entries: readonly LogEntry[] } { - const entries: LogEntry[] = []; - return { - entries, - debug(msg, attrs) { - entries.push({ level: "debug", msg, ...(attrs !== undefined ? { attrs } : {}) }); - }, - info(msg, attrs) { - entries.push({ level: "info", msg, ...(attrs !== undefined ? { attrs } : {}) }); - }, - warn(msg, attrs) { - entries.push({ level: "warn", msg, ...(attrs !== undefined ? { attrs } : {}) }); - }, - error(msg, attrs) { - entries.push({ level: "error", msg, ...(attrs !== undefined ? { attrs } : {}) }); - }, - child() { - return fakeLogger(); - }, - span() { - return { - id: "fake-span", - log: fakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const entries: LogEntry[] = []; + return { + entries, + debug(msg, attrs) { + entries.push({ level: "debug", msg, ...(attrs !== undefined ? { attrs } : {}) }); + }, + info(msg, attrs) { + entries.push({ level: "info", msg, ...(attrs !== undefined ? { attrs } : {}) }); + }, + warn(msg, attrs) { + entries.push({ level: "warn", msg, ...(attrs !== undefined ? { attrs } : {}) }); + }, + error(msg, attrs) { + entries.push({ level: "error", msg, ...(attrs !== undefined ? { attrs } : {}) }); + }, + child() { + return fakeLogger(); + }, + span() { + return { + id: "fake-span", + log: fakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } // ── Fake registry (same pattern as router.test.ts) ────────────────────────── function fakeProvider(id: string, title?: string): SurfaceProvider { - const catalogEntry: SurfaceCatalogEntry = { - id, - region: "default", - title: title ?? `Surface ${id}`, - }; - return { - catalogEntry, - getSpec(_context?: SurfaceContext): SurfaceSpec { - return { - id, - region: "default", - title: catalogEntry.title, - fields: [], - }; - }, - invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext) {}, - }; + const catalogEntry: SurfaceCatalogEntry = { + id, + region: "default", + title: title ?? `Surface ${id}`, + }; + return { + catalogEntry, + getSpec(_context?: SurfaceContext): SurfaceSpec { + return { + id, + region: "default", + title: catalogEntry.title, + fields: [], + }; + }, + invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext) {}, + }; } function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry { - const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); - return { - register(_provider: SurfaceProvider) { - return () => {}; - }, - getCatalog() { - return [...map.values()].map((p) => p.catalogEntry); - }, - getSurface(id: string) { - return map.get(id); - }, - }; + const map = new Map(providers.map((p) => [p.catalogEntry.id, p])); + return { + register(_provider: SurfaceProvider) { + return () => {}; + }, + getCatalog() { + return [...map.values()].map((p) => p.catalogEntry); + }, + getSurface(id: string) { + return map.get(id); + }, + }; } // ── Fake SessionOrchestrator (DI at the transport edge, not a vi.mock) ────── interface FakeOrchestratorOpts { - /** Pre-registered listeners per conversation (for test assertions). */ - readonly listeners?: Map>; - /** Events to replay on subscribe (simulates buffered in-flight events). */ - readonly bufferedEvents?: Map; - /** Custom startTurn impl. */ - readonly startTurn?: SessionOrchestrator["startTurn"]; - /** If true, startTurn always returns already-active. */ - readonly alreadyActive?: boolean; - /** Custom enqueue impl. */ - readonly enqueue?: SessionOrchestrator["enqueue"]; - /** If true, enqueue reports the conversation was active (startedTurn:false). */ - readonly queueActive?: boolean; + /** Pre-registered listeners per conversation (for test assertions). */ + readonly listeners?: Map>; + /** Events to replay on subscribe (simulates buffered in-flight events). */ + readonly bufferedEvents?: Map; + /** Custom startTurn impl. */ + readonly startTurn?: SessionOrchestrator["startTurn"]; + /** If true, startTurn always returns already-active. */ + readonly alreadyActive?: boolean; + /** Custom enqueue impl. */ + readonly enqueue?: SessionOrchestrator["enqueue"]; + /** If true, enqueue reports the conversation was active (startedTurn:false). */ + readonly queueActive?: boolean; } function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { - readonly listeners: Map>; - readonly startCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; - readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; - readonly aborted: boolean; + readonly listeners: Map>; + readonly startCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; + readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; + readonly aborted: boolean; } { - const listeners = opts?.listeners ?? new Map>(); - const bufferedEvents = opts?.bufferedEvents ?? new Map(); - const startCalls: { conversationId: string; text: string }[] = []; - const enqueueCalls: { conversationId: string; text: string }[] = []; - const aborted = false; - - return { - listeners, - get startCalls() { - return startCalls; - }, - get enqueueCalls() { - return enqueueCalls; - }, - get aborted() { - return aborted; - }, - startTurn(input) { - startCalls.push({ - conversationId: input.conversationId, - text: input.text, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - if (opts?.startTurn) { - return opts.startTurn(input); - } - if (opts?.alreadyActive) { - return { started: false, reason: "already-active" }; - } - return { started: true, turnId: "fake-turn-id" }; - }, - enqueue(input) { - enqueueCalls.push({ - conversationId: input.conversationId, - text: input.text, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - if (opts?.enqueue) { - return opts.enqueue(input); - } - if (opts?.queueActive) { - return { startedTurn: false, queue: [] }; - } - return { startedTurn: true, queue: [] }; - }, - subscribe(conversationId, listener) { - let set = listeners.get(conversationId); - if (!set) { - set = new Set(); - listeners.set(conversationId, set); - } - // Replay buffered events (late-join). - const buffered = bufferedEvents.get(conversationId); - if (buffered) { - for (const event of buffered) { - listener(event); - } - } - set.add(listener); - return () => { - set.delete(listener); - }; - }, - isActive(conversationId) { - return listeners.has(conversationId); - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(_input) { - // Not used by the new transport-ws, but kept for interface compat. - }, - }; + const listeners = opts?.listeners ?? new Map>(); + const bufferedEvents = opts?.bufferedEvents ?? new Map(); + const startCalls: { conversationId: string; text: string }[] = []; + const enqueueCalls: { conversationId: string; text: string }[] = []; + const aborted = false; + + return { + listeners, + get startCalls() { + return startCalls; + }, + get enqueueCalls() { + return enqueueCalls; + }, + get aborted() { + return aborted; + }, + startTurn(input) { + startCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); + if (opts?.startTurn) { + return opts.startTurn(input); + } + if (opts?.alreadyActive) { + return { started: false, reason: "already-active" }; + } + return { started: true, turnId: "fake-turn-id" }; + }, + enqueue(input) { + enqueueCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); + if (opts?.enqueue) { + return opts.enqueue(input); + } + if (opts?.queueActive) { + return { startedTurn: false, queue: [] }; + } + return { startedTurn: true, queue: [] }; + }, + subscribe(conversationId, listener) { + let set = listeners.get(conversationId); + if (!set) { + set = new Set(); + listeners.set(conversationId, set); + } + // Replay buffered events (late-join). + const buffered = bufferedEvents.get(conversationId); + if (buffered) { + for (const event of buffered) { + listener(event); + } + } + set.add(listener); + return () => { + set.delete(listener); + }; + }, + isActive(conversationId) { + return listeners.has(conversationId); + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(_input) { + // Not used by the new transport-ws, but kept for interface compat. + }, + }; } /** Create a fake orchestrator that broadcasts events when `broadcast` is called. */ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & { - readonly listeners: Map>; - readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; - broadcast(conversationId: string, event: AgentEvent): void; + readonly listeners: Map>; + readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; + broadcast(conversationId: string, event: AgentEvent): void; } { - const listeners = new Map>(); - const enqueueCalls: { conversationId: string; text: string }[] = []; - - return { - listeners, - enqueueCalls, - broadcast(conversationId, event) { - const set = listeners.get(conversationId); - if (set) { - for (const listener of set) { - listener(event); - } - } - }, - startTurn(_input) { - return { started: true, turnId: "fake-turn-id" }; - }, - enqueue(input) { - enqueueCalls.push({ - conversationId: input.conversationId, - text: input.text, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - return { startedTurn: true, queue: [] }; - }, - subscribe(conversationId, listener) { - let set = listeners.get(conversationId); - if (!set) { - set = new Set(); - listeners.set(conversationId, set); - } - set.add(listener); - return () => { - set.delete(listener); - }; - }, - isActive(conversationId) { - return listeners.has(conversationId); - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(_input) {}, - }; + const listeners = new Map>(); + const enqueueCalls: { conversationId: string; text: string }[] = []; + + return { + listeners, + enqueueCalls, + broadcast(conversationId, event) { + const set = listeners.get(conversationId); + if (set) { + for (const listener of set) { + listener(event); + } + } + }, + startTurn(_input) { + return { started: true, turnId: "fake-turn-id" }; + }, + enqueue(input) { + enqueueCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); + return { startedTurn: true, queue: [] }; + }, + subscribe(conversationId, listener) { + let set = listeners.get(conversationId); + if (!set) { + set = new Set(); + listeners.set(conversationId, set); + } + set.add(listener); + return () => { + set.delete(listener); + }; + }, + isActive(conversationId) { + return listeners.has(conversationId); + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(_input) {}, + }; } // ── Per-connection state (mirrors extension.ts) ───────────────────────────── interface ConnectionState { - readonly subs: Set; - readonly providerDisposers: Map void>; - readonly chatSubscriptions: Map void>; + readonly subs: Set; + readonly providerDisposers: Map void>; + readonly chatSubscriptions: Map void>; } // ── Server helper ─────────────────────────────────────────────────────────── function startServer( - registry: SurfaceRegistry, - orchestrator: SessionOrchestrator, - port = 0, - logger?: Logger, + registry: SurfaceRegistry, + orchestrator: SessionOrchestrator, + port = 0, + logger?: Logger, ) { - const log = logger ?? fakeLogger(); - const connections = new Set>(); - - /** Broadcast a message to every connected client (mirrors extension.ts). */ - function broadcast(msg: WsServerMessage): void { - for (const ws of connections) { - try { - ws.send(JSON.stringify(msg)); - } catch { - // Connection may have been dropped; swallow. - } - } - } - - const server = Bun.serve({ - port, - fetch(req, srv) { - const initial: ConnectionState = { - subs: new Set(), - providerDisposers: new Map(), - chatSubscriptions: new Map(), - }; - if (srv.upgrade(req, { data: initial })) return; - return new Response("expected websocket", { status: 426 }); - }, - websocket: { - open(ws) { - connections.add(ws); - log.debug("transport-ws: connection open"); - ws.send(JSON.stringify(catalogMessage(registry))); - }, - - message(ws, raw) { - const state = ws.data; - if (!state) return; - - let parsed: SurfaceClientMessage; - try { - parsed = JSON.parse(String(raw)) as SurfaceClientMessage; - } catch { - ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); - return; - } - - const result = routeClientMessage(registry, state.subs, parsed); - - switch (result.kind) { - case "surface": { - for (const reply of result.replies) { - if (reply.type === "error") { - log.warn?.("transport-ws: surface-op error", { - ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}), - reason: reply.message, - }); - } - } - - if (result.subChange) { - const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); - if (result.subChange.op === "add") { - state.subs.add(key); - } else { - state.subs.delete(key); - } - } - - for (const reply of result.replies) { - ws.send(JSON.stringify(reply)); - } - break; - } - - case "chat": { - const resolvedId = result.conversationId ?? crypto.randomUUID(); - // Auto-subscribe the sender. - if (!state.chatSubscriptions.has(resolvedId)) { - const unsubscribe = orchestrator.subscribe(resolvedId, (event) => { - ws.send(JSON.stringify({ type: "chat.delta", event })); - }); - state.chatSubscriptions.set(resolvedId, unsubscribe); - } - // Start the turn detached. - const startResult = orchestrator.startTurn({ - conversationId: resolvedId, - text: result.message, - ...(result.model !== undefined ? { modelName: result.model } : {}), - ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), - ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), - }); - if (!startResult.started) { - ws.send( - JSON.stringify({ - type: "chat.error", - conversationId: resolvedId, - message: "a turn is already generating for this conversation", - }), - ); - } else { - log.info?.("transport-ws: chat.send accepted", { - conversationId: resolvedId, - model: result.model ?? null, - }); - } - break; - } - - case "chat-subscribe": { - if (!state.chatSubscriptions.has(result.conversationId)) { - const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => { - ws.send(JSON.stringify({ type: "chat.delta", event })); - }); - state.chatSubscriptions.set(result.conversationId, unsubscribe); - } - break; - } - - case "chat-unsubscribe": { - const dispose = state.chatSubscriptions.get(result.conversationId); - if (dispose) { - dispose(); - state.chatSubscriptions.delete(result.conversationId); - } - break; - } - - case "chat-queue": { - // Mirror extension.ts: fire-and-forget. On startedTurn:true - // auto-subscribe the sender (deltas stream); on false emit - // nothing back. - const enqueueResult = orchestrator.enqueue({ - conversationId: result.conversationId, - text: result.text, - ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), - }); - if (enqueueResult.startedTurn) { - if (!state.chatSubscriptions.has(result.conversationId)) { - const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => { - ws.send(JSON.stringify({ type: "chat.delta", event })); - }); - state.chatSubscriptions.set(result.conversationId, unsubscribe); - } - } - log.info?.("transport-ws: chat.queue accepted", { - conversationId: result.conversationId, - startedTurn: enqueueResult.startedTurn, - }); - break; - } - - case "chat-error": { - log.warn?.("transport-ws: malformed chat.send", { - reason: result.errorMessage, - ...(result.conversationId !== undefined - ? { conversationId: result.conversationId } - : {}), - }); - ws.send( - JSON.stringify({ - type: "chat.error", - conversationId: result.conversationId, - message: result.errorMessage, - }), - ); - break; - } - } - }, - - close(ws) { - connections.delete(ws); - const state = ws.data; - if (state) { - for (const dispose of state.chatSubscriptions.values()) { - dispose(); - } - state.chatSubscriptions.clear(); - for (const dispose of state.providerDisposers.values()) { - dispose(); - } - } - log.debug("transport-ws: connection close"); - }, - }, - }); - - /** - * Simulate the `conversationOpened` hook firing — mirrors the - * `host.on(conversationOpened, ...)` subscription in extension.ts, which - * broadcasts a `conversation.open` WS message (carrying the conversation's - * persisted `workspaceId`) to every connected client. - */ - return Object.assign(server, { - triggerConversationOpen(conversationId: string, workspaceId: string): void { - broadcast({ type: "conversation.open", conversationId, workspaceId }); - }, - /** - * Simulate the `conversationStatusChanged` hook firing — mirrors the - * `host.on(conversationStatusChanged, ...)` subscription in extension.ts, - * which broadcasts a `conversation.statusChanged` WS message (carrying the - * conversation's persisted `workspaceId`) to every connected client. - */ - triggerConversationStatusChanged( - conversationId: string, - status: ConversationStatus, - workspaceId: string, - ): void { - broadcast({ - type: "conversation.statusChanged", - conversationId, - status, - workspaceId, - }); - }, - }); + const log = logger ?? fakeLogger(); + const connections = new Set>(); + + /** Broadcast a message to every connected client (mirrors extension.ts). */ + function broadcast(msg: WsServerMessage): void { + for (const ws of connections) { + try { + ws.send(JSON.stringify(msg)); + } catch { + // Connection may have been dropped; swallow. + } + } + } + + const server = Bun.serve({ + port, + fetch(req, srv) { + const initial: ConnectionState = { + subs: new Set(), + providerDisposers: new Map(), + chatSubscriptions: new Map(), + }; + if (srv.upgrade(req, { data: initial })) return; + return new Response("expected websocket", { status: 426 }); + }, + websocket: { + open(ws) { + connections.add(ws); + log.debug("transport-ws: connection open"); + ws.send(JSON.stringify(catalogMessage(registry))); + }, + + message(ws, raw) { + const state = ws.data; + if (!state) return; + + let parsed: SurfaceClientMessage; + try { + parsed = JSON.parse(String(raw)) as SurfaceClientMessage; + } catch { + ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + return; + } + + const result = routeClientMessage(registry, state.subs, parsed); + + switch (result.kind) { + case "surface": { + for (const reply of result.replies) { + if (reply.type === "error") { + log.warn?.("transport-ws: surface-op error", { + ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}), + reason: reply.message, + }); + } + } + + if (result.subChange) { + const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); + if (result.subChange.op === "add") { + state.subs.add(key); + } else { + state.subs.delete(key); + } + } + + for (const reply of result.replies) { + ws.send(JSON.stringify(reply)); + } + break; + } + + case "chat": { + const resolvedId = result.conversationId ?? crypto.randomUUID(); + // Auto-subscribe the sender. + if (!state.chatSubscriptions.has(resolvedId)) { + const unsubscribe = orchestrator.subscribe(resolvedId, (event) => { + ws.send(JSON.stringify({ type: "chat.delta", event })); + }); + state.chatSubscriptions.set(resolvedId, unsubscribe); + } + // Start the turn detached. + const startResult = orchestrator.startTurn({ + conversationId: resolvedId, + text: result.message, + ...(result.model !== undefined ? { modelName: result.model } : {}), + ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + }); + if (!startResult.started) { + ws.send( + JSON.stringify({ + type: "chat.error", + conversationId: resolvedId, + message: "a turn is already generating for this conversation", + }), + ); + } else { + log.info?.("transport-ws: chat.send accepted", { + conversationId: resolvedId, + model: result.model ?? null, + }); + } + break; + } + + case "chat-subscribe": { + if (!state.chatSubscriptions.has(result.conversationId)) { + const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => { + ws.send(JSON.stringify({ type: "chat.delta", event })); + }); + state.chatSubscriptions.set(result.conversationId, unsubscribe); + } + break; + } + + case "chat-unsubscribe": { + const dispose = state.chatSubscriptions.get(result.conversationId); + if (dispose) { + dispose(); + state.chatSubscriptions.delete(result.conversationId); + } + break; + } + + case "chat-queue": { + // Mirror extension.ts: fire-and-forget. On startedTurn:true + // auto-subscribe the sender (deltas stream); on false emit + // nothing back. + const enqueueResult = orchestrator.enqueue({ + conversationId: result.conversationId, + text: result.text, + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + }); + if (enqueueResult.startedTurn) { + if (!state.chatSubscriptions.has(result.conversationId)) { + const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => { + ws.send(JSON.stringify({ type: "chat.delta", event })); + }); + state.chatSubscriptions.set(result.conversationId, unsubscribe); + } + } + log.info?.("transport-ws: chat.queue accepted", { + conversationId: result.conversationId, + startedTurn: enqueueResult.startedTurn, + }); + break; + } + + case "chat-error": { + log.warn?.("transport-ws: malformed chat.send", { + reason: result.errorMessage, + ...(result.conversationId !== undefined + ? { conversationId: result.conversationId } + : {}), + }); + ws.send( + JSON.stringify({ + type: "chat.error", + conversationId: result.conversationId, + message: result.errorMessage, + }), + ); + break; + } + } + }, + + close(ws) { + connections.delete(ws); + const state = ws.data; + if (state) { + for (const dispose of state.chatSubscriptions.values()) { + dispose(); + } + state.chatSubscriptions.clear(); + for (const dispose of state.providerDisposers.values()) { + dispose(); + } + } + log.debug("transport-ws: connection close"); + }, + }, + }); + + /** + * Simulate the `conversationOpened` hook firing — mirrors the + * `host.on(conversationOpened, ...)` subscription in extension.ts, which + * broadcasts a `conversation.open` WS message (carrying the conversation's + * persisted `workspaceId`) to every connected client. + */ + return Object.assign(server, { + triggerConversationOpen(conversationId: string, workspaceId: string): void { + broadcast({ type: "conversation.open", conversationId, workspaceId }); + }, + /** + * Simulate the `conversationStatusChanged` hook firing — mirrors the + * `host.on(conversationStatusChanged, ...)` subscription in extension.ts, + * which broadcasts a `conversation.statusChanged` WS message (carrying the + * conversation's persisted `workspaceId`) to every connected client. + */ + triggerConversationStatusChanged( + conversationId: string, + status: ConversationStatus, + workspaceId: string, + ): void { + broadcast({ + type: "conversation.statusChanged", + conversationId, + status, + workspaceId, + }); + }, + }); } // ── Helpers ───────────────────────────────────────────────────────────────── function waitForMessage(ws: WebSocket): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000); - function handler(ev: MessageEvent) { - clearTimeout(timeout); - ws.removeEventListener("message", handler); - resolve(JSON.parse(ev.data as string) as WsServerMessage); - } - ws.addEventListener("message", handler); - }); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000); + function handler(ev: MessageEvent) { + clearTimeout(timeout); + ws.removeEventListener("message", handler); + resolve(JSON.parse(ev.data as string) as WsServerMessage); + } + ws.addEventListener("message", handler); + }); } function waitForMessages(ws: WebSocket, count: number): Promise { - return new Promise((resolve, reject) => { - const msgs: WsServerMessage[] = []; - const timeout = setTimeout( - () => reject(new Error(`timed out waiting for ${count} messages (got ${msgs.length})`)), - 5000, - ); - function handler(ev: MessageEvent) { - msgs.push(JSON.parse(ev.data as string) as WsServerMessage); - if (msgs.length === count) { - clearTimeout(timeout); - ws.removeEventListener("message", handler); - resolve(msgs); - } - } - ws.addEventListener("message", handler); - }); + return new Promise((resolve, reject) => { + const msgs: WsServerMessage[] = []; + const timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${count} messages (got ${msgs.length})`)), + 5000, + ); + function handler(ev: MessageEvent) { + msgs.push(JSON.parse(ev.data as string) as WsServerMessage); + if (msgs.length === count) { + clearTimeout(timeout); + ws.removeEventListener("message", handler); + resolve(msgs); + } + } + ws.addEventListener("message", handler); + }); } // ── Tests ─────────────────────────────────────────────────────────────────── describe("Bun.serve WebSocket server", () => { - let server: ReturnType; - let port: number; - const defaultOrchestrator = fakeOrchestrator(); - - beforeEach(() => { - const provider = fakeProvider("demo", "Demo Surface"); - const registry = fakeRegistry([provider]); - server = startServer(registry, defaultOrchestrator); - port = server.port as number; - }); - - afterEach(() => { - server.stop(); - }); - - test("performs WebSocket upgrade (returns 101)", async () => { - const ws = new WebSocket(`ws://localhost:${port}`); - const msg = await waitForMessage(ws); - expect(msg.type).toBe("catalog"); - ws.close(); - }); - - test("sends catalog on open", async () => { - const ws = new WebSocket(`ws://localhost:${port}`); - const msg = await waitForMessage(ws); - expect(msg).toEqual({ - type: "catalog", - catalog: [{ id: "demo", region: "default", title: "Demo Surface" }], - }); - ws.close(); - }); - - test("subscribe returns surface spec", async () => { - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" })); - const msg = await waitForMessage(ws); - - expect(msg.type).toBe("surface"); - if (msg.type === "surface") { - expect(msg.spec.id).toBe("demo"); - expect(msg.spec.title).toBe("Demo Surface"); - } - ws.close(); - }); - - test("subscribe to unknown surface returns error", async () => { - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" })); - const msg = await waitForMessage(ws); - - expect(msg).toEqual({ - type: "error", - surfaceId: "nope", - message: "Unknown surface: nope", - }); - ws.close(); - }); - - test("non-WebSocket request returns 426", async () => { - const res = await fetch(`http://localhost:${port}/`); - expect(res.status).toBe(426); - expect(await res.text()).toBe("expected websocket"); - }); + let server: ReturnType; + let port: number; + const defaultOrchestrator = fakeOrchestrator(); + + beforeEach(() => { + const provider = fakeProvider("demo", "Demo Surface"); + const registry = fakeRegistry([provider]); + server = startServer(registry, defaultOrchestrator); + port = server.port as number; + }); + + afterEach(() => { + server.stop(); + }); + + test("performs WebSocket upgrade (returns 101)", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + const msg = await waitForMessage(ws); + expect(msg.type).toBe("catalog"); + ws.close(); + }); + + test("sends catalog on open", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + const msg = await waitForMessage(ws); + expect(msg).toEqual({ + type: "catalog", + catalog: [{ id: "demo", region: "default", title: "Demo Surface" }], + }); + ws.close(); + }); + + test("subscribe returns surface spec", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" })); + const msg = await waitForMessage(ws); + + expect(msg.type).toBe("surface"); + if (msg.type === "surface") { + expect(msg.spec.id).toBe("demo"); + expect(msg.spec.title).toBe("Demo Surface"); + } + ws.close(); + }); + + test("subscribe to unknown surface returns error", async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" })); + const msg = await waitForMessage(ws); + + expect(msg).toEqual({ + type: "error", + surfaceId: "nope", + message: "Unknown surface: nope", + }); + ws.close(); + }); + + test("non-WebSocket request returns 426", async () => { + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(426); + expect(await res.text()).toBe("expected websocket"); + }); }); describe("chat ops (new orchestrator API)", () => { - let server: ReturnType; - let port: number; - - afterEach(() => { - server.stop(); - }); - - test("chat.send auto-subscribes the sender and delivers deltas via orchestrator.subscribe broadcast", async () => { - const orch = fakeOrchestratorWithBroadcast(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); - - // Give the message handler time to run. - await new Promise((r) => setTimeout(r, 50)); - - // The sender should be auto-subscribed. Broadcast an event. - const event = { - type: "text-delta", - conversationId: "c1", - turnId: "t1", - delta: "Hello", - } as AgentEvent; - orch.broadcast("c1", event); - - const msg = await waitForMessage(ws); - expect(msg.type).toBe("chat.delta"); - if (msg.type === "chat.delta") { - expect(msg.event).toEqual(event); - } - - ws.close(); - }); - - test("chat.send with already-active turn sends chat.error but keeps subscription", async () => { - const orch = fakeOrchestrator({ alreadyActive: true }); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); - const errMsg = await waitForMessage(ws); - - expect(errMsg.type).toBe("chat.error"); - if (errMsg.type === "chat.error") { - expect(errMsg.message).toBe("a turn is already generating for this conversation"); - expect(errMsg.conversationId).toBe("c1"); - } - - ws.close(); - }); - - test("chat.send threads workspaceId — orchestrator receives it", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send( - JSON.stringify({ - type: "chat.send", - conversationId: "c1", - message: "hello workspace", - workspaceId: "my-workspace", - }), - ); - await new Promise((r) => setTimeout(r, 50)); - - expect(orch.startCalls).toHaveLength(1); - expect(orch.startCalls[0]?.workspaceId).toBe("my-workspace"); - - ws.close(); - }); - - test("chat.send defaults workspaceId when omitted — orchestrator receives undefined", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send( - JSON.stringify({ - type: "chat.send", - conversationId: "c1", - message: "hello no workspace", - }), - ); - await new Promise((r) => setTimeout(r, 50)); - - expect(orch.startCalls).toHaveLength(1); - expect(orch.startCalls[0]).not.toHaveProperty("workspaceId"); - - ws.close(); - }); - - test("multi-client fan-out — two connections both subscribe the same conversation", async () => { - const orch = fakeOrchestratorWithBroadcast(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws1 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws1); // drain catalog - const ws2 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws2); // drain catalog - - // Both subscribe to the same conversation. - ws1.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" })); - ws2.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" })); - await new Promise((r) => setTimeout(r, 50)); - - // Broadcast an event. - const event = { - type: "text-delta", - conversationId: "shared-conv", - turnId: "t1", - delta: "Hi both", - } as AgentEvent; - orch.broadcast("shared-conv", event); - - const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); - - expect(msg1.type).toBe("chat.delta"); - expect(msg2.type).toBe("chat.delta"); - if (msg1.type === "chat.delta" && msg2.type === "chat.delta") { - expect(msg1.event).toEqual(event); - expect(msg2.event).toEqual(event); - } - - ws1.close(); - ws2.close(); - }); - - test("disconnect does NOT abort the turn", async () => { - const orch = fakeOrchestratorWithBroadcast(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - // Start a turn. - ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); - await new Promise((r) => setTimeout(r, 50)); - - // Close the socket. - ws.close(); - await new Promise((r) => setTimeout(r, 50)); - - // The turn should still be "running" — the orchestrator was never told to abort. - // Broadcast a post-close event; the fake still has the listener set (real orchestrator - // would too until turn-sealed). We just verify no abort was invoked. - // The fakeOrchestratorWithBroadcast has no abort mechanism — that's the point: - // the transport never calls abort on disconnect. - expect(true).toBe(true); // If we got here, no abort was attempted. - }); - - test("late-join replay forwarded — subscribe mid-turn receives buffered events", async () => { - const bufferedEvents: AgentEvent[] = [ - { type: "turn-start", conversationId: "c1", turnId: "t1" } as AgentEvent, - { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "Hel" } as AgentEvent, - ]; - const bufferedMap = new Map(); - bufferedMap.set("c1", bufferedEvents); - - const orch = fakeOrchestrator({ bufferedEvents: bufferedMap }); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - // Subscribe mid-turn — the fake orchestrator replays buffered events. - ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: "c1" })); - - const msgs = await waitForMessages(ws, bufferedEvents.length); - - for (let i = 0; i < bufferedEvents.length; i++) { - const msg = msgs[i]; - const expected = bufferedEvents[i]; - if (!msg || !expected) throw new Error(`missing at index ${i}`); - expect(msg.type).toBe("chat.delta"); - if (msg.type === "chat.delta") { - expect(msg.event).toEqual(expected); - } - } - - ws.close(); - }); - - test("chat.send auto-subscribes the sender — deltas arrive without separate chat.subscribe", async () => { - const orch = fakeOrchestratorWithBroadcast(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - // Send without a separate chat.subscribe. - ws.send(JSON.stringify({ type: "chat.send", conversationId: "auto-conv", message: "go" })); - await new Promise((r) => setTimeout(r, 50)); - - // Broadcast should reach the sender. - const event = { type: "done", conversationId: "auto-conv", turnId: "t1" } as AgentEvent; - orch.broadcast("auto-conv", event); - - const msg = await waitForMessage(ws); - expect(msg.type).toBe("chat.delta"); - if (msg.type === "chat.delta") { - expect(msg.event).toEqual(event); - } - - ws.close(); - }); + let server: ReturnType; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("chat.send auto-subscribes the sender and delivers deltas via orchestrator.subscribe broadcast", async () => { + const orch = fakeOrchestratorWithBroadcast(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); + + // Give the message handler time to run. + await new Promise((r) => setTimeout(r, 50)); + + // The sender should be auto-subscribed. Broadcast an event. + const event = { + type: "text-delta", + conversationId: "c1", + turnId: "t1", + delta: "Hello", + } as AgentEvent; + orch.broadcast("c1", event); + + const msg = await waitForMessage(ws); + expect(msg.type).toBe("chat.delta"); + if (msg.type === "chat.delta") { + expect(msg.event).toEqual(event); + } + + ws.close(); + }); + + test("chat.send with already-active turn sends chat.error but keeps subscription", async () => { + const orch = fakeOrchestrator({ alreadyActive: true }); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); + const errMsg = await waitForMessage(ws); + + expect(errMsg.type).toBe("chat.error"); + if (errMsg.type === "chat.error") { + expect(errMsg.message).toBe("a turn is already generating for this conversation"); + expect(errMsg.conversationId).toBe("c1"); + } + + ws.close(); + }); + + test("chat.send threads workspaceId — orchestrator receives it", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.send", + conversationId: "c1", + message: "hello workspace", + workspaceId: "my-workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.startCalls).toHaveLength(1); + expect(orch.startCalls[0]?.workspaceId).toBe("my-workspace"); + + ws.close(); + }); + + test("chat.send defaults workspaceId when omitted — orchestrator receives undefined", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.send", + conversationId: "c1", + message: "hello no workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.startCalls).toHaveLength(1); + expect(orch.startCalls[0]).not.toHaveProperty("workspaceId"); + + ws.close(); + }); + + test("multi-client fan-out — two connections both subscribe the same conversation", async () => { + const orch = fakeOrchestratorWithBroadcast(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws1 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws1); // drain catalog + const ws2 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws2); // drain catalog + + // Both subscribe to the same conversation. + ws1.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" })); + ws2.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" })); + await new Promise((r) => setTimeout(r, 50)); + + // Broadcast an event. + const event = { + type: "text-delta", + conversationId: "shared-conv", + turnId: "t1", + delta: "Hi both", + } as AgentEvent; + orch.broadcast("shared-conv", event); + + const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); + + expect(msg1.type).toBe("chat.delta"); + expect(msg2.type).toBe("chat.delta"); + if (msg1.type === "chat.delta" && msg2.type === "chat.delta") { + expect(msg1.event).toEqual(event); + expect(msg2.event).toEqual(event); + } + + ws1.close(); + ws2.close(); + }); + + test("disconnect does NOT abort the turn", async () => { + const orch = fakeOrchestratorWithBroadcast(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Start a turn. + ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); + await new Promise((r) => setTimeout(r, 50)); + + // Close the socket. + ws.close(); + await new Promise((r) => setTimeout(r, 50)); + + // The turn should still be "running" — the orchestrator was never told to abort. + // Broadcast a post-close event; the fake still has the listener set (real orchestrator + // would too until turn-sealed). We just verify no abort was invoked. + // The fakeOrchestratorWithBroadcast has no abort mechanism — that's the point: + // the transport never calls abort on disconnect. + expect(true).toBe(true); // If we got here, no abort was attempted. + }); + + test("late-join replay forwarded — subscribe mid-turn receives buffered events", async () => { + const bufferedEvents: AgentEvent[] = [ + { type: "turn-start", conversationId: "c1", turnId: "t1" } as AgentEvent, + { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "Hel" } as AgentEvent, + ]; + const bufferedMap = new Map(); + bufferedMap.set("c1", bufferedEvents); + + const orch = fakeOrchestrator({ bufferedEvents: bufferedMap }); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Subscribe mid-turn — the fake orchestrator replays buffered events. + ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: "c1" })); + + const msgs = await waitForMessages(ws, bufferedEvents.length); + + for (let i = 0; i < bufferedEvents.length; i++) { + const msg = msgs[i]; + const expected = bufferedEvents[i]; + if (!msg || !expected) throw new Error(`missing at index ${i}`); + expect(msg.type).toBe("chat.delta"); + if (msg.type === "chat.delta") { + expect(msg.event).toEqual(expected); + } + } + + ws.close(); + }); + + test("chat.send auto-subscribes the sender — deltas arrive without separate chat.subscribe", async () => { + const orch = fakeOrchestratorWithBroadcast(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Send without a separate chat.subscribe. + ws.send(JSON.stringify({ type: "chat.send", conversationId: "auto-conv", message: "go" })); + await new Promise((r) => setTimeout(r, 50)); + + // Broadcast should reach the sender. + const event = { type: "done", conversationId: "auto-conv", turnId: "t1" } as AgentEvent; + orch.broadcast("auto-conv", event); + + const msg = await waitForMessage(ws); + expect(msg.type).toBe("chat.delta"); + if (msg.type === "chat.delta") { + expect(msg.event).toEqual(event); + } + + ws.close(); + }); }); describe("chat.queue (steering enqueue)", () => { - let server: ReturnType; - let port: number; - - afterEach(() => { - server.stop(); - }); - - test("chat.queue with valid text → orchestrator.enqueue called with {conversationId, text}, no reply sent", async () => { - const orch = fakeOrchestrator(); // idle → startedTurn:true - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "steer please" })); - // Allow the message handler to run. - await new Promise((r) => setTimeout(r, 50)); - - expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer please" }]); - // chat.send-path equivalence: enqueue NOT called via startTurn. - expect(orch.startCalls).toHaveLength(0); - // Fire-and-forget: no chat.error, no ack — only the catalog was sent. - // (startedTurn:true path auto-subscribes but emits nothing itself.) - - ws.close(); - }); - - test("chat.queue threads workspaceId — orchestrator receives it", async () => { - const orch = fakeOrchestrator(); // idle → startedTurn:true - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send( - JSON.stringify({ - type: "chat.queue", - conversationId: "c1", - text: "steer here", - workspaceId: "my-workspace", - }), - ); - await new Promise((r) => setTimeout(r, 50)); - - expect(orch.enqueueCalls).toEqual([ - { conversationId: "c1", text: "steer here", workspaceId: "my-workspace" }, - ]); - - ws.close(); - }); - - test("chat.queue on startedTurn:true auto-subscribes the sender (deltas stream as chat.delta)", async () => { - const orch = fakeOrchestratorWithBroadcast(); // enqueue → startedTurn:true - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "go" })); - await new Promise((r) => setTimeout(r, 50)); - - // The sender was auto-subscribed — a broadcast reaches it as a chat.delta. - const event = { - type: "text-delta", - conversationId: "c1", - turnId: "t1", - delta: "Hi", - } as AgentEvent; - orch.broadcast("c1", event); - - const msg = await waitForMessage(ws); - expect(msg.type).toBe("chat.delta"); - if (msg.type === "chat.delta") { - expect(msg.event).toEqual(event); - } - - ws.close(); - }); - - test("chat.queue on startedTurn:false (queued for steering) emits NOTHING back", async () => { - const orch = fakeOrchestrator({ queueActive: true }); // enqueue → startedTurn:false - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "steer" })); - await new Promise((r) => setTimeout(r, 50)); - - expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer" }]); - // No further message should arrive within a quiet window: success is - // confirmed by the message-queue SURFACE, not a reply here. We assert - // by NOT receiving anything (a silent socket). - await expect( - Promise.race([ - waitForMessage(ws).then((m) => new Error(`unexpected reply: ${JSON.stringify(m)}`)), - new Promise((resolve) => setTimeout(() => resolve("silent"), 150)), - ]), - ).resolves.toBe("silent"); - - ws.close(); - }); - - test("chat.queue with empty text → chat.error to client, no enqueue", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: " " })); - const errMsg = await waitForMessage(ws); - - expect(errMsg.type).toBe("chat.error"); - if (errMsg.type === "chat.error") { - expect(errMsg.conversationId).toBe("c1"); - expect(errMsg.message).toContain("non-empty string"); - } - expect(orch.enqueueCalls).toHaveLength(0); - - ws.close(); - }); - - test("chat.queue with missing text → chat.error to client, no enqueue", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1" })); - const errMsg = await waitForMessage(ws); - - expect(errMsg.type).toBe("chat.error"); - if (errMsg.type === "chat.error") { - expect(errMsg.message).toContain("non-empty string"); - } - expect(orch.enqueueCalls).toHaveLength(0); - - ws.close(); - }); + let server: ReturnType; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("chat.queue with valid text → orchestrator.enqueue called with {conversationId, text}, no reply sent", async () => { + const orch = fakeOrchestrator(); // idle → startedTurn:true + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "steer please" })); + // Allow the message handler to run. + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer please" }]); + // chat.send-path equivalence: enqueue NOT called via startTurn. + expect(orch.startCalls).toHaveLength(0); + // Fire-and-forget: no chat.error, no ack — only the catalog was sent. + // (startedTurn:true path auto-subscribes but emits nothing itself.) + + ws.close(); + }); + + test("chat.queue threads workspaceId — orchestrator receives it", async () => { + const orch = fakeOrchestrator(); // idle → startedTurn:true + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.queue", + conversationId: "c1", + text: "steer here", + workspaceId: "my-workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.enqueueCalls).toEqual([ + { conversationId: "c1", text: "steer here", workspaceId: "my-workspace" }, + ]); + + ws.close(); + }); + + test("chat.queue on startedTurn:true auto-subscribes the sender (deltas stream as chat.delta)", async () => { + const orch = fakeOrchestratorWithBroadcast(); // enqueue → startedTurn:true + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "go" })); + await new Promise((r) => setTimeout(r, 50)); + + // The sender was auto-subscribed — a broadcast reaches it as a chat.delta. + const event = { + type: "text-delta", + conversationId: "c1", + turnId: "t1", + delta: "Hi", + } as AgentEvent; + orch.broadcast("c1", event); + + const msg = await waitForMessage(ws); + expect(msg.type).toBe("chat.delta"); + if (msg.type === "chat.delta") { + expect(msg.event).toEqual(event); + } + + ws.close(); + }); + + test("chat.queue on startedTurn:false (queued for steering) emits NOTHING back", async () => { + const orch = fakeOrchestrator({ queueActive: true }); // enqueue → startedTurn:false + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: "steer" })); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer" }]); + // No further message should arrive within a quiet window: success is + // confirmed by the message-queue SURFACE, not a reply here. We assert + // by NOT receiving anything (a silent socket). + await expect( + Promise.race([ + waitForMessage(ws).then((m) => new Error(`unexpected reply: ${JSON.stringify(m)}`)), + new Promise((resolve) => setTimeout(() => resolve("silent"), 150)), + ]), + ).resolves.toBe("silent"); + + ws.close(); + }); + + test("chat.queue with empty text → chat.error to client, no enqueue", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1", text: " " })); + const errMsg = await waitForMessage(ws); + + expect(errMsg.type).toBe("chat.error"); + if (errMsg.type === "chat.error") { + expect(errMsg.conversationId).toBe("c1"); + expect(errMsg.message).toContain("non-empty string"); + } + expect(orch.enqueueCalls).toHaveLength(0); + + ws.close(); + }); + + test("chat.queue with missing text → chat.error to client, no enqueue", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.queue", conversationId: "c1" })); + const errMsg = await waitForMessage(ws); + + expect(errMsg.type).toBe("chat.error"); + if (errMsg.type === "chat.error") { + expect(errMsg.message).toContain("non-empty string"); + } + expect(orch.enqueueCalls).toHaveLength(0); + + ws.close(); + }); }); describe("logging", () => { - let server: ReturnType; - let port: number; - - afterEach(() => { - server.stop(); - }); - - test("logs a warn on a surface-op error", async () => { - const logger = fakeLogger(); - const registry = fakeRegistry([]); - server = startServer(registry, fakeOrchestrator(), 0, logger); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nonexistent" })); - await waitForMessage(ws); // drain error reply - ws.close(); - // Allow close handler to run - await new Promise((r) => setTimeout(r, 50)); - - const surfaceErrors = logger.entries.filter( - (e) => e.level === "warn" && e.msg === "transport-ws: surface-op error", - ); - expect(surfaceErrors.length).toBeGreaterThanOrEqual(1); - expect(surfaceErrors[0]?.attrs).toMatchObject({ - surfaceId: "nonexistent", - reason: "Unknown surface: nonexistent", - }); - }); - - test("logs an info when a chat.send is accepted", async () => { - const logger = fakeLogger(); - const orch = fakeOrchestrator(); - const registry = fakeRegistry([]); - server = startServer(registry, orch, 0, logger); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send( - JSON.stringify({ - type: "chat.send", - conversationId: "conv-42", - message: "hello", - model: "gpt-4", - }), - ); - // Wait for the message handler to run - await new Promise((r) => setTimeout(r, 100)); - ws.close(); - await new Promise((r) => setTimeout(r, 50)); - - const accepted = logger.entries.filter( - (e) => e.level === "info" && e.msg === "transport-ws: chat.send accepted", - ); - expect(accepted).toHaveLength(1); - expect(accepted[0]?.attrs).toMatchObject({ - conversationId: "conv-42", - model: "gpt-4", - }); - }); - - test("logs a warn on a malformed chat.send", async () => { - const logger = fakeLogger(); - const registry = fakeRegistry([]); - server = startServer(registry, fakeOrchestrator(), 0, logger); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.send", message: "" })); - await waitForMessage(ws); // drain chat.error reply - ws.close(); - await new Promise((r) => setTimeout(r, 50)); - - const malformed = logger.entries.filter( - (e) => e.level === "warn" && e.msg === "transport-ws: malformed chat.send", - ); - expect(malformed).toHaveLength(1); - expect(malformed[0]?.attrs).toMatchObject({ - reason: "chat.send requires a non-empty string `message`", - }); - }); - - test("does not log 'in-flight turn aborted' on close", async () => { - const logger = fakeLogger(); - const orch = fakeOrchestratorWithBroadcast(); - const registry = fakeRegistry([]); - server = startServer(registry, orch, 0, logger); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); - await new Promise((r) => setTimeout(r, 50)); - ws.close(); - await new Promise((r) => setTimeout(r, 50)); - - const abortLogs = logger.entries.filter( - (e) => e.msg.includes("aborted") || e.msg.includes("abort"), - ); - expect(abortLogs).toHaveLength(0); - }); + let server: ReturnType; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("logs a warn on a surface-op error", async () => { + const logger = fakeLogger(); + const registry = fakeRegistry([]); + server = startServer(registry, fakeOrchestrator(), 0, logger); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nonexistent" })); + await waitForMessage(ws); // drain error reply + ws.close(); + // Allow close handler to run + await new Promise((r) => setTimeout(r, 50)); + + const surfaceErrors = logger.entries.filter( + (e) => e.level === "warn" && e.msg === "transport-ws: surface-op error", + ); + expect(surfaceErrors.length).toBeGreaterThanOrEqual(1); + expect(surfaceErrors[0]?.attrs).toMatchObject({ + surfaceId: "nonexistent", + reason: "Unknown surface: nonexistent", + }); + }); + + test("logs an info when a chat.send is accepted", async () => { + const logger = fakeLogger(); + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch, 0, logger); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.send", + conversationId: "conv-42", + message: "hello", + model: "gpt-4", + }), + ); + // Wait for the message handler to run + await new Promise((r) => setTimeout(r, 100)); + ws.close(); + await new Promise((r) => setTimeout(r, 50)); + + const accepted = logger.entries.filter( + (e) => e.level === "info" && e.msg === "transport-ws: chat.send accepted", + ); + expect(accepted).toHaveLength(1); + expect(accepted[0]?.attrs).toMatchObject({ + conversationId: "conv-42", + model: "gpt-4", + }); + }); + + test("logs a warn on a malformed chat.send", async () => { + const logger = fakeLogger(); + const registry = fakeRegistry([]); + server = startServer(registry, fakeOrchestrator(), 0, logger); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.send", message: "" })); + await waitForMessage(ws); // drain chat.error reply + ws.close(); + await new Promise((r) => setTimeout(r, 50)); + + const malformed = logger.entries.filter( + (e) => e.level === "warn" && e.msg === "transport-ws: malformed chat.send", + ); + expect(malformed).toHaveLength(1); + expect(malformed[0]?.attrs).toMatchObject({ + reason: "chat.send requires a non-empty string `message`", + }); + }); + + test("does not log 'in-flight turn aborted' on close", async () => { + const logger = fakeLogger(); + const orch = fakeOrchestratorWithBroadcast(); + const registry = fakeRegistry([]); + server = startServer(registry, orch, 0, logger); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" })); + await new Promise((r) => setTimeout(r, 50)); + ws.close(); + await new Promise((r) => setTimeout(r, 50)); + + const abortLogs = logger.entries.filter( + (e) => e.msg.includes("aborted") || e.msg.includes("abort"), + ); + expect(abortLogs).toHaveLength(0); + }); }); describe("conversation.open broadcast (conversationOpened hook)", () => { - let server: ReturnType; - let port: number; - - afterEach(() => { - server.stop(); - }); - - test("conversation.open broadcast on conversationOpened hook", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - // Simulate the conversationOpened hook firing (extension.ts's - // `host.on(conversationOpened, ...)` handler runs and broadcasts). - server.triggerConversationOpen("conv-42", "ws-7"); - - const msg = await waitForMessage(ws); - expect(msg).toEqual({ - type: "conversation.open", - conversationId: "conv-42", - workspaceId: "ws-7", - }); - - ws.close(); - }); - - test("conversation.open sent to all connected clients", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); - server = startServer(registry, orch); - port = server.port as number; - - const ws1 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws1); // drain catalog - const ws2 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws2); // drain catalog - - // Global fan-out: BOTH connected clients receive the broadcast, - // regardless of any per-conversation subscription state. The forwarded - // `workspaceId` is identical on both. - server.triggerConversationOpen("shared-conv", "ws-shared"); - - const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); - expect(msg1).toEqual({ - type: "conversation.open", - conversationId: "shared-conv", - workspaceId: "ws-shared", - }); - expect(msg2).toEqual({ - type: "conversation.open", - conversationId: "shared-conv", - workspaceId: "ws-shared", - }); - - ws1.close(); - ws2.close(); - }); + let server: ReturnType; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("conversation.open broadcast on conversationOpened hook", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Simulate the conversationOpened hook firing (extension.ts's + // `host.on(conversationOpened, ...)` handler runs and broadcasts). + server.triggerConversationOpen("conv-42", "ws-7"); + + const msg = await waitForMessage(ws); + expect(msg).toEqual({ + type: "conversation.open", + conversationId: "conv-42", + workspaceId: "ws-7", + }); + + ws.close(); + }); + + test("conversation.open sent to all connected clients", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws1 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws1); // drain catalog + const ws2 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws2); // drain catalog + + // Global fan-out: BOTH connected clients receive the broadcast, + // regardless of any per-conversation subscription state. The forwarded + // `workspaceId` is identical on both. + server.triggerConversationOpen("shared-conv", "ws-shared"); + + const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); + expect(msg1).toEqual({ + type: "conversation.open", + conversationId: "shared-conv", + workspaceId: "ws-shared", + }); + expect(msg2).toEqual({ + type: "conversation.open", + conversationId: "shared-conv", + workspaceId: "ws-shared", + }); + + ws1.close(); + ws2.close(); + }); }); describe("conversation.statusChanged broadcast (conversationStatusChanged hook)", () => { - let server: ReturnType; - let port: number; - - afterEach(() => { - server.stop(); - }); - - test("conversation.statusChanged broadcast forwards workspaceId", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); - server = startServer(registry, orch); - port = server.port as number; - - const ws = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws); // drain catalog - - // Simulate the conversationStatusChanged hook firing (extension.ts's - // `host.on(conversationStatusChanged, ...)` handler runs and broadcasts). - server.triggerConversationStatusChanged("conv-9", "active", "ws-9"); - - const msg = await waitForMessage(ws); - expect(msg).toEqual({ - type: "conversation.statusChanged", - conversationId: "conv-9", - status: "active", - workspaceId: "ws-9", - }); - - ws.close(); - }); - - test("conversation.statusChanged sent to all connected clients with the same workspaceId", async () => { - const orch = fakeOrchestrator(); - const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); - server = startServer(registry, orch); - port = server.port as number; - - const ws1 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws1); // drain catalog - const ws2 = new WebSocket(`ws://localhost:${port}`); - await waitForMessage(ws2); // drain catalog - - // Global fan-out: BOTH connected clients receive the broadcast with the - // conversation's persisted workspaceId forwarded unchanged. - server.triggerConversationStatusChanged("shared-conv", "idle", "ws-shared"); - - const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); - expect(msg1).toEqual({ - type: "conversation.statusChanged", - conversationId: "shared-conv", - status: "idle", - workspaceId: "ws-shared", - }); - expect(msg2).toEqual({ - type: "conversation.statusChanged", - conversationId: "shared-conv", - status: "idle", - workspaceId: "ws-shared", - }); - - ws1.close(); - ws2.close(); - }); + let server: ReturnType; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("conversation.statusChanged broadcast forwards workspaceId", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Simulate the conversationStatusChanged hook firing (extension.ts's + // `host.on(conversationStatusChanged, ...)` handler runs and broadcasts). + server.triggerConversationStatusChanged("conv-9", "active", "ws-9"); + + const msg = await waitForMessage(ws); + expect(msg).toEqual({ + type: "conversation.statusChanged", + conversationId: "conv-9", + status: "active", + workspaceId: "ws-9", + }); + + ws.close(); + }); + + test("conversation.statusChanged sent to all connected clients with the same workspaceId", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws1 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws1); // drain catalog + const ws2 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws2); // drain catalog + + // Global fan-out: BOTH connected clients receive the broadcast with the + // conversation's persisted workspaceId forwarded unchanged. + server.triggerConversationStatusChanged("shared-conv", "idle", "ws-shared"); + + const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); + expect(msg1).toEqual({ + type: "conversation.statusChanged", + conversationId: "shared-conv", + status: "idle", + workspaceId: "ws-shared", + }); + expect(msg2).toEqual({ + type: "conversation.statusChanged", + conversationId: "shared-conv", + status: "idle", + workspaceId: "ws-shared", + }); + + ws1.close(); + ws2.close(); + }); }); diff --git a/packages/transport-ws/tsconfig.json b/packages/transport-ws/tsconfig.json index 2a1d7ab..c861681 100644 --- a/packages/transport-ws/tsconfig.json +++ b/packages/transport-ws/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../session-orchestrator" }, - { "path": "../surface-registry" }, - { "path": "../transport-contract" }, - { "path": "../ui-contract" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../session-orchestrator" }, + { "path": "../surface-registry" }, + { "path": "../transport-contract" }, + { "path": "../ui-contract" } + ] } diff --git a/packages/ui-contract/package.json b/packages/ui-contract/package.json index 3fef8a5..ba8ecfa 100644 --- a/packages/ui-contract/package.json +++ b/packages/ui-contract/package.json @@ -1,8 +1,8 @@ { - "name": "@dispatch/ui-contract", - "version": "0.2.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts" + "name": "@dispatch/ui-contract", + "version": "0.2.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts" } diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts index 16d13c8..218ad6f 100644 --- a/packages/ui-contract/src/index.ts +++ b/packages/ui-contract/src/index.ts @@ -27,13 +27,13 @@ export type Region = string; * may unify `command` → `action`; see `notes/restructure-plan.md` §8.) */ export interface ActionRef { - readonly actionId: string; + readonly actionId: string; } /** One selectable option in a `selector` field. */ export interface SurfaceOption { - readonly value: string; - readonly label: string; + readonly value: string; + readonly label: string; } /** @@ -42,43 +42,43 @@ export interface SurfaceOption { * hints; the contract is the data shape. */ export type SurfaceField = - | ToggleField - | ProgressField - | SelectorField - | StatField - | NumberField - | ButtonField - | CustomField; + | ToggleField + | ProgressField + | SelectorField + | StatField + | NumberField + | ButtonField + | CustomField; /** A boolean setting plus the action that flips it. */ export interface ToggleField { - readonly kind: "toggle"; - readonly label: string; - readonly value: boolean; - readonly action: ActionRef; + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; } /** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */ export interface ProgressField { - readonly kind: "progress"; - readonly label: string; - readonly value: number; + readonly kind: "progress"; + readonly label: string; + readonly value: number; } /** An enum choice: the current value, the options, and the action that sets it. */ export interface SelectorField { - readonly kind: "selector"; - readonly label: string; - readonly value: string; - readonly options: readonly SurfaceOption[]; - readonly action: ActionRef; + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; } /** A read-only labelled scalar readout. */ export interface StatField { - readonly kind: "stat"; - readonly label: string; - readonly value: string; + readonly kind: "stat"; + readonly label: string; + readonly value: string; } /** @@ -89,21 +89,21 @@ export interface StatField { * payload. Unlike `progress`/`stat` (read-only), this field is interactive. */ export interface NumberField { - readonly kind: "number"; - readonly label: string; - readonly value: number; - readonly min?: number; - readonly max?: number; - readonly step?: number; - readonly unit?: string; - readonly action: ActionRef; + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; } /** A labelled action trigger. */ export interface ButtonField { - readonly kind: "button"; - readonly label: string; - readonly action: ActionRef; + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; } /** @@ -114,9 +114,9 @@ export interface ButtonField { * (not a blind `unknown`). */ export interface CustomField { - readonly kind: "custom"; - readonly rendererId: string; - readonly payload: unknown; + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; } /** @@ -124,10 +124,10 @@ export interface CustomField { * unit a backend extension contributes and a client renders. */ export interface SurfaceSpec { - readonly id: string; - readonly region: Region; - readonly title: string; - readonly fields: readonly SurfaceField[]; + readonly id: string; + readonly region: Region; + readonly title: string; + readonly fields: readonly SurfaceField[]; } /** @@ -136,17 +136,17 @@ export interface SurfaceSpec { * `GET /surfaces/:id`. */ export interface SurfaceCatalogEntry { - readonly id: string; - readonly region: Region; - readonly title: string; - /** - * Whether the surface's spec/values differ per conversation ("conversation") - * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces - * on a conversation switch (they ignore `conversationId`). Optional + additive: - * when absent, a client should assume conversation-scoped (the conservative - * "always send the focused conversationId" policy still works for both). - */ - readonly scope?: "global" | "conversation"; + readonly id: string; + readonly region: Region; + readonly title: string; + /** + * Whether the surface's spec/values differ per conversation ("conversation") + * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces + * on a conversation switch (they ignore `conversationId`). Optional + additive: + * when absent, a client should assume conversation-scoped (the conservative + * "always send the focused conversationId" policy still works for both). + */ + readonly scope?: "global" | "conversation"; } /** The surface catalog: the list of available surfaces a client can choose to show. */ @@ -162,9 +162,9 @@ export type SurfaceCatalog = readonly SurfaceCatalogEntry[]; * client which conversation this update pertains to. A global surface omits it. */ export interface SurfaceUpdate { - readonly surfaceId: string; - readonly spec: SurfaceSpec; - readonly conversationId?: string; + readonly surfaceId: string; + readonly spec: SurfaceSpec; + readonly conversationId?: string; } // ───────────────────────────────────────────────────────────────────────────── @@ -186,16 +186,16 @@ export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | Invok * conversation in focus → the surface decides its default/empty state). */ export interface SubscribeMessage { - readonly type: "subscribe"; - readonly surfaceId: string; - readonly conversationId?: string; + readonly type: "subscribe"; + readonly surfaceId: string; + readonly conversationId?: string; } /** Stop receiving updates for a surface (and the same `conversationId`, if scoped). */ export interface UnsubscribeMessage { - readonly type: "unsubscribe"; - readonly surfaceId: string; - readonly conversationId?: string; + readonly type: "unsubscribe"; + readonly surfaceId: string; + readonly conversationId?: string; } /** @@ -204,24 +204,24 @@ export interface UnsubscribeMessage { * `conversationId` the action targets. */ export interface InvokeMessage { - readonly type: "invoke"; - readonly surfaceId: string; - readonly actionId: string; - readonly payload?: unknown; - readonly conversationId?: string; + readonly type: "invoke"; + readonly surfaceId: string; + readonly actionId: string; + readonly payload?: unknown; + readonly conversationId?: string; } /** A server → client message on the surface channel. */ export type SurfaceServerMessage = - | CatalogMessage - | SurfaceMessage - | SurfaceUpdateMessage - | SurfaceErrorMessage; + | CatalogMessage + | SurfaceMessage + | SurfaceUpdateMessage + | SurfaceErrorMessage; /** The current surface catalog (sent on connect and whenever it changes). */ export interface CatalogMessage { - readonly type: "catalog"; - readonly catalog: SurfaceCatalog; + readonly type: "catalog"; + readonly catalog: SurfaceCatalog; } /** @@ -230,20 +230,20 @@ export interface CatalogMessage { * surface (so the client routes it), and is absent for a global surface. */ export interface SurfaceMessage { - readonly type: "surface"; - readonly spec: SurfaceSpec; - readonly conversationId?: string; + readonly type: "surface"; + readonly spec: SurfaceSpec; + readonly conversationId?: string; } /** A live update for a subscribed surface. */ export interface SurfaceUpdateMessage { - readonly type: "update"; - readonly update: SurfaceUpdate; + readonly type: "update"; + readonly update: SurfaceUpdate; } /** A surface-scoped error (e.g. unknown surface id, invoke failed). */ export interface SurfaceErrorMessage { - readonly type: "error"; - readonly surfaceId?: string; - readonly message: string; + readonly type: "error"; + readonly surfaceId?: string; + readonly message: string; } diff --git a/packages/ui-contract/tsconfig.json b/packages/ui-contract/tsconfig.json index 2a3be7e..1190d9b 100644 --- a/packages/ui-contract/tsconfig.json +++ b/packages/ui-contract/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"] } diff --git a/packages/wire/package.json b/packages/wire/package.json index 027c0b3..ee57977 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,8 +1,8 @@ { - "name": "@dispatch/wire", - "version": "0.12.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts" + "name": "@dispatch/wire", + "version": "0.12.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts" } diff --git a/packages/wire/src/index.test.ts b/packages/wire/src/index.test.ts index cd297b7..3f07e00 100644 --- a/packages/wire/src/index.test.ts +++ b/packages/wire/src/index.test.ts @@ -11,49 +11,49 @@ import { describe, expect, it } from "vitest"; import type { Computer, ComputerEntry, Workspace } from "./index.js"; describe("@dispatch/wire — Computer / Workspace shapes", () => { - it("a Computer literal satisfies the Computer type", () => { - const c: Computer = { - alias: "myserver", - hostName: "myserver.example.com", - port: 22, - user: "deploy", - identityFile: null, - knownHost: true, - }; - expect(c.alias).toBe("myserver"); - expect(c.port).toBe(22); - expect(c.identityFile).toBeNull(); - expect(c.knownHost).toBe(true); - }); + it("a Computer literal satisfies the Computer type", () => { + const c: Computer = { + alias: "myserver", + hostName: "myserver.example.com", + port: 22, + user: "deploy", + identityFile: null, + knownHost: true, + }; + expect(c.alias).toBe("myserver"); + expect(c.port).toBe(22); + expect(c.identityFile).toBeNull(); + expect(c.knownHost).toBe(true); + }); - it("ComputerEntry extends Computer and carries usageCount", () => { - const entry: ComputerEntry = { - alias: "buildbox", - hostName: "buildbox", - port: 2222, - user: "root", - identityFile: "/home/u/.ssh/id_ed25519", - knownHost: false, - usageCount: 3, - }; - // Compile-time proof that ComputerEntry is assignable to Computer. - const asComputer: Computer = entry; - expect(asComputer.alias).toBe("buildbox"); - expect(entry.usageCount).toBe(3); - }); + it("ComputerEntry extends Computer and carries usageCount", () => { + const entry: ComputerEntry = { + alias: "buildbox", + hostName: "buildbox", + port: 2222, + user: "root", + identityFile: "/home/u/.ssh/id_ed25519", + knownHost: false, + usageCount: 3, + }; + // Compile-time proof that ComputerEntry is assignable to Computer. + const asComputer: Computer = entry; + expect(asComputer.alias).toBe("buildbox"); + expect(entry.usageCount).toBe(3); + }); - it("a Workspace carries defaultComputerId (null = local)", () => { - const remote: Workspace = { - id: "default", - title: "Default", - defaultCwd: null, - defaultComputerId: "myserver", - createdAt: 0, - lastActivityAt: 0, - }; - expect(remote.defaultComputerId).toBe("myserver"); + it("a Workspace carries defaultComputerId (null = local)", () => { + const remote: Workspace = { + id: "default", + title: "Default", + defaultCwd: null, + defaultComputerId: "myserver", + createdAt: 0, + lastActivityAt: 0, + }; + expect(remote.defaultComputerId).toBe("myserver"); - const local: Workspace = { ...remote, defaultComputerId: null }; - expect(local.defaultComputerId).toBeNull(); - }); + const local: Workspace = { ...remote, defaultComputerId: null }; + expect(local.defaultComputerId).toBeNull(); + }); }); diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index 8dc3a72..16b7023 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -31,23 +31,23 @@ export type StepId = string & { readonly __brand: "StepId" }; * append-only conversation log. Discriminated by `type`. */ export type Chunk = - | TextChunk - | ThinkingChunk - | ToolCallChunk - | ToolResultChunk - | ErrorChunk - | SystemChunk; + | TextChunk + | ThinkingChunk + | ToolCallChunk + | ToolResultChunk + | ErrorChunk + | SystemChunk; /** A piece of plain text content from the assistant or user. */ export interface TextChunk { - readonly type: "text"; - readonly text: string; + readonly type: "text"; + readonly text: string; } /** A piece of model reasoning / thinking content (e.g. extended thinking). */ export interface ThinkingChunk { - readonly type: "thinking"; - readonly text: string; + readonly type: "thinking"; + readonly text: string; } /** @@ -56,22 +56,22 @@ export interface ThinkingChunk { * `ToolContract.execute`. */ export interface ToolCallChunk { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; - /** - * The step that produced this call — generation provenance stamped by the - * runtime when the model emits the call (NOT storage metadata like `seq`, - * which is why it lives on the chunk and travels with it through persistence - * and replay). Tool calls a model batches together in one step share the same - * `stepId`: the grouping key for rendering a parallel batch as one unit, and - * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional: - * absent on chunks reconstructed outside a turn and on rows persisted before - * this field existed, so a consumer must tolerate its absence (render - * ungrouped). - */ - readonly stepId?: StepId; + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + /** + * The step that produced this call — generation provenance stamped by the + * runtime when the model emits the call (NOT storage metadata like `seq`, + * which is why it lives on the chunk and travels with it through persistence + * and replay). Tool calls a model batches together in one step share the same + * `stepId`: the grouping key for rendering a parallel batch as one unit, and + * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional: + * absent on chunks reconstructed outside a turn and on rows persisted before + * this field existed, so a consumer must tolerate its absence (render + * ungrouped). + */ + readonly stepId?: StepId; } /** @@ -80,27 +80,27 @@ export interface ToolCallChunk { * (synthesized if interrupted — see reconcile). */ export interface ToolResultChunk { - readonly type: "tool-result"; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; - /** - * The step that produced the originating call — equal to the `stepId` on the - * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result` - * AgentEvent, so a consumer groups a step's calls with their results. - * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`). - * Optional for the same reasons; `reconcile` copies it from the originating - * call onto a synthesized (interrupted) result. - */ - readonly stepId?: StepId; + readonly type: "tool-result"; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; + /** + * The step that produced the originating call — equal to the `stepId` on the + * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result` + * AgentEvent, so a consumer groups a step's calls with their results. + * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`). + * Optional for the same reasons; `reconcile` copies it from the originating + * call onto a synthesized (interrupted) result. + */ + readonly stepId?: StepId; } /** An error that occurred during generation or tool dispatch. */ export interface ErrorChunk { - readonly type: "error"; - readonly message: string; - readonly code?: string; + readonly type: "error"; + readonly message: string; + readonly code?: string; } /** @@ -108,8 +108,8 @@ export interface ErrorChunk { * Kept distinct from text so the log records provenance. */ export interface SystemChunk { - readonly type: "system"; - readonly text: string; + readonly type: "system"; + readonly text: string; } /** @@ -118,8 +118,8 @@ export interface SystemChunk { * rendered. */ export interface ChatMessage { - readonly role: Role; - readonly chunks: readonly Chunk[]; + readonly role: Role; + readonly chunks: readonly Chunk[]; } /** @@ -141,9 +141,9 @@ export interface ChatMessage { * `stepId`), which is intrinsic to the content and so travels with it. */ export interface StoredChunk { - readonly seq: number; - readonly role: Role; - readonly chunk: Chunk; + readonly seq: number; + readonly role: Role; + readonly chunk: Chunk; } // ─── Reasoning effort ─────────────────────────────────────────────────────── @@ -167,10 +167,10 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; * Cache fields are optional because not all providers expose cache metrics. */ export interface Usage { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheWriteTokens?: number; } // ─── Persisted metrics ─────────────────────────────────────────────────────── @@ -184,15 +184,15 @@ export interface Usage { * served by `GET /conversations/:id/metrics`. */ export interface StepMetrics { - readonly stepId: StepId; - /** The step's token usage (all four counters; cache fields optional per `Usage`). */ - readonly usage: Usage; - /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */ - readonly ttftMs?: number; - /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */ - readonly decodeMs?: number; - /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */ - readonly genTotalMs?: number; + readonly stepId: StepId; + /** The step's token usage (all four counters; cache fields optional per `Usage`). */ + readonly usage: Usage; + /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */ + readonly ttftMs?: number; + /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */ + readonly decodeMs?: number; + /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */ + readonly genTotalMs?: number; } /** @@ -205,23 +205,23 @@ export interface StepMetrics { * on every `AgentEvent`, the join key to the live stream.) */ export interface TurnMetrics { - readonly turnId: string; - /** Aggregate token usage across all steps in the turn. */ - readonly usage: Usage; - /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */ - readonly durationMs?: number; - /** Per-step metrics in step order. */ - readonly steps: readonly StepMetrics[]; - /** - * **Context size** — tokens the conversation occupies as of this turn: the - * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`), - * NOT the aggregate `usage` (which sums per-step prompts and overcounts a - * multi-step turn). The persisted, replayable counterpart of - * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client - * reopening a past conversation reads the LAST turn's `contextSize` as the - * current context usage. Optional: absent when no per-step usage was available. - */ - readonly contextSize?: number; + readonly turnId: string; + /** Aggregate token usage across all steps in the turn. */ + readonly usage: Usage; + /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */ + readonly durationMs?: number; + /** Per-step metrics in step order. */ + readonly steps: readonly StepMetrics[]; + /** + * **Context size** — tokens the conversation occupies as of this turn: the + * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`), + * NOT the aggregate `usage` (which sums per-step prompts and overcounts a + * multi-step turn). The persisted, replayable counterpart of + * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client + * reopening a past conversation reads the LAST turn's `contextSize` as the + * current context usage. Optional: absent when no per-step usage was available. + */ + readonly contextSize?: number; } // ─── Message queue + steering ─────────────────────────────────────────────── @@ -234,12 +234,12 @@ export interface TurnMetrics { * use (so a separate frontend repo can depend on the wire alone to render it). */ export interface QueuedMessage { - /** Stable id (client-visible) for UI keying + dedup. */ - readonly id: string; - /** The message text the client enqueued. */ - readonly text: string; - /** When the message was enqueued (epoch-ms). */ - readonly queuedAt: number; + /** Stable id (client-visible) for UI keying + dedup. */ + readonly id: string; + /** The message text the client enqueued. */ + readonly text: string; + /** When the message was enqueued (epoch-ms). */ + readonly queuedAt: number; } /** @@ -252,7 +252,7 @@ export interface QueuedMessage { * surface clears) and/or when the matching `TurnSteeringEvent` arrives. */ export interface QueuePayload { - readonly messages: readonly QueuedMessage[]; + readonly messages: readonly QueuedMessage[]; } // ─── Outward events ───────────────────────────────────────────────────────── @@ -262,34 +262,34 @@ export interface QueuePayload { * Consumers (transport, persistence, notifications) pattern-match on `type`. */ export type AgentEvent = - | StatusEvent - | TurnStartEvent - | TurnInputEvent - | TurnTextDeltaEvent - | TurnReasoningDeltaEvent - | TurnToolCallEvent - | TurnToolResultEvent - | TurnToolOutputEvent - | TurnUsageEvent - | TurnStepCompleteEvent - | TurnErrorEvent - | TurnProviderRetryEvent - | TurnDoneEvent - | TurnSealedEvent - | TurnSteeringEvent; + | StatusEvent + | TurnStartEvent + | TurnInputEvent + | TurnTextDeltaEvent + | TurnReasoningDeltaEvent + | TurnToolCallEvent + | TurnToolResultEvent + | TurnToolOutputEvent + | TurnUsageEvent + | TurnStepCompleteEvent + | TurnErrorEvent + | TurnProviderRetryEvent + | TurnDoneEvent + | TurnSealedEvent + | TurnSteeringEvent; /** Status change for a conversation (e.g. idle → running). */ export interface StatusEvent { - readonly type: "status"; - readonly conversationId: string; - readonly status: string; + readonly type: "status"; + readonly conversationId: string; + readonly status: string; } /** A turn has begun. */ export interface TurnStartEvent { - readonly type: "turn-start"; - readonly conversationId: string; - readonly turnId: string; + readonly type: "turn-start"; + readonly conversationId: string; + readonly turnId: string; } /** @@ -305,93 +305,93 @@ export interface TurnStartEvent { * directly. Carries the raw prompt `text` (the same text passed to the provider). */ export interface TurnInputEvent { - readonly type: "user-message"; - readonly conversationId: string; - readonly turnId: string; - readonly text: string; + readonly type: "user-message"; + readonly conversationId: string; + readonly turnId: string; + readonly text: string; } /** Incremental text content from the model during a turn. */ export interface TurnTextDeltaEvent { - readonly type: "text-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; + readonly type: "text-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; } /** Incremental reasoning / thinking content during a turn. */ export interface TurnReasoningDeltaEvent { - readonly type: "reasoning-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; + readonly type: "reasoning-delta"; + readonly conversationId: string; + readonly turnId: string; + readonly delta: string; } /** The model has requested a tool to be run. */ export interface TurnToolCallEvent { - readonly type: "tool-call"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step that produced this call. Tool calls a model batches together in - * one step share the same `stepId` — the grouping key for rendering a - * parallel batch as one unit. Matches the `stepId` on the matching - * `tool-result` event and on the persisted tool chunk - * (`StoredChunk.chunk.stepId`). - */ - readonly stepId: StepId; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; + readonly type: "tool-call"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step that produced this call. Tool calls a model batches together in + * one step share the same `stepId` — the grouping key for rendering a + * parallel batch as one unit. Matches the `stepId` on the matching + * `tool-result` event and on the persisted tool chunk + * (`StoredChunk.chunk.stepId`). + */ + readonly stepId: StepId; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; } /** A tool has completed execution. */ export interface TurnToolResultEvent { - readonly type: "tool-result"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step that produced the originating call. Equal to the `stepId` on the - * matching `tool-call` event (same `toolCallId`) and on the persisted tool - * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with - * their results. - */ - readonly stepId: StepId; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; - /** - * How long the tool took to execute (dispatch → result), in milliseconds — - * the backend's authoritative execution time, distinct from any client-side - * wall-clock. Optional: present only when the runtime was given a clock. - */ - readonly durationMs?: number; + readonly type: "tool-result"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step that produced the originating call. Equal to the `stepId` on the + * matching `tool-call` event (same `toolCallId`) and on the persisted tool + * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with + * their results. + */ + readonly stepId: StepId; + readonly toolCallId: string; + readonly toolName: string; + readonly content: string; + readonly isError: boolean; + /** + * How long the tool took to execute (dispatch → result), in milliseconds — + * the backend's authoritative execution time, distinct from any client-side + * wall-clock. Optional: present only when the runtime was given a clock. + */ + readonly durationMs?: number; } /** Streaming output from a tool execution (e.g. shell stdout/stderr). */ export interface TurnToolOutputEvent { - readonly type: "tool-output"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly data: string; - readonly stream: "stdout" | "stderr"; + readonly type: "tool-output"; + readonly conversationId: string; + readonly turnId: string; + readonly toolCallId: string; + readonly data: string; + readonly stream: "stdout" | "stderr"; } /** Token usage for the current step or turn. */ export interface TurnUsageEvent { - readonly type: "usage"; - readonly conversationId: string; - readonly turnId: string; - /** - * The step this usage report belongs to, so a consumer can attribute tokens - * per step (and join with the matching `step-complete` timing by `stepId`). - * Optional: absent when the runtime had no step context, and on usage emitted - * before this field existed. - */ - readonly stepId?: StepId; - readonly usage: Usage; + readonly type: "usage"; + readonly conversationId: string; + readonly turnId: string; + /** + * The step this usage report belongs to, so a consumer can attribute tokens + * per step (and join with the matching `step-complete` timing by `stepId`). + * Optional: absent when the runtime had no step context, and on usage emitted + * before this field existed. + */ + readonly stepId?: StepId; + readonly usage: Usage; } /** @@ -404,30 +404,30 @@ export interface TurnUsageEvent { * content token (text or reasoning) was observed this step. */ export interface TurnStepCompleteEvent { - readonly type: "step-complete"; - readonly conversationId: string; - readonly turnId: string; - readonly stepId: StepId; - /** Time to first token: stream start → first text/reasoning delta. */ - readonly ttftMs?: number; - /** Decode time: first token → stream end (generation total − TTFT). */ - readonly decodeMs?: number; - /** - * Total generation time for the step: stream start → stream end. Present - * whenever a clock was available, even if no first token was seen (in which - * case `ttftMs`/`decodeMs` are absent). When a first token was seen, - * `genTotalMs === ttftMs + decodeMs`. - */ - readonly genTotalMs?: number; + readonly type: "step-complete"; + readonly conversationId: string; + readonly turnId: string; + readonly stepId: StepId; + /** Time to first token: stream start → first text/reasoning delta. */ + readonly ttftMs?: number; + /** Decode time: first token → stream end (generation total − TTFT). */ + readonly decodeMs?: number; + /** + * Total generation time for the step: stream start → stream end. Present + * whenever a clock was available, even if no first token was seen (in which + * case `ttftMs`/`decodeMs` are absent). When a first token was seen, + * `genTotalMs === ttftMs + decodeMs`. + */ + readonly genTotalMs?: number; } /** An error occurred during the turn. */ export interface TurnErrorEvent { - readonly type: "error"; - readonly conversationId: string; - readonly turnId: string; - readonly message: string; - readonly code?: string; + readonly type: "error"; + readonly conversationId: string; + readonly turnId: string; + readonly message: string; + readonly code?: string; } /** @@ -442,51 +442,51 @@ export interface TurnErrorEvent { * before that retry fires. */ export interface TurnProviderRetryEvent { - readonly type: "provider-retry"; - readonly conversationId: string; - readonly turnId: string; - /** 0-based: this is the Nth retry about to happen. */ - readonly attempt: number; - /** ms the client should expect to wait before the retry fires. */ - readonly delayMs: number; - /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ - readonly message: string; - /** The HTTP code when known (e.g. "429"). */ - readonly code?: string; + readonly type: "provider-retry"; + readonly conversationId: string; + readonly turnId: string; + /** 0-based: this is the Nth retry about to happen. */ + readonly attempt: number; + /** ms the client should expect to wait before the retry fires. */ + readonly delayMs: number; + /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ + readonly message: string; + /** The HTTP code when known (e.g. "429"). */ + readonly code?: string; } /** The turn has completed (model finished generating). */ export interface TurnDoneEvent { - readonly type: "done"; - readonly conversationId: string; - readonly turnId: string; - readonly reason: string; - /** - * Total wall-clock duration of the turn (turn start → turn end), in - * milliseconds. Optional: present only when the runtime was given a clock. - */ - readonly durationMs?: number; - /** - * Aggregate token usage across all steps in the turn — a convenience total so - * a consumer need not sum the per-step `usage` events. Optional (absent if the - * provider reported no usage). - */ - readonly usage?: Usage; - /** - * **Context size** — the number of tokens the conversation now occupies: this - * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full - * prompt sent into the last LLM round-trip plus that round-trip's output). This - * is the "tokens in context" figure a client renders as the chat's current - * context usage, and a client treats the LATEST turn's value as the live total. - * - * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's - * `inputTokens`, which overcounts a multi-step / tool-calling turn because every - * step re-prefills the growing prompt — the final step's input already includes - * all prior context, so its input+output is the true occupancy. Optional: absent - * when no per-step usage was observed this turn (mirrors `usage`). A later field - * will carry the model's max context-window LIMIT; this is only the current size. - */ - readonly contextSize?: number; + readonly type: "done"; + readonly conversationId: string; + readonly turnId: string; + readonly reason: string; + /** + * Total wall-clock duration of the turn (turn start → turn end), in + * milliseconds. Optional: present only when the runtime was given a clock. + */ + readonly durationMs?: number; + /** + * Aggregate token usage across all steps in the turn — a convenience total so + * a consumer need not sum the per-step `usage` events. Optional (absent if the + * provider reported no usage). + */ + readonly usage?: Usage; + /** + * **Context size** — the number of tokens the conversation now occupies: this + * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full + * prompt sent into the last LLM round-trip plus that round-trip's output). This + * is the "tokens in context" figure a client renders as the chat's current + * context usage, and a client treats the LATEST turn's value as the live total. + * + * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's + * `inputTokens`, which overcounts a multi-step / tool-calling turn because every + * step re-prefills the growing prompt — the final step's input already includes + * all prior context, so its input+output is the true occupancy. Optional: absent + * when no per-step usage was observed this turn (mirrors `usage`). A later field + * will carry the model's max context-window LIMIT; this is only the current size. + */ + readonly contextSize?: number; } /** @@ -494,9 +494,9 @@ export interface TurnDoneEvent { * This is the hook point for post-turn extensions (compaction, cache-warm). */ export interface TurnSealedEvent { - readonly type: "turn-sealed"; - readonly conversationId: string; - readonly turnId: string; + readonly type: "turn-sealed"; + readonly conversationId: string; + readonly turnId: string; } /** @@ -517,10 +517,10 @@ export interface TurnSealedEvent { * event per drain; the combined text of all drained messages. */ export interface TurnSteeringEvent { - readonly type: "steering"; - readonly conversationId: string; - readonly turnId: string; - readonly text: string; + readonly type: "steering"; + readonly conversationId: string; + readonly turnId: string; + readonly text: string; } // ─── Conversation metadata ─────────────────────────────────────────────────── @@ -542,23 +542,23 @@ export type ConversationStatus = "active" | "idle" | "closed"; * for cross-device persistence. */ export interface ConversationMeta { - readonly id: string; - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - /** - * The workspace this conversation belongs to. Always present; reads as - * `"default"` for legacy conversations that were never explicitly assigned. - * Conversations created with no `workspaceId` default to `"default"`. - */ - readonly workspaceId: string; - /** - * Set on a compacted conversation: points to the archive conversation ID - * that holds the full pre-compaction history. Absent on conversations - * that have never been compacted. - */ - readonly compactedFrom?: string; + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + /** + * The workspace this conversation belongs to. Always present; reads as + * `"default"` for legacy conversations that were never explicitly assigned. + * Conversations created with no `workspaceId` default to `"default"`. + */ + readonly workspaceId: string; + /** + * Set on a compacted conversation: points to the archive conversation ID + * that holds the full pre-compaction history. Absent on conversations + * that have never been compacted. + */ + readonly compactedFrom?: string; } // ─── Compaction ────────────────────────────────────────────────────────────── @@ -571,10 +571,10 @@ export interface ConversationMeta { * pre-compaction history (non-destructive — the original history is preserved). */ export interface CompactionResult { - readonly summary: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly summary: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } // ─── Workspaces ────────────────────────────────────────────────────────────── @@ -590,24 +590,24 @@ export interface CompactionResult { * created with no `workspaceId` are assigned to `"default"`. */ export interface Workspace { - /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ - readonly id: string; - /** Display title (editable). Defaults to `id` on creation. */ - readonly title: string; - /** The workspace's default cwd, or `null` (fall through to server default). */ - readonly defaultCwd: string | null; - /** - * The workspace's default computer — an SSH config `Host` alias that - * conversations in this workspace inherit when they set no `computerId` of - * their own. `null` means local (no SSH; today's behavior). The computer - * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` - * (per-conv `computerId` → this → `null`/local). - */ - readonly defaultComputerId: string | null; - /** Epoch-ms when the workspace was first created. */ - readonly createdAt: number; - /** Epoch-ms of the most recent conversation activity in this workspace. */ - readonly lastActivityAt: number; + /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ + readonly id: string; + /** Display title (editable). Defaults to `id` on creation. */ + readonly title: string; + /** The workspace's default cwd, or `null` (fall through to server default). */ + readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; + /** Epoch-ms when the workspace was first created. */ + readonly createdAt: number; + /** Epoch-ms of the most recent conversation activity in this workspace. */ + readonly lastActivityAt: number; } /** @@ -615,8 +615,8 @@ export interface Workspace { * plus a conversation count. */ export interface WorkspaceEntry extends Workspace { - /** Number of conversations assigned to this workspace. */ - readonly conversationCount: number; + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; } // ─── Computers ─────────────────────────────────────────────────────────────── @@ -634,21 +634,21 @@ export interface WorkspaceEntry extends Workspace { * drives the frontend "known/new" indicator and is read-only. */ export interface Computer { - /** The SSH config `Host` alias — also the `computerId` users select. */ - readonly alias: string; - /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ - readonly hostName: string; - /** Resolved port (config `Port`, default 22). */ - readonly port: number; - /** Resolved user (config `User`, default the current user). */ - readonly user: string; - /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ - readonly identityFile: string | null; - /** - * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously - * connected). Drives the frontend "known/new" indicator. Read-only. - */ - readonly knownHost: boolean; + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; } /** @@ -656,6 +656,6 @@ export interface Computer { * a usage count. Parallel to `WorkspaceEntry`. */ export interface ComputerEntry extends Computer { - /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ - readonly usageCount: number; + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; } diff --git a/packages/wire/tsconfig.json b/packages/wire/tsconfig.json index 2a3be7e..1190d9b 100644 --- a/packages/wire/tsconfig.json +++ b/packages/wire/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index c6fc144..2e800b8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,23 +1,23 @@ { - "exclude": ["**/*.test.ts", "**/*.test.tsx"], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2023"], - "types": ["bun"], - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "exactOptionalPropertyTypes": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "composite": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true - } + "exclude": ["**/*.test.ts", "**/*.test.tsx"], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["bun"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + } } diff --git a/tsconfig.json b/tsconfig.json index aab3ac1..f247206 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,119 +1,119 @@ { - "files": [], - "references": [ - { - "path": "./packages/wire" - }, - { - "path": "./packages/kernel" - }, - { - "path": "./packages/transport-contract" - }, - { - "path": "./packages/ui-contract" - }, - { - "path": "./packages/surface-registry" - }, - { - "path": "./packages/transport-ws" - }, - { - "path": "./packages/surface-loaded-extensions" - }, - { - "path": "./packages/storage-sqlite" - }, - { - "path": "./packages/auth-apikey" - }, - { - "path": "./packages/provider-openai-compat" - }, - { - "path": "./packages/openai-stream" - }, - { - "path": "./packages/provider-umans" - }, - { - "path": "./packages/credential-store" - }, - { - "path": "./packages/exec-backend" - }, - { - "path": "./packages/ssh" - }, - { - "path": "./packages/conversation-store" - }, - { - "path": "./packages/throughput-store" - }, - { - "path": "./packages/todo" - }, - { - "path": "./packages/session-orchestrator" - }, - { - "path": "./packages/transport-http" - }, - { - "path": "./packages/tool-read-file" - }, - { - "path": "./packages/tool-shell" - }, - { - "path": "./packages/tool-edit-file" - }, - { - "path": "./packages/tool-write-file" - }, - { - "path": "./packages/tool-web-search" - }, - { - "path": "./packages/tool-youtube-transcript" - }, - { - "path": "./packages/skills" - }, - { - "path": "./packages/cache-warming" - }, - { - "path": "./packages/message-queue" - }, - { - "path": "./packages/mcp" - }, - { - "path": "./packages/lsp" - }, - { - "path": "./packages/system-prompt" - }, - { - "path": "./packages/cli" - }, - { - "path": "./packages/journal-sink" - }, - { - "path": "./packages/trace-store" - }, - { - "path": "./packages/observability-collector" - }, - { - "path": "./packages/trace-replay" - }, - { - "path": "./packages/host-bin" - } - ] + "files": [], + "references": [ + { + "path": "./packages/wire" + }, + { + "path": "./packages/kernel" + }, + { + "path": "./packages/transport-contract" + }, + { + "path": "./packages/ui-contract" + }, + { + "path": "./packages/surface-registry" + }, + { + "path": "./packages/transport-ws" + }, + { + "path": "./packages/surface-loaded-extensions" + }, + { + "path": "./packages/storage-sqlite" + }, + { + "path": "./packages/auth-apikey" + }, + { + "path": "./packages/provider-openai-compat" + }, + { + "path": "./packages/openai-stream" + }, + { + "path": "./packages/provider-umans" + }, + { + "path": "./packages/credential-store" + }, + { + "path": "./packages/exec-backend" + }, + { + "path": "./packages/ssh" + }, + { + "path": "./packages/conversation-store" + }, + { + "path": "./packages/throughput-store" + }, + { + "path": "./packages/todo" + }, + { + "path": "./packages/session-orchestrator" + }, + { + "path": "./packages/transport-http" + }, + { + "path": "./packages/tool-read-file" + }, + { + "path": "./packages/tool-shell" + }, + { + "path": "./packages/tool-edit-file" + }, + { + "path": "./packages/tool-write-file" + }, + { + "path": "./packages/tool-web-search" + }, + { + "path": "./packages/tool-youtube-transcript" + }, + { + "path": "./packages/skills" + }, + { + "path": "./packages/cache-warming" + }, + { + "path": "./packages/message-queue" + }, + { + "path": "./packages/mcp" + }, + { + "path": "./packages/lsp" + }, + { + "path": "./packages/system-prompt" + }, + { + "path": "./packages/cli" + }, + { + "path": "./packages/journal-sink" + }, + { + "path": "./packages/trace-store" + }, + { + "path": "./packages/observability-collector" + }, + { + "path": "./packages/trace-replay" + }, + { + "path": "./packages/host-bin" + } + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 33b8337..6c8f0c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,17 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ - test: { - // Everything runs here under vitest EXCEPT bun-only tests, which run via - // `test:bun` (they use real `Bun.serve` / `bun:sqlite` / `bun:test`): - // - `*.bun.test.ts` files (e.g. transport-ws's live WebSocket server test) - // - packages whose code imports Bun-only modules (`bun:sqlite`) - exclude: [ - ...configDefaults.exclude, - "**/*.bun.test.ts", - "packages/storage-sqlite/**", - "packages/trace-store/**", - "packages/observability-collector/**", - ], - }, + test: { + // Everything runs here under vitest EXCEPT bun-only tests, which run via + // `test:bun` (they use real `Bun.serve` / `bun:sqlite` / `bun:test`): + // - `*.bun.test.ts` files (e.g. transport-ws's live WebSocket server test) + // - packages whose code imports Bun-only modules (`bun:sqlite`) + exclude: [ + ...configDefaults.exclude, + "**/*.bun.test.ts", + "packages/storage-sqlite/**", + "packages/trace-store/**", + "packages/observability-collector/**", + ], + }, }); -- cgit v1.2.3