diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/session-orchestrator | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip | |
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/session-orchestrator')
| -rw-r--r-- | packages/session-orchestrator/package.json | 26 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 294 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/index.ts | 84 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/metrics.test.ts | 720 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/metrics.ts | 226 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 7774 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 2066 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/pure.test.ts | 282 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/pure.ts | 60 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 1058 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.test.ts | 118 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.ts | 56 | ||||
| -rw-r--r-- | packages/session-orchestrator/tsconfig.json | 20 |
13 files changed, 6392 insertions, 6392 deletions
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 (`<serverId>__<toolName>`, 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 (`<serverId>__<toolName>`, 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<StepId, StepAccumulator>(); - const stepOrder: StepId[] = []; - let doneUsage: Usage | undefined; - let doneDurationMs: number | undefined; + const steps = new Map<StepId, StepAccumulator>(); + 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<string, ChatMessage[]>; - readonly metricsData: Map<string, TurnMetrics[]>; - readonly cwdData: Map<string, string>; - readonly computerData: Map<string, string>; - readonly effortData: Map<string, ReasoningEffort>; - readonly modelData: Map<string, string>; - readonly workspaceIdData: Map<string, string>; + readonly data: Map<string, ChatMessage[]>; + readonly metricsData: Map<string, TurnMetrics[]>; + readonly cwdData: Map<string, string>; + readonly computerData: Map<string, string>; + readonly effortData: Map<string, ReasoningEffort>; + readonly modelData: Map<string, string>; + readonly workspaceIdData: Map<string, string>; } { - const data = new Map<string, ChatMessage[]>(); - const metricsData = new Map<string, TurnMetrics[]>(); - const cwdData = new Map<string, string>(); - const computerData = new Map<string, string>(); - const effortData = new Map<string, ReasoningEffort>(); - const modelData = new Map<string, string>(); - const workspaceIdData = new Map<string, string>(); - // 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<string>(); - 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<string, ChatMessage[]>(); + const metricsData = new Map<string, TurnMetrics[]>(); + const cwdData = new Map<string, string>(); + const computerData = new Map<string, string>(); + const effortData = new Map<string, ReasoningEffort>(); + const modelData = new Map<string, string>(); + const workspaceIdData = new Map<string, string>(); + // 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<string>(); + 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<ToolAssembly> { - 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<RunTurnResult>; + result: RunTurnResult; + captured: RunTurnInput[]; + captureRunTurn: (input: RunTurnInput) => Promise<RunTurnResult>; } { - 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<ToolAssembly> => { - 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<ToolAssembly> => { + 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<ToolAssembly> => { - 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<ToolAssembly> => { - 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<ToolAssembly> => { + 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<ToolAssembly> => { + 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, 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<void>((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<RunTurnResult> => { - 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, _payload: TPayload): void => { - emitted.push({ hook: hook.id }); - }; - - const blockingRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { - await new Promise<void>((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<void>((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<RunTurnResult> => { + 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, 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 = <TPayload>(hook: EventHookDescriptor<TPayload>, _payload: TPayload): void => { + emitted.push({ hook: hook.id }); + }; + + const blockingRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { + await new Promise<void>((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<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, - eventType: string, - ): Promise<AgentEvent> { - 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<void>((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<void>((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<void>((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<RunTurnResult> => { - 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<void>((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<RunTurnResult> => { - 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<void>((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<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + eventType: string, + ): Promise<AgentEvent> { + 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<void>((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<void>((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<void>((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<RunTurnResult> => { + 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<void>((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<RunTurnResult> => { + 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<void>((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<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, - eventType: string, - ): Promise<AgentEvent> { - 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<void>((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<void>((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<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + eventType: string, + ): Promise<AgentEvent> { + 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<void>((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<void>((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<void>((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<void>((resolve) => { - resolveSealed = resolve; - }); - let resolveFirstDelta: (() => void) | undefined; - const firstDelta = new Promise<void>((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<AgentEvent, { type: "done" }> => 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<void>((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<void>((resolve) => { + resolveSealed = resolve; + }); + let resolveFirstDelta: (() => void) | undefined; + const firstDelta = new Promise<void>((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<AgentEvent, { type: "done" }> => 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<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, - ): Promise<void> { - 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<string, string | null>([ - ["default", null], - ["my-workspace", "/projects/my-workspace"], - ]); - const assignedWorkspaceIds = new Map<string, string>(); - 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<string, string | null>([ - ["default", null], - ["my-workspace", "/projects/my-workspace"], - ]); - const assignedWorkspaceIds = new Map<string, string>(); - - 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<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + ): Promise<void> { + 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<string, string | null>([ + ["default", null], + ["my-workspace", "/projects/my-workspace"], + ]); + const assignedWorkspaceIds = new Map<string, string>(); + 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<string, string | null>([ + ["default", null], + ["my-workspace", "/projects/my-workspace"], + ]); + const assignedWorkspaceIds = new Map<string, string>(); + + 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<string>, - 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<string>, + 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<typeof createInMemoryStore>, - 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<typeof createInMemoryStore>, + 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<TurnLifecyclePayload> = - defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started"); + defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started"); /** Fired when a turn SETTLES (sealed) for a conversation (consumers arm warming timers). */ export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> = - defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled"); + defineEventHook<TurnLifecyclePayload>("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<ConversationClosedPayload> = - defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed"); + defineEventHook<ConversationClosedPayload>("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<ConversationOpenedPayload> = - defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened"); + defineEventHook<ConversationOpenedPayload>("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<ConversationStatusChangedPayload> = - defineEventHook<ConversationStatusChangedPayload>( - "session-orchestrator/conversation-status-changed", - ); + defineEventHook<ConversationStatusChangedPayload>( + "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<ConversationCompactedPayload> = - defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted"); + defineEventHook<ConversationCompactedPayload>("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<WarmCompletedPayload> = - defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed"); + defineEventHook<WarmCompletedPayload>("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<WarmResult | { readonly error: string }>; + readonly warm: ( + conversationId: string, + opts?: { readonly cwd?: string; readonly modelName?: string }, + ) => Promise<WarmResult | { readonly error: string }>; } export const cacheWarmHandle: ServiceHandle<WarmService> = defineService<WarmService>( - "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<CompactionResult | { readonly error: string }>; + /** + * 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<CompactionResult | { readonly error: string }>; } export const compactionHandle: ServiceHandle<CompactionService> = defineService<CompactionService>( - "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<void>; + 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<void>; } export const sessionOrchestratorHandle = defineService<SessionOrchestrator>( - "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<ModelInfo | undefined>; - readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; - /** - * 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<ToolAssembly>; - /** 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?: <TPayload>(hook: EventHookDescriptor<TPayload>, 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<ModelInfo | undefined>; + readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; + /** + * 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<ToolAssembly>; + /** 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?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; } /** Deps for the warm service — emit is REQUIRED so warmCompleted is never silently dropped. */ export type WarmServiceDeps = SessionOrchestratorDeps & { - readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; }; export interface SessionOrchestratorBundle { - readonly orchestrator: SessionOrchestrator; - /** The shared active-conversations set, for use by createWarmService. */ - readonly activeConversations: ReadonlySet<string>; + readonly orchestrator: SessionOrchestrator; + /** The shared active-conversations set, for use by createWarmService. */ + readonly activeConversations: ReadonlySet<string>; } /** @@ -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<void> => { - 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<void> => { + 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<string>(); - const subscribers = new Map<string, Set<TurnEventListener>>(); - const activeTurns = new Map<string, ActiveTurn>(); - // 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<boolean> => { - 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<void>((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<string>(); + const subscribers = new Map<string, Set<TurnEventListener>>(); + const activeTurns = new Map<string, ActiveTurn>(); + // 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<boolean> => { + 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<void>((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<string>, + deps: WarmServiceDeps, + activeConversations: ReadonlySet<string>, ): 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: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; - }, - activeConversations: ReadonlySet<string>, + deps: SessionOrchestratorDeps & { + readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + }, + activeConversations: ReadonlySet<string>, ): 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<string, ProviderContract>(); - providers.set("test-provider", provider); - - expect(selectFirstProvider(providers)).toBe(provider); - }); - - it("throws when the map is empty", () => { - const providers = new Map<string, ProviderContract>(); - 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<string, ProviderContract>(); - 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<string, ProviderContract>(); + providers.set("test-provider", provider); + + expect(selectFirstProvider(providers)).toBe(provider); + }); + + it("throws when the map is empty", () => { + const providers = new Map<string, ProviderContract>(); + 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<string, ProviderContract>(); + 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<string, ProviderContract>, + providers: ReadonlyMap<string, ProviderContract>, ): 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<string, unknown>): 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<string, ChatMessage[]>; - readonly metricsData: Map<string, TurnMetrics[]>; - readonly cwdData: Map<string, string>; - readonly effortData: Map<string, ReasoningEffort>; - readonly modelData: Map<string, string>; + readonly data: Map<string, ChatMessage[]>; + readonly metricsData: Map<string, TurnMetrics[]>; + readonly cwdData: Map<string, string>; + readonly effortData: Map<string, ReasoningEffort>; + readonly modelData: Map<string, string>; } { - const data = new Map<string, ChatMessage[]>(); - const metricsData = new Map<string, TurnMetrics[]>(); - const cwdData = new Map<string, string>(); - const effortData = new Map<string, ReasoningEffort>(); - const modelData = new Map<string, string>(); - 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<string, ChatMessage[]>(); + const metricsData = new Map<string, TurnMetrics[]>(); + const cwdData = new Map<string, string>(); + const effortData = new Map<string, ReasoningEffort>(); + const modelData = new Map<string, string>(); + 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<ToolAssembly> { - 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<RunTurnResult>; + captured: RunTurnInput[]; + drainedMessages: ChatMessage[]; + wasDrainCalled: () => boolean; + runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; } { - 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<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, + orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, ): Promise<void> { - 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<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, - count: number, + orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + count: number, ): Promise<void> { - 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<AgentEvent, { type: "steering" }> { - return e.type === "steering"; + return e.type === "steering"; } function isUserMessage(e: AgentEvent): e is Extract<AgentEvent, { type: "user-message" }> { - 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<void>((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<void>((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<void>((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { - 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<void>((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<void>((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { - 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<void>((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<void>((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { + 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<void>((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<void>((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { + 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<void>((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<ToolAssembly> = defineFilter<ToolAssembly>( - "session-orchestrator/tools", + "session-orchestrator/tools", ); /** @@ -42,18 +42,18 @@ export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAsse * MCP's `filterMcpTools` extraction pattern. */ export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssembly { - // 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, - }; + // 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" } + ] } |
