summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/session-orchestrator/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/session-orchestrator/src')
-rw-r--r--packages/session-orchestrator/src/extension.ts294
-rw-r--r--packages/session-orchestrator/src/index.ts84
-rw-r--r--packages/session-orchestrator/src/metrics.test.ts720
-rw-r--r--packages/session-orchestrator/src/metrics.ts226
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts7774
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts2066
-rw-r--r--packages/session-orchestrator/src/pure.test.ts282
-rw-r--r--packages/session-orchestrator/src/pure.ts60
-rw-r--r--packages/session-orchestrator/src/queue.test.ts1058
-rw-r--r--packages/session-orchestrator/src/tools-filter.test.ts118
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts56
11 files changed, 6369 insertions, 6369 deletions
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,
+ };
}