summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator
diff options
context:
space:
mode:
Diffstat (limited to 'packages/session-orchestrator')
-rw-r--r--packages/session-orchestrator/package.json27
-rw-r--r--packages/session-orchestrator/src/extension.ts336
-rw-r--r--packages/session-orchestrator/src/index.ts88
-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.ts8584
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts2640
-rw-r--r--packages/session-orchestrator/src/pure.test.ts386
-rw-r--r--packages/session-orchestrator/src/pure.ts159
-rw-r--r--packages/session-orchestrator/src/queue.test.ts1252
-rw-r--r--packages/session-orchestrator/src/tools-filter.test.ts118
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts66
-rw-r--r--packages/session-orchestrator/tsconfig.json21
13 files changed, 8229 insertions, 6394 deletions
diff --git a/packages/session-orchestrator/package.json b/packages/session-orchestrator/package.json
index 40cc0fe..b9f3d22 100644
--- a/packages/session-orchestrator/package.json
+++ b/packages/session-orchestrator/package.json
@@ -1,15 +1,16 @@
{
- "name": "@dispatch/session-orchestrator",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/kernel": "workspace:*",
- "@dispatch/conversation-store": "workspace:*",
- "@dispatch/credential-store": "workspace:*",
- "@dispatch/message-queue": "workspace:*",
- "@dispatch/system-prompt": "workspace:*"
- }
+ "name": "@dispatch/session-orchestrator",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/credential-store": "workspace:*",
+ "@dispatch/message-queue": "workspace:*",
+ "@dispatch/provider-concurrency": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*"
+ }
}
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index 1a57cc3..777feaa 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -3,171 +3,213 @@ import { credentialStoreHandle } from "@dispatch/credential-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { runTurn } from "@dispatch/kernel";
import { messageQueueHandle } from "@dispatch/message-queue";
+import { concurrencyServiceHandle } from "@dispatch/provider-concurrency";
import { systemPromptHandle } from "@dispatch/system-prompt";
import {
- cacheWarmHandle,
- compactionHandle,
- createCompactionService,
- createSessionOrchestrator,
- createWarmService,
- sessionOrchestratorHandle,
+ cacheWarmHandle,
+ compactionHandle,
+ createCompactionService,
+ createSessionOrchestrator,
+ createWarmService,
+ sessionOrchestratorHandle,
+ visionHandoffLocalHandle,
} 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),
+ // Injected process.memoryUsage() sampler — the production edge. Tests
+ // inject a fake to assert per-turn before/after telemetry. Wired in the
+ // shell (like `now: () => Date.now()`); pure decision logic is untouched.
+ sampleMemory: () => {
+ const m = process.memoryUsage();
+ return {
+ rss: m.rss,
+ heapUsed: m.heapUsed,
+ heapTotal: m.heapTotal,
+ external: m.external,
+ arrayBuffers: m.arrayBuffers,
+ };
+ },
+ 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;
+ }
+ },
+ resolveConcurrencyLimiter: () => {
+ // Lazily resolve the concurrency limiter. Returns undefined when the
+ // provider-concurrency extension isn't loaded (no concurrency limiting —
+ // feature degrades off). Lazy so activation order with
+ // provider-concurrency doesn't matter; called per-turn, not at activate.
+ const loaded = host.getExtensions().some((m) => m.id === "provider-concurrency");
+ if (!loaded) return undefined;
+ try {
+ return host.getService(concurrencyServiceHandle);
+ } catch {
+ return undefined;
+ }
+ },
+ resolveVisionHandoff: () => {
+ // Lazily resolve the vision-handoff service. Returns undefined when the
+ // vision-handoff extension isn't loaded (images pass through unchanged —
+ // correct for vision-capable models; the feature degrades off cleanly for
+ // text-only turns). Lazy so activation order doesn't matter; the
+ // activated-manifests guard avoids a getService throw when absent.
+ const loaded = host.getExtensions().some((m) => m.id === "vision-handoff");
+ if (!loaded) return undefined;
+ try {
+ return host.getService(visionHandoffLocalHandle);
+ } 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..360e8d2 100644
--- a/packages/session-orchestrator/src/index.ts
+++ b/packages/session-orchestrator/src/index.ts
@@ -1,48 +1,52 @@
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 CancelQueuedMessageResult,
+ 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,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
+ 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..c4be03c 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -1,3982 +1,4790 @@
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,
+ LogDeps,
+ Logger,
+ LogRecord,
+ LogSink,
+ ProviderContract,
+ ProviderEvent,
+ ProviderStreamOptions,
+ ReasoningEffort,
+ RunTurnInput,
+ RunTurnResult,
+ StoredChunk,
+ ToolContract,
+ TurnMetrics,
} from "@dispatch/kernel";
-import { runTurn } from "@dispatch/kernel";
+import { createLogger, runTurn } from "@dispatch/kernel";
+import { createMessageQueueService } from "@dispatch/message-queue";
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 ConversationCompactedPayload,
+ type ConversationOpenedPayload,
+ type ConversationStatusChangedPayload,
+ conversationCompacted,
+ createCompactionService,
+ createSessionOrchestrator,
+ createWarmService,
+ type TurnLifecyclePayload,
+ type WarmCompletedPayload,
} from "./orchestrator.js";
+import type { MemorySample } from "./pure.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>();
+ const compactPercentData = new Map<string, number>();
+ // 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(conversationId) {
+ return compactPercentData.get(conversationId) ?? null;
+ },
+ async setCompactPercent(conversationId, percent) {
+ compactPercentData.set(conversationId, percent);
+ },
+ 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("title (summon-title): deferred until after workspace initialization", () => {
+ // Regression: an earlier implementation set the title in the HTTP /chat
+ // route BEFORE the turn started, which pre-created the conversation meta
+ // and made the orchestrator's `meta === null` newness check falsely report
+ // an EXISTING conversation — so ensureWorkspace / setWorkspaceId / the
+ // first-turn system-prompt construct were ALL skipped. The fix defers the
+ // title set into workspaceSetupPromise, AFTER the newness check + workspace
+ // assignment, so a titled new conversation is still initialized correctly.
+
+ /** Wrap the in-memory store to record the ORDER of init-relevant calls. */
+ function createCallRecordingStore() {
+ const base = createInMemoryStore();
+ const calls: string[] = [];
+ const titleCalls: { conversationId: string; title: string }[] = [];
+ return {
+ store: {
+ ...base,
+ async getConversationMeta(conversationId: string) {
+ calls.push(`getMeta:${conversationId}`);
+ return base.getConversationMeta(conversationId);
+ },
+ async ensureWorkspace(id: string) {
+ calls.push(`ensureWorkspace:${id}`);
+ return base.ensureWorkspace(id);
+ },
+ async setWorkspaceId(conversationId: string, workspaceId: string) {
+ calls.push(`setWorkspaceId:${workspaceId}`);
+ await base.setWorkspaceId(conversationId, workspaceId);
+ },
+ async setConversationTitle(conversationId: string, title: string) {
+ calls.push(`setTitle:${title}`);
+ titleCalls.push({ conversationId, title });
+ await base.setConversationTitle(conversationId, title);
+ },
+ } as ConversationStore,
+ calls,
+ titleCalls,
+ };
+ }
+
+ it("titled new conversation: workspace assigned, system prompt constructed, title set", async () => {
+ const { store, calls, titleCalls } = createCallRecordingStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+ const constructCalls: string[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ resolveSystemPrompt: () =>
+ createFakeSystemPromptService(async (conversationId) => {
+ constructCalls.push(conversationId);
+ return "CONSTRUCTED_PROMPT";
+ }),
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-title-new",
+ text: "hi",
+ onEvent: () => {},
+ title: "My Task",
+ workspaceId: "my-workspace",
+ });
+
+ // The bug: workspace init was skipped. It must NOT be.
+ expect(calls).toContain("ensureWorkspace:my-workspace");
+ expect(calls).toContain("setWorkspaceId:my-workspace");
+ // First-turn system prompt construct runs (proves isNewConversation was
+ // true — the newness check was not fooled by a pre-created meta).
+ expect(constructCalls).toEqual(["conv-title-new"]);
+ // The title is persisted.
+ expect(titleCalls).toEqual([{ conversationId: "conv-title-new", title: "My Task" }]);
+ });
+
+ it("title is set AFTER the newness check + workspace assignment (order)", async () => {
+ const { store, calls } = createCallRecordingStore();
+ 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-title-order",
+ text: "hi",
+ onEvent: () => {},
+ title: "Ordered",
+ });
+
+ const getMetaIdx = calls.findIndex((c) => c.startsWith("getMeta:"));
+ const ensureIdx = calls.findIndex((c) => c.startsWith("ensureWorkspace:"));
+ const setWsIdx = calls.findIndex((c) => c.startsWith("setWorkspaceId:"));
+ const setTitleIdx = calls.findIndex((c) => c.startsWith("setTitle:"));
+ expect(getMetaIdx).toBeGreaterThanOrEqual(0);
+ expect(ensureIdx).toBeGreaterThan(getMetaIdx);
+ expect(setWsIdx).toBeGreaterThan(ensureIdx);
+ expect(setTitleIdx).toBeGreaterThan(setWsIdx);
+ });
+
+ it("no title: setConversationTitle is not called", async () => {
+ const { store, calls } = createCallRecordingStore();
+ 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-title",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(calls.some((c) => c.startsWith("setTitle:"))).toBe(false);
+ });
+
+ it("existing conversation with a title: workspace NOT re-assigned, title still set", async () => {
+ const { store, calls, titleCalls } = createCallRecordingStore();
+ // Seed an existing conversation (meta non-null, workspace already set).
+ await store.setWorkspaceId("conv-title-existing", "prior-workspace");
+ 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-title-existing",
+ text: "hi",
+ onEvent: () => {},
+ title: "Renamed",
+ });
+
+ // Existing conversation: workspace init must not run again.
+ expect(calls.some((c) => c.startsWith("ensureWorkspace:"))).toBe(false);
+ // But the title is still applied (rename on an existing conversation).
+ expect(titleCalls).toEqual([{ conversationId: "conv-title-existing", title: "Renamed" }]);
+ });
+
+ it("turn still completes if setConversationTitle throws", async () => {
+ const base = createInMemoryStore();
+ const store: ConversationStore = {
+ ...base,
+ async setConversationTitle() {
+ throw new Error("title store unavailable");
+ },
+ };
+ 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-title-throws",
+ text: "hi",
+ onEvent: () => {},
+ title: "Resilient",
+ });
+
+ // The turn ran despite the title-set failure.
+ expect(captured).toHaveLength(1);
+ });
});
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);
+ });
+});
+
+describe("in-flight compaction", () => {
+ // Seeds a conversation with `count` alternating user/assistant text messages
+ // so the history is long enough to compact (> DEFAULT_KEEP_LAST_N = 10).
+ 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: `seed message ${i}` }],
+ });
+ }
+ store.data.set(conversationId, messages);
+ }
+
+ // A provider whose `stream` serves a SCRIPT of per-call event lists, in
+ // order. Captures the messages passed to each call so a test can assert what
+ // the model saw at each step (incl. after in-flight compaction replaced it).
+ function createScriptedCapturingProvider(script: ProviderEvent[][]): {
+ provider: ProviderContract;
+ capturedMessages: ChatMessage[][];
+ } {
+ const capturedMessages: ChatMessage[][] = [];
+ let callIndex = 0;
+ const provider: ProviderContract = {
+ id: "fake",
+ stream(messages) {
+ capturedMessages.push([...messages]);
+ const events = script[callIndex] ?? [];
+ callIndex++;
+ return (async function* () {
+ for (const event of events) {
+ yield event;
+ }
+ })();
+ },
+ };
+ return { provider, capturedMessages };
+ }
+
+ function echoTool(): ToolContract {
+ return {
+ name: "echo",
+ description: "echo",
+ parameters: { type: "object" },
+ execute: async () => ({ content: "echoed" }),
+ };
+ }
+
+ it("triggers when a step's usage exceeds the threshold: history is compacted mid-turn and the prompt continues with the summary", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-inflight", 15); // > keepLastN(10) → compactable
+
+ // contextWindow 1000, default percent 85 → threshold 850.
+ // Step 0 emits a tool call + usage(inputTokens 900) → 910 > 850 → trigger.
+ // Then the compaction summary call, then step 1 ends the turn.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ // Compaction summary call (performCompaction):
+ [
+ { type: "text-delta", delta: "COMPACTED SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ // Step 1 (post-compaction) — the turn CONTINUES:
+ [
+ { type: "text-delta", delta: "all done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ const { events, onEvent } = collectEvents();
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-inflight",
+ text: "keep working overnight",
+ onEvent,
+ modelName: "test/model",
+ });
+
+ // 1) The conversationCompacted event fired mid-turn.
+ expect(compactedEvents).toHaveLength(1);
+ expect(compactedEvents[0]?.conversationId).toBe("conv-inflight");
+ expect(compactedEvents[0]?.messagesSummarized).toBeGreaterThan(0);
+ expect(compactedEvents[0]?.messagesKept).toBe(10);
+
+ // 2) The store history was replaced: it now begins with the system summary
+ // message, and the OLDEST seed messages are gone (summarized). The most
+ // recent messages are retained (keepLastN = 10), so some later seed
+ // messages may survive — that is correct.
+ const stored = store.data.get("conv-inflight") ?? [];
+ expect(stored.length).toBeGreaterThan(0);
+ expect(stored[0]?.role).toBe("system");
+ expect(stored[0]?.chunks[0]).toMatchObject({ type: "text" });
+ const firstText = (stored[0]?.chunks[0] as { text: string } | undefined)?.text ?? "";
+ expect(firstText).toContain("COMPACTED SUMMARY");
+ // The earliest seed messages were summarized away (not retained).
+ expect(
+ stored.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "seed message 0")),
+ ).toBe(false);
+
+ // 3) The turn CONTINUED after compaction: 3 provider calls happened
+ // (step 0, compaction summary, step 1) and the final assistant text
+ // was produced + persisted.
+ expect(capturedMessages).toHaveLength(3);
+ const step1Messages = capturedMessages[2] ?? [];
+ // Step 1 saw the COMPACTED history: it must start with the summary
+ // system message, NOT the original seed/user prefix.
+ expect(step1Messages[0]?.role).toBe("system");
+
+ const turnSealed = events.some((e) => e.type === "turn-sealed");
+ expect(turnSealed).toBe(true);
+ });
+
+ it("does NOT trigger when the step usage is below the threshold (history unchanged, no event)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-below", 15);
+
+ // contextWindow 1000 → threshold 850. Step usage 100 < 850 → no trigger.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 100, outputTokens: 5 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-below",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // No compaction event, and only 2 provider calls (no summary call).
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(2);
+ // The seed messages are still the start of history (uncompacted).
+ const stored = store.data.get("conv-below") ?? [];
+ expect(stored[0]?.chunks[0]).toMatchObject({ type: "text", text: "seed message 0" });
+ });
+
+ it("does NOT trigger when auto-compact is disabled (compact percent = 0)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-disabled", 15);
+ await store.setCompactPercent("conv-disabled", 0);
+
+ // Usage would exceed the default threshold, but percent=0 disables it.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-disabled",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(2);
+ });
+
+ it("does NOT trigger on a text-only turn (no tool calls → no next step → no boundary)", async () => {
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-textonly", 15);
+
+ // Single text-only step with high usage — but no tool calls → the turn
+ // ends → there is no step boundary to compact at (post-seal handles it).
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "text-delta", delta: "final answer" },
+ { type: "usage", usage: { inputTokens: 950, outputTokens: 10 } },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-textonly",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // No in-flight compaction (only 1 provider call — the turn ended).
+ expect(compactedEvents).toHaveLength(0);
+ expect(capturedMessages).toHaveLength(1);
+ });
+
+ it("the manual compaction SERVICE still refuses while a conversation is generating, but in-flight compaction runs anyway", async () => {
+ // This documents the two-path design: compact() (the service) guards on
+ // activeConversations and refuses mid-turn; the in-flight path bypasses
+ // that guard (it IS the mid-turn path) using performCompaction directly.
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-twopath", 15);
+
+ const { provider } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+ const activeConversations = new Set<string>();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ // The compaction SERVICE shares the orchestrator's activeConversations set;
+ // build it against the SAME set so the guard reflects reality.
+ const compactionService = createCompactionService(
+ {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: () => {},
+ },
+ activeConversations,
+ );
+
+ // Drive a turn that triggers in-flight compaction. We can't easily inspect
+ // activeConversations mid-turn, so we assert the observable contract:
+ // in-flight compaction produced an event (it ran WHILE active), and the
+ // store was compacted.
+ await orchestrator.handleMessage({
+ conversationId: "conv-twopath",
+ text: "hi",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ expect(compactedEvents).toHaveLength(1);
+ const stored = store.data.get("conv-twopath") ?? [];
+ expect(stored[0]?.role).toBe("system");
+
+ // After the turn settles (idle), the manual service CAN compact (no longer
+ // active) — and it succeeds (history is compactable again only if long
+ // enough; here it is short post-compaction, so it reports too-short, which
+ // proves the service path is reachable and its guard is the ONLY reason it
+ // would have refused mid-turn).
+ const manual = await compactionService.compact("conv-twopath");
+ // Post-compaction the history is short (summary + ~10 + turn tail) → the
+ // service reports an error (too short / threshold), NOT "generating".
+ expect("error" in manual).toBe(true);
+ if ("error" in manual) {
+ expect(manual.error).not.toBe("conversation is generating");
+ }
+ });
+
+ it("steering messages are persisted and survive in-flight compaction — the store and the LLM's context stay aligned (Bug A + B)", async () => {
+ // Regression test for the two critical bugs:
+ // A) drainSteering injected steering into the kernel's in-memory messages
+ // but never persisted it → the user could never see it, and
+ // compaction (loading the store) scrubbed it.
+ // B) compaction sliced the store and the kernel's messages independently;
+ // the unpersisted steering offset the slices → DB and LLM dropped
+ // DIFFERENT messages (structural divergence).
+ // Fix: drainSteering persists (awaited); compaction uses the kernel's LIVE
+ // messages array, so the store write and the kernel's replacement use the
+ // SAME recent slice → aligned, and the steering is retained.
+ const store = createInMemoryStore();
+ seedHistory(store, "conv-align", 15); // > keepLastN(10) → compactable
+ const queue = createMessageQueueService({
+ id: () => `q-${Math.random().toString(36).slice(2, 8)}`,
+ now: () => 1000,
+ notify: () => {},
+ });
+ queue.enqueue("conv-align", "STEER MID-TURN"); // drained at step 0's boundary
+
+ // contextWindow 1000, percent 85 → threshold 850. Step 0 usage 900 → fire.
+ const { provider, capturedMessages } = createScriptedCapturingProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} },
+ { type: "usage", usage: { inputTokens: 900, outputTokens: 10 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ // Compaction summary call:
+ [
+ { type: "text-delta", delta: "ALIGN SUMMARY" },
+ { type: "finish", reason: "stop" },
+ ],
+ // Step 1 (post-compaction) — the turn continues:
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const compactedEvents: ConversationCompactedPayload[] = [];
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [echoTool()],
+ applyToolsFilter: identityApplyToolsFilter,
+ resolveModel: () => ({ provider, model: "model" }),
+ resolveModelInfo: async () => ({ id: "test/model", contextWindow: 1000 }),
+ resolveQueue: () => queue,
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook === conversationCompacted) {
+ compactedEvents.push(payload as ConversationCompactedPayload);
+ }
+ },
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-align",
+ text: "keep working overnight",
+ onEvent: () => {},
+ modelName: "test/model",
+ });
+
+ // Bug A: the steering message was PERSISTED to the store (the user CAN see
+ // it). It is either retained in the kept recent slice or captured in the
+ // summary; either way it must be present in the store, not lost.
+ expect(compactedEvents).toHaveLength(1);
+ const stored = store.data.get("conv-align") ?? [];
+ // The compacted history begins with the summary system message.
+ expect(stored[0]?.role).toBe("system");
+ expect((stored[0]?.chunks[0] as { text?: string } | undefined)?.text).toContain(
+ "ALIGN SUMMARY",
+ );
+ // The steering message survived in the kept recent slice (it was the most
+ // recent user message before compaction, so it is within keepLastN=10).
+ const storedSteering = stored.find(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(storedSteering).toBeDefined();
+
+ // Bug B: the store and the LLM's context are ALIGNED. The provider's
+ // post-compaction call (captured[2]) is the kernel's working history AFTER
+ // compaction replaced it. The store was written with the SAME compacted
+ // history, then step 1's assistant output was appended on top. So the
+ // kernel's view (captured[2]) must be an exact PREFIX of the store — same
+ // summary, same recent slice, same steering at the same index. (Before the
+ // fix, the store dropped the steering while the kernel kept it, so the two
+ // diverged structurally.)
+ const step1Messages = capturedMessages[2] ?? [];
+ expect(step1Messages[0]?.role).toBe("system"); // summary heads both
+ const kernelSteering = step1Messages.find(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(kernelSteering).toBeDefined();
+ // The kernel's post-compaction history is an exact PREFIX of the store
+ // (the store then has step 1's appended assistant output after it). Same
+ // length, same roles in order, same steering index => structural alignment.
+ expect(stored.length).toBeGreaterThanOrEqual(step1Messages.length);
+ const storePrefix = stored.slice(0, step1Messages.length);
+ expect(storePrefix).toHaveLength(step1Messages.length);
+ for (let i = 0; i < step1Messages.length; i++) {
+ expect(storePrefix[i]?.role).toBe(step1Messages[i]?.role);
+ }
+ const storeSteerIdx = storePrefix.findIndex(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ const kernelSteerIdx = step1Messages.findIndex(
+ (m) =>
+ m.role === "user" && m.chunks.some((c) => c.type === "text" && c.text === "STEER MID-TURN"),
+ );
+ expect(kernelSteerIdx).toBe(storeSteerIdx);
+ expect(kernelSteerIdx).toBeGreaterThanOrEqual(0);
+ });
+});
+
+describe("per-turn memory telemetry", () => {
+ function capturingLogger(): { logger: Logger; records: LogRecord[] } {
+ let id = 0;
+ const deps: LogDeps = { now: () => 1000 + id++, newId: () => `id-${id++}` };
+ const records: LogRecord[] = [];
+ const sink: LogSink = { emit: (r) => records.push(r) };
+ return { logger: createLogger({ extensionId: "session-orchestrator" }, sink, deps), records };
+ }
+
+ it("logs before/after samples around the stream, tagged with conversationId + turnId", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const { logger, records } = capturingLogger();
+
+ const samples: MemorySample[] = [
+ { rss: 100, heapUsed: 10, heapTotal: 20, external: 1, arrayBuffers: 0 },
+ { rss: 250, heapUsed: 30, heapTotal: 20, external: 1, arrayBuffers: 5 },
+ ];
+ let sampleIdx = 0;
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ logger,
+ sampleMemory: () => samples[sampleIdx++] as MemorySample,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-mem",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ const beforeLogs = records.filter((r) => r.kind === "log" && r.msg === "memory:turn:before");
+ const afterLogs = records.filter((r) => r.kind === "log" && r.msg === "memory:turn:after");
+ expect(beforeLogs).toHaveLength(1);
+ expect(afterLogs).toHaveLength(1);
+
+ // Both samples carry the turn's conversationId + turnId correlation.
+ const turnId = captured[0]?.turnId;
+ expect(turnId).toMatch(/^turn-/);
+ const before = beforeLogs[0] as Extract<LogRecord, { kind: "log" }>;
+ const after = afterLogs[0] as Extract<LogRecord, { kind: "log" }>;
+ expect(before.conversationId).toBe("conv-mem");
+ expect(before.turnId).toBe(turnId);
+ expect(after.conversationId).toBe("conv-mem");
+ expect(after.turnId).toBe(turnId);
+
+ // Before sample carries absolute MB values.
+ expect(before.attributes?.rssMB).toBe(0); // 100 bytes rounds to 0 MB
+ // After sample carries absolute + delta (delta rss = 150 bytes → 0 MB).
+ expect(after.attributes?.rssMB).toBe(0);
+ // deltaRssMB is the rounded delta (150 bytes → 0 MB).
+ expect(after.attributes).toHaveProperty("deltaRssMB");
+ });
+
+ it("emits no memory logs when sampleMemory is not injected (degrades off)", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+ const { logger, records } = capturingLogger();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ logger,
+ // sampleMemory intentionally omitted
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-mem",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ const memLogs = records.filter(
+ (r) => r.kind === "log" && typeof r.msg === "string" && r.msg.startsWith("memory:turn"),
+ );
+ expect(memLogs).toHaveLength(0);
+ });
+
+ it("getActiveConversationCount tracks in-flight turns", async () => {
+ const store = createInMemoryStore();
+ const result: RunTurnResult = {
+ messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }],
+ usage: { inputTokens: 1, outputTokens: 1 },
+ finishReason: "stop",
+ };
+ // A runTurn that blocks until the test releases it — keeps the turn
+ // active so getActiveConversationCount reflects an in-flight turn.
+ let release: () => void = () => {};
+ const blocked = new Promise<void>((resolve) => {
+ release = resolve;
+ });
+ const blockingRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => {
+ await blocked;
+ return result;
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: blockingRunTurn,
+ });
+
+ expect(orchestrator.getActiveConversationCount()).toBe(0);
+ const done = orchestrator.handleMessage({
+ conversationId: "conv-active",
+ text: "hi",
+ onEvent: () => {},
+ });
+ // Give the detached turn a tick to register as active.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(orchestrator.getActiveConversationCount()).toBe(1);
+
+ release();
+ await done;
+ expect(orchestrator.getActiveConversationCount()).toBe(0);
+ });
});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 4aa77f7..a2e141a 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -1,78 +1,170 @@
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,
+ ImageInput,
+ 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 { ConcurrencyLimiter } from "@dispatch/provider-concurrency";
+import { wrapProviderWithConcurrency } from "@dispatch/provider-concurrency";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { createMetricsAccumulator } from "./metrics.js";
import {
- buildUserMessage,
- defaultDispatchPolicy,
- delayFor,
- generateTurnId,
- resolveModelName,
- resolveReasoningEffort,
+ buildUserMessage,
+ defaultDispatchPolicy,
+ delayFor,
+ generateTurnId,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
+ resolveModelName,
+ resolveReasoningEffort,
} from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
+// --- Vision handoff (lazy, optional) ---
+
+/**
+ * Minimal contract the vision-handoff service satisfies. Defined here (not
+ * imported from the vision-handoff package) so the orchestrator has NO
+ * compile-time dependency on it — the service is resolved lazily at runtime
+ * (like the message-queue / system-prompt services), and the feature degrades
+ * off cleanly when the extension isn't loaded (images pass through unchanged,
+ * which is correct for vision-capable models and a no-op for text-only turns).
+ *
+ * `prepareForProvider` transforms a message list for the provider: if the
+ * active model is vision-capable, messages pass through unchanged; otherwise
+ * image chunks are replaced with numbered placeholders (telling the model to
+ * call `consult_vision`) and the images are registered for tool access.
+ */
+export interface VisionHandoffService {
+ /**
+ * Store images to tmp files and return compact URLs. Each input image's data
+ * URL is saved to a tmp file and replaced with a compact HTTP path so the
+ * persisted conversation store holds a tiny string, not megabytes of base64.
+ * When `saveImageToTmp` is not configured, data URLs pass through unchanged.
+ */
+ readonly storeImages: (
+ conversationId: string,
+ images: readonly ImageInput[],
+ ) => Promise<readonly ImageInput[]>;
+
+ /** Delete all tmp images for a conversation (on close). Best-effort. */
+ readonly purgeConversationImages: (conversationId: string) => Promise<void>;
+
+ readonly prepareForProvider: (
+ messages: readonly ChatMessage[],
+ currentModelName: string | undefined,
+ opts?: {
+ readonly conversationId?: string;
+ readonly imageLimit?: number;
+ readonly signal?: AbortSignal;
+ readonly logger?: Logger;
+ },
+ ) => Promise<readonly ChatMessage[]>;
+}
+
+/**
+ * Local handle for the vision-handoff service, keyed by the same ID the
+ * vision-handoff extension registers under (`"vision-handoff/service"`). Defined
+ * locally (not imported) so the orchestrator has no compile-time dependency on
+ * the vision-handoff package — the service is resolved lazily at runtime, and
+ * the feature degrades off cleanly when the extension isn't loaded.
+ */
+export const visionHandoffLocalHandle: ServiceHandle<VisionHandoffService> =
+ defineService<VisionHandoffService>("vision-handoff/service");
+
// --- 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;
+ /**
+ * Images attached to this turn (e.g. user-pasted screenshots). Each is
+ * appended as an `image` chunk on the persisted user message. For a
+ * vision-capable model the images pass through to the provider natively; for
+ * a non-vision model the vision handoff transcribes them to text first.
+ * Optional — omit for a text-only turn.
+ */
+ readonly images?: readonly ImageInput[];
+ 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;
+ /**
+ * An EXPLICIT system-prompt override. When provided (a string, including the
+ * empty string), it is sent to the provider AS-IS and the system-prompt
+ * SERVICE is bypassed entirely (no template construction, no
+ * persist/reuse). This is how a caller that owns its own prompt (e.g. the
+ * heartbeat extension) drives a turn without the templated workspace prompt.
+ * Omitted/`undefined` = the existing behavior (construct/reuse via the
+ * system-prompt service when loaded).
+ */
+ readonly systemPrompt?: string;
+ /**
+ * A human-readable title for the conversation tab. When provided, it is
+ * persisted via `setConversationTitle` AFTER the new-conversation workspace
+ * setup resolves (so the `meta === null` newness detection still fires and
+ * `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ * construction are NOT skipped) and BEFORE the first message append (so the
+ * append's auto-title does not overwrite it). Omit to keep the auto-derived
+ * title. The caller is responsible for trimming/validation.
+ */
+ readonly title?: 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;
+ /**
+ * Images attached (the steering / opening message analog of
+ * `StartTurnInput.images`). Threaded to `startTurn` when the conversation is
+ * idle (the message starts a turn). Additive optional.
+ */
+ readonly images?: readonly ImageInput[];
+ /** 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 +176,55 @@ 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[];
+}
+
+/**
+ * Result of `SessionOrchestrator.cancelQueuedMessage`. `cancelled` is true when
+ * a message with the given id was found in the conversation's queue and removed
+ * (it will never run — never delivered as steering, never carried into a new
+ * turn). `cancelled` is false when the message was not in the queue (already
+ * drained/delivered, never existed, unknown conversation) OR when the
+ * message-queue extension isn't loaded (degraded — feature off). `queue` is the
+ * post-cancel snapshot (empty when no queue extension is loaded).
+ */
+export interface CancelQueuedMessageResult {
+ readonly cancelled: 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 +233,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 +253,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 +273,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 +291,217 @@ 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;
+ /**
+ * Cancel (remove) a SINGLE queued message by id so it never runs. The single
+ * entry transports call to cancel a queued steering message. Resolves the
+ * message-queue service lazily (same as `enqueue`); when the extension isn't
+ * loaded the call degrades to `{ cancelled: false, queue: [] }`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered)
+ * returns `{ cancelled: false, ... }` without error.
+ */
+ cancelQueuedMessage(input: {
+ readonly conversationId: string;
+ readonly messageId: string;
+ }): CancelQueuedMessageResult;
+ subscribe(conversationId: string, listener: TurnEventListener): () => void;
+ isActive(conversationId: string): boolean;
+ /**
+ * The number of conversations currently driving a turn (in the
+ * `activeConversations` set). Used by host-bin's periodic memory telemetry
+ * to tag each RSS sample with the active-conversation count, so growth can
+ * be attributed to the streaming/turn path vs an idle baseline.
+ */
+ getActiveConversationCount(): number;
+ /**
+ * 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;
+ /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
+ systemPrompt?: string;
+ /** Images attached to this turn — see {@link StartTurnInput.images}. */
+ images?: readonly ImageInput[];
+ /** Conversation tab title — see {@link StartTurnInput.title}. */
+ title?: 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;
+ /**
+ * Lazily resolves the concurrency limiter, or `undefined` when the
+ * provider-concurrency extension isn't loaded (no concurrency limiting —
+ * feature degrades off). When present, each resolved provider is wrapped so
+ * that a concurrency slot is acquired before the stream starts and released
+ * when the stream completes. Lazy so activation order doesn't matter.
+ */
+ readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ /**
+ * Lazily resolves the vision-handoff service, or `undefined` when the
+ * vision-handoff extension isn't loaded. Used to transcribe image chunks to
+ * text for non-vision models before they reach the provider (so a text-only
+ * model can still reason about pasted/code images). When `undefined`, images
+ * pass through unchanged (correct for vision-capable models; a text-only model
+ * would then receive image content its API may reject — the feature degrades
+ * off cleanly for text-only turns since there are no images). Lazy so
+ * activation order doesn't matter; called per-turn.
+ */
+ readonly resolveVisionHandoff?: () => VisionHandoffService | 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;
+ /**
+ * Optional process.memoryUsage() sampler, injected for testability. When
+ * present, the orchestrator captures a sample immediately before and after
+ * each turn's stream completes (`deps.runTurn`) and logs the per-turn delta
+ * tagged with conversationId + turnId — correlating RSS growth with the
+ * streaming path (the prime leak suspect). When absent (undefined), no
+ * per-turn memory telemetry is emitted (feature degrades off cleanly).
+ * Pure decision logic stays unchanged; this is additive observability.
+ */
+ readonly sampleMemory?: () => MemorySample;
+ /** 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 +514,1262 @@ 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,
+ systemPromptOverride: string | undefined,
+ images: readonly ImageInput[] | undefined,
+ title: string | undefined,
+ ): void {
+ const turnId = generateTurnId();
+ const promptStartedAt = deps.now?.() ?? Date.now();
+ 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.
+ //
+ // An explicit `title` (e.g. the CLI `--title` flag) is persisted HERE,
+ // AFTER the workspace setup resolves — deliberately NOT before the turn.
+ // Setting it earlier (e.g. in the HTTP route) would pre-create the meta
+ // row, make `meta !== null`, and fool this newness check into skipping
+ // `ensureWorkspace` / `setWorkspaceId` / first-turn system-prompt
+ // construction. By deferring it to here, the title lands after the
+ // workspace is assigned but BEFORE the first message append (so the
+ // append's auto-title sees a non-"Untitled" title and preserves it).
+ 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);
+ }
+ if (title !== undefined) {
+ // Best-effort: a title-set failure must NOT break the turn (the
+ // workspace setup above already succeeded). Log and continue — the
+ // append's auto-derived title applies instead.
+ try {
+ await deps.conversationStore.setConversationTitle(conversationId, title);
+ } catch (err) {
+ deps.logger?.child({ conversationId }).warn("orchestrator: title set failure", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return meta === null;
+ })();
+
+ // 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);
+
+ // Store images to tmp files (compact URLs) BEFORE building the user
+ // message so the persisted chunks hold tiny URL references, not
+ // megabytes of base64 data URLs. When the vision-handoff service isn't
+ // loaded, images pass through unchanged (backward compatible).
+ const visionHandoffForStore = deps.resolveVisionHandoff?.();
+ const storedImages =
+ visionHandoffForStore !== undefined && images !== undefined
+ ? await visionHandoffForStore.storeImages(conversationId, images)
+ : images;
+
+ const userMsg = buildUserMessage(text, storedImages);
+
+ // 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();
+ }
+
+ // Wrap the resolved provider with concurrency limiting when the
+ // provider-concurrency extension is loaded. The slot is acquired
+ // before the stream starts (before the HTTP request) and released
+ // when the stream completes (after all tokens are generated). The
+ // promptStartedAt (turn start time) is used for oldest-agent-first
+ // scheduling when multiple agents are queued.
+ //
+ // Status lifecycle with concurrency: "active" is emitted early (in
+ // payloadPromise.then, before this code runs). If acquire() blocks,
+ // onQueued emits "queued" (broadcast-only — persisted status stays
+ // "active"). When the slot is granted, onAcquired emits "active"
+ // again, transitioning "queued" → "active" so the FE switches from
+ // the loading ring back to dots. A request that gets a slot
+ // immediately never emits "queued" — onAcquired fires right after
+ // the early "active", which is a harmless no-op re-broadcast.
+ const limiter = deps.resolveConcurrencyLimiter?.();
+ if (limiter !== undefined) {
+ const emitStatus = (status: "queued" | "active"): void => {
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status,
+ workspaceId,
+ });
+ });
+ };
+ provider = wrapProviderWithConcurrency(
+ provider,
+ limiter,
+ conversationId,
+ workspaceId,
+ promptStartedAt,
+ () => emitStatus("queued"),
+ () => emitStatus("active"),
+ );
+ }
+
+ const baseTools = deps.resolveTools();
+ const assembled = await deps.applyToolsFilter({
+ tools: baseTools,
+ conversationId,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ // Thread the turn's abort signal into the filter chain so a filter
+ // awaiting slow I/O (the MCP tools filter connecting to MCP servers)
+ // can be interrupted by POST /conversations/:id/stop instead of
+ // blocking the turn until its own timeout fires.
+ signal: controller.signal,
+ });
+ 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).
+ //
+ // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a
+ // string, incl. empty), it is sent to the provider AS-IS and the
+ // system-prompt service is bypassed entirely (no construct/reuse).
+ // This lets a caller that owns its own prompt (e.g. the heartbeat
+ // extension) drive a turn without the templated workspace prompt.
+ let systemPrompt: string | undefined;
+ if (systemPromptOverride !== undefined) {
+ systemPrompt = systemPromptOverride;
+ } else {
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ 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
+ : async (): Promise<readonly ChatMessage[]> => {
+ const queued = queue.drain(conversationId);
+ if (queued.length === 0) return [];
+ const steerText = queued.map((q) => q.text).join("\n\n");
+ const steeringMessage: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: steerText }],
+ };
+ // Persist the injected steering message to the store as part
+ // of the SAME critical section as the injection, so it is
+ // never lost. Without this, the message would live only in
+ // the kernel's in-memory messages array (never persisted),
+ // so a user could never see it — and in-flight compaction
+ // (which loads the store) would scrub it. A fire-and-forget
+ // append would race with the next step's `onStepComplete`
+ // append and collide on the store's seq counter, so we
+ // `await` it (the kernel awaits drainSteering). Errors
+ // propagate (a DB failure ends the turn, matching
+ // `onStepComplete`'s behavior).
+ await deps.conversationStore.append(conversationId, [steeringMessage]);
+ emitToHub(conversationId, {
+ type: "steering",
+ conversationId,
+ turnId,
+ text: steerText,
+ });
+ return [steeringMessage];
+ };
+
+ // Vision handoff: transform the message list for the provider. When the
+ // active model is vision-capable, images pass through natively (no-op).
+ // When it is NOT vision-capable, image chunks are transcribed to text
+ // descriptions via a vision-capable model — so a text-only model can
+ // still reason about images. The PERSISTED user message keeps the
+ // original image chunks (appended below); only the provider's view is
+ // transcribed. When the vision-handoff service isn't loaded, images pass
+ // through unchanged (correct for vision models; text-only models would
+ // then receive image content their API may reject — degrades off cleanly
+ // for text-only turns with no images).
+ const visionHandoff = deps.resolveVisionHandoff?.();
+ let providerMessages: readonly ChatMessage[] = [...history, userMsg];
+ if (visionHandoff !== undefined) {
+ const visionSettings = await deps.conversationStore.getVisionSettings();
+ providerMessages = await visionHandoff.prepareForProvider(
+ providerMessages,
+ effectiveModelName,
+ {
+ conversationId,
+ imageLimit: visionSettings.imageLimit,
+ signal: controller.signal,
+ ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
+ },
+ );
+ }
+
+ const opts: RunTurnInput = {
+ provider,
+ messages: providerMessages,
+ 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 } : {}),
+ // In-flight compaction: at every tool-result boundary the kernel
+ // calls this with the step's usage + the running messages. When the
+ // context size exceeds the compact-percent threshold (percent of the
+ // model's context window), the old history is summarized and
+ // replaced with [summary, ...recent] — mid-turn, without stopping —
+ // so a long-running turn (e.g. left overnight) does not run out of
+ // context. This is DISTINCT from the post-seal auto-compact below:
+ // that one runs AFTER the turn ends (preparing the next turn) and
+ // refuses while the conversation is active; this one runs DURING the
+ // turn (saving the running turn) and uses the live step usage (not
+ // persisted metrics, which are only written at turn end). When the
+ // threshold is not exceeded, compaction is disabled (percent 0), or
+ // the model's context window is unknown, it returns void and the
+ // kernel keeps its history unchanged (a strict no-op).
+ onStepBoundary: async ({ stepUsage, messages }) => {
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 0) return; // auto-compact disabled
+ // contextSize mirrors the persisted definition: this step's
+ // inputTokens + outputTokens (the prompt the NEXT step would
+ // inherit, grown by this step's output).
+ const contextSize = stepUsage.inputTokens + stepUsage.outputTokens;
+ if (effectiveModelName === undefined || deps.resolveModelInfo === undefined) return;
+ const info = await deps.resolveModelInfo(effectiveModelName);
+ if (info?.contextWindow === undefined) return;
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return; // threshold not exceeded
+
+ const keepLastN = DEFAULT_KEEP_LAST_N;
+ turnLogger?.info("compaction:in-flight", {
+ conversationId,
+ turnId,
+ contextSize,
+ threshold,
+ percent,
+ });
+ const outcome = await performCompaction(
+ {
+ conversationStore: deps.conversationStore,
+ resolveProvider: deps.resolveProvider,
+ ...(deps.resolveModel !== undefined ? { resolveModel: deps.resolveModel } : {}),
+ ...(deps.resolveSystemPrompt !== undefined
+ ? { resolveSystemPrompt: deps.resolveSystemPrompt }
+ : {}),
+ ...(deps.resolveConcurrencyLimiter !== undefined
+ ? { resolveConcurrencyLimiter: deps.resolveConcurrencyLimiter }
+ : {}),
+ ...(deps.logger !== undefined ? { logger: deps.logger } : {}),
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
+ // emit is required by performCompaction; fall back to a no-op
+ // when the orchestrator was constructed without one (tests).
+ emit: deps.emit ?? noopEmit,
+ },
+ conversationId,
+ // Pass the kernel's LIVE messages array (not a store reload):
+ // it includes mid-turn steering messages (now persisted by
+ // drainSteering) and is the authoritative prompt state. Using it
+ // for the split keeps the store write and the kernel's
+ // replacement aligned (same recent slice) — no DB↔LLM divergence.
+ { keepLastN, modelName: effectiveModelName, messages },
+ );
+ if ("error" in outcome) {
+ turnLogger?.warn("compaction:in-flight:skipped", {
+ conversationId,
+ turnId,
+ error: outcome.error,
+ });
+ return; // too short / empty summary / unknown model → no replacement
+ }
+ // Return the compacted history the kernel should adopt. This is
+ // EXACTLY what performCompaction wrote to the store
+ // ([summary, ...recent-from-live]), so the kernel's working
+ // history and the store stay byte-aligned.
+ return outcome.compactedMessages;
+ },
+ };
+
+ // 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]);
+
+ // Per-turn memory telemetry: capture a sample immediately BEFORE the
+ // stream starts. Paired with the post-stream sample below, this
+ // measures the streaming path's memory footprint per turn (the prime
+ // leak suspect — AI-SDK streaming buffers + per-turn message arrays).
+ // Tagged with conversationId + turnId via the turnLogger's correlation
+ // context. Additive observability only — does not alter the stream.
+ const sampleMem = deps.sampleMemory;
+ const memBefore = sampleMem?.();
+ if (memBefore !== undefined) {
+ turnLogger?.debug("memory:turn:before", memorySampleAttributes(memBefore));
+ }
+
+ 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;
+ },
+ });
+
+ // Per-turn memory telemetry: capture a sample immediately AFTER the
+ // stream completes and log the per-turn delta vs `memBefore`. A
+ // positive rss delta on a sealed turn flags memory retained by the
+ // streaming path (the leak we are localizing). No I/O beyond the
+ // injected sampler; pure delta computation via memoryDelta(). The
+ // delta attributes carry a `delta` prefix so the absolute "after"
+ // values and the per-turn delta coexist without key collision.
+ if (memBefore !== undefined) {
+ const memAfter = sampleMem?.();
+ if (memAfter !== undefined) {
+ turnLogger?.info("memory:turn:after", {
+ ...memorySampleAttributes(memAfter),
+ ...memorySampleAttributes(memoryDelta(memBefore, memAfter), "delta"),
+ });
+ }
+ }
+
+ // 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,
+ systemPrompt,
+ images,
+ title,
+ }) {
+ if (activeTurns.has(conversationId)) {
+ return { started: false, reason: "already-active" };
+ }
+ runTurnDetached(
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId ?? "default",
+ systemPrompt,
+ images,
+ title,
+ );
+ const turn = activeTurns.get(conversationId);
+ const turnId = turn !== undefined ? turn.turnId : "";
+ return { started: true, turnId };
+ },
+
+ enqueue({ conversationId, text, workspaceId, computerId, images }) {
+ const result = orchestrator.startTurn({
+ conversationId,
+ text,
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(images !== undefined ? { images } : {}),
+ });
+ 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 };
+ },
+
+ cancelQueuedMessage({ conversationId, messageId }) {
+ // When the message-queue extension isn't loaded this degrades: nothing to
+ // cancel, empty snapshot (feature off). Mirrors `enqueue`'s degraded path.
+ const queue = deps.resolveQueue?.();
+ if (queue === undefined) {
+ return { cancelled: false, queue: [] };
+ }
+ const beforeLen = queue.getQueue(conversationId).length;
+ const snapshot = queue.cancel(conversationId, messageId);
+ const cancelled = snapshot.length < beforeLen;
+ return { cancelled, 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);
+ },
+
+ getActiveConversationCount() {
+ return activeConversations.size;
+ },
+
+ 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");
+ // Purge tmp images for this conversation (best-effort, fire-and-forget).
+ const vh = deps.resolveVisionHandoff?.();
+ if (vh !== undefined) void vh.purgeConversationImages(conversationId);
+ 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,
+ systemPrompt,
+ images,
+ title,
+ }) {
+ const turnInput: StartTurnInput = {
+ conversationId,
+ text,
+ ...(modelName !== undefined ? { modelName } : {}),
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ ...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
+ };
+ 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();
+ }
+
+ // Wrap with concurrency limiting (same as the main turn path).
+ const warmLimiter = deps.resolveConcurrencyLimiter?.();
+ if (warmLimiter !== undefined) {
+ const warmWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ provider = wrapProviderWithConcurrency(
+ provider,
+ warmLimiter,
+ conversationId,
+ warmWorkspaceId,
+ deps.now?.() ?? Date.now(),
+ );
+ }
+
+ 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;
+/**
+ * No-op emit used as a fallback when the orchestrator is constructed without an
+ * `emit` (some tests). `performCompaction` requires a non-optional `emit` (it
+ * emits `conversationCompacted`); the in-flight path degrades to emitting
+ * nothing rather than skipping compaction entirely. Generic-typed so it
+ * satisfies `PerformCompactionDeps["emit"]` for any hook payload type.
+ */
+const noopEmit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void =
+ () => {};
+
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");
+}
+
+/**
+ * Deps for {@link performCompaction} — the subset of `SessionOrchestratorDeps`
+ * needed to summarize old history, fork it to an archive, and replace it with
+ * a summary + recent messages. Structural so both the compaction SERVICE
+ * (`compact`, manual + post-seal auto) and the IN-FLIGHT compaction path (the
+ * turn loop's `onStepBoundary`) can call the same shared core without duplicating
+ * the summarization/fork/replace/emit logic. The active-conversation guard and
+ * the threshold check are the CALLERS' policy (they differ between the two
+ * paths) and are NOT performed here.
+ */
+interface PerformCompactionDeps {
+ readonly conversationStore: ConversationStore;
+ readonly resolveProvider: () => ProviderContract;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
+ readonly resolveConcurrencyLimiter?: () => ConcurrencyLimiter | undefined;
+ readonly logger?: Logger;
+ readonly now?: () => number;
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+}
+
+/** Result of a successful {@link performCompaction}. */
+interface PerformCompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+ /**
+ * The system-role summary message that heads the compacted history
+ * (`[summaryMessage, ...recentKept]`). Returned so the in-flight caller can
+ * build the kernel's replacement history with the SAME summary object.
+ */
+ readonly summaryMessage: ChatMessage;
+ /**
+ * The full compacted history `[summaryMessage, ...recentKept]` exactly as
+ * written to the store. The in-flight caller returns this to the kernel so
+ * the kernel's working history and the store stay byte-aligned (the same
+ * `recentKept` slice — taken from the caller-supplied live `messages` — is
+ * used for BOTH the store write and this return value).
+ */
+ readonly compactedMessages: readonly ChatMessage[];
+}
+
+/**
+ * The shared compaction core: summarize the oldest `history.length -
+ * keepLastN` messages via a provider stream, fork the full pre-compaction
+ * history to an archive (non-destructive), and replace the live history with
+ * `[summaryMessage, ...recentKept]`. Emits `conversationCompacted`. Returns the
+ * result (incl. the `summaryMessage` + the `compactedMessages`) or an error.
+ *
+ * History source: when `opts.messages` is provided (the in-flight path), it is
+ * used as the authoritative history — this is the kernel's LIVE messages array,
+ * which includes mid-turn steering messages (and the vision-transformed
+ * provider view) that a store reload could miss (the steering persist may not
+ * have completed, or — before this fix — was never done at all). Using the live
+ * array keeps the store write and the kernel's replacement aligned (same
+ * `recentKept` slice), avoiding the DB↔LLM structural divergence where
+ * independent slices dropped different messages. When `opts.messages` is
+ * omitted (the post-seal/manual `compact()` path — the turn has ended, so the
+ * store is stable), the history is loaded from the store.
+ *
+ * Performs NO active-conversation guard and NO threshold check — those are the
+ * callers' policy. No-ops (returns an error) when the conversation is too
+ * short to compact (≤ keepLastN messages) or the summary is empty.
+ */
+async function performCompaction(
+ deps: PerformCompactionDeps,
+ conversationId: string,
+ opts: {
+ readonly keepLastN?: number;
+ readonly modelName?: string;
+ /** The kernel's live messages array (in-flight path). Omit to load the store (post-seal/manual). */
+ readonly messages?: readonly ChatMessage[];
+ },
+): Promise<PerformCompactionResult | { readonly error: string }> {
+ // Use the caller-supplied live messages (in-flight) or load the store
+ // (post-seal/manual — the store is stable once the turn has ended).
+ const history = opts.messages ?? (await deps.conversationStore.load(conversationId));
+ const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
+
+ if (history.length <= keepLastN) {
+ return { error: "conversation too short to compact" };
+ }
+
+ // 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();
+ }
+
+ // Wrap with concurrency limiting (same as the main turn path).
+ const compactionLimiter = deps.resolveConcurrencyLimiter?.();
+ if (compactionLimiter !== undefined) {
+ const compactionWorkspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ provider = wrapProviderWithConcurrency(
+ provider,
+ compactionLimiter,
+ conversationId,
+ compactionWorkspaceId,
+ deps.now?.() ?? Date.now(),
+ );
+ }
+
+ // 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] + the recent kept messages. `toKeep`
+ // is sliced from the caller-supplied live `messages` (in-flight) — the SAME
+ // slice returned below as `compactedMessages` — so the store and the kernel's
+ // working history stay byte-aligned (same messages kept/dropped).
+ const summaryMessage: ChatMessage = {
+ role: "system",
+ chunks: [
+ {
+ type: "text",
+ text: `The following is a summary of the previous conversation:\n\n${summary}`,
+ },
+ ],
+ };
+
+ const compactedMessages: readonly ChatMessage[] = [summaryMessage, ...toKeep];
+ await deps.conversationStore.replaceHistory(conversationId, compactedMessages);
+ await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
+
+ deps.emit(conversationCompacted, {
+ conversationId,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ });
+
+ return {
+ summary,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ summaryMessage,
+ compactedMessages,
+ };
}
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" };
+ }
+
+ // Auto mode: check if contextSize exceeds percent of contextWindow.
+ // The threshold check is the caller's policy (uses persisted turn
+ // metrics) and is NOT performed by the shared `performCompaction` core.
+ 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" };
+ }
+
+ // Shared summarize + fork + replace + emit core (no active guard, no
+ // threshold — those are this caller's policy above). The length check
+ // ("conversation too short to compact") lives inside the core.
+ const outcome = await performCompaction(deps, conversationId, {
+ ...(opts?.keepLastN !== undefined ? { keepLastN: opts.keepLastN } : {}),
+ ...(opts?.modelName !== undefined ? { modelName: opts.modelName } : {}),
+ });
+ if ("error" in outcome) return { error: outcome.error };
+
+ const { summary, newConversationId, messagesSummarized, messagesKept } = outcome;
+ const result: CompactionResult = {
+ summary,
+ newConversationId,
+ messagesSummarized,
+ messagesKept,
+ };
+ return result;
+ },
+ };
}
diff --git a/packages/session-orchestrator/src/pure.test.ts b/packages/session-orchestrator/src/pure.test.ts
index 2cbe15f..d4fe84a 100644
--- a/packages/session-orchestrator/src/pure.test.ts
+++ b/packages/session-orchestrator/src/pure.test.ts
@@ -1,163 +1,267 @@
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,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
+ 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: "" });
+ });
+
+ it("appends image chunks after the text chunk when images are given", () => {
+ const msg = buildUserMessage("look at this", [
+ { url: "data:image/png;base64,aaa" },
+ { url: "data:image/jpeg;base64,bbb", mimeType: "image/jpeg" },
+ ]);
+ expect(msg.chunks).toHaveLength(3);
+ expect(msg.chunks[0]).toEqual({ type: "text", text: "look at this" });
+ expect(msg.chunks[1]).toEqual({ type: "image", url: "data:image/png;base64,aaa" });
+ expect(msg.chunks[2]).toEqual({
+ type: "image",
+ url: "data:image/jpeg;base64,bbb",
+ mimeType: "image/jpeg",
+ });
+ });
+
+ it("builds an image-only message when text is empty", () => {
+ const msg = buildUserMessage("", [{ url: "data:image/png;base64,zzz" }]);
+ expect(msg.chunks).toHaveLength(1);
+ expect(msg.chunks[0]).toEqual({ type: "image", url: "data:image/png;base64,zzz" });
+ });
+
+ it("includes mimeType when provided", () => {
+ const msg = buildUserMessage("hi", [
+ { url: "data:image/webp;base64,x", mimeType: "image/webp" },
+ ]);
+ expect((msg.chunks[1] as { mimeType?: string }).mimeType).toBe("image/webp");
+ });
+
+ it("omits mimeType when not provided", () => {
+ const msg = buildUserMessage("hi", [{ url: "https://example.com/x.png" }]);
+ expect((msg.chunks[1] as { mimeType?: string }).mimeType).toBeUndefined();
+ });
});
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);
+ });
+});
+
+describe("memorySampleAttributes", () => {
+ const sample: MemorySample = {
+ rss: 100 * 1024 * 1024, // 100 MB
+ heapUsed: 40 * 1024 * 1024,
+ heapTotal: 60 * 1024 * 1024,
+ external: 5 * 1024 * 1024,
+ arrayBuffers: 2 * 1024 * 1024,
+ };
+
+ it("formats fields as rounded MB with no prefix", () => {
+ const attrs = memorySampleAttributes(sample);
+ expect(attrs).toEqual({
+ rssMB: 100,
+ heapUsedMB: 40,
+ heapTotalMB: 60,
+ externalMB: 5,
+ arrayBuffersMB: 2,
+ });
+ });
+
+ it("namespaces keys with the given prefix", () => {
+ const attrs = memorySampleAttributes(sample, "delta");
+ expect(attrs).toEqual({
+ deltaRssMB: 100,
+ deltaHeapUsedMB: 40,
+ deltaHeapTotalMB: 60,
+ deltaExternalMB: 5,
+ deltaArrayBuffersMB: 2,
+ });
+ });
+
+ it("rounds fractional MB", () => {
+ const attrs = memorySampleAttributes({ ...sample, rss: 100.6 * 1024 * 1024 });
+ expect(attrs.rssMB).toBe(101);
+ });
+});
+
+describe("memoryDelta", () => {
+ const before: MemorySample = {
+ rss: 200 * 1024 * 1024,
+ heapUsed: 100 * 1024 * 1024,
+ heapTotal: 150 * 1024 * 1024,
+ external: 10 * 1024 * 1024,
+ arrayBuffers: 4 * 1024 * 1024,
+ };
+ const after: MemorySample = {
+ rss: 350 * 1024 * 1024,
+ heapUsed: 120 * 1024 * 1024,
+ heapTotal: 150 * 1024 * 1024,
+ external: 10 * 1024 * 1024,
+ arrayBuffers: 8 * 1024 * 1024,
+ };
+
+ it("computes signed after - before per field", () => {
+ const delta = memoryDelta(before, after);
+ expect(delta.rss).toBe(150 * 1024 * 1024);
+ expect(delta.heapUsed).toBe(20 * 1024 * 1024);
+ expect(delta.heapTotal).toBe(0);
+ expect(delta.external).toBe(0);
+ expect(delta.arrayBuffers).toBe(4 * 1024 * 1024);
+ });
+
+ it("is negative when memory dropped", () => {
+ const delta = memoryDelta(after, before);
+ expect(delta.rss).toBe(-150 * 1024 * 1024);
+ });
});
diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts
index a028cbe..489b140 100644
--- a/packages/session-orchestrator/src/pure.ts
+++ b/packages/session-orchestrator/src/pure.ts
@@ -1,12 +1,41 @@
import type {
- ChatMessage,
- ProviderContract,
- ReasoningEffort,
- ToolDispatchPolicy,
+ Attributes,
+ ChatMessage,
+ Chunk,
+ ImageInput,
+ ProviderContract,
+ ReasoningEffort,
+ ToolDispatchPolicy,
} from "@dispatch/kernel";
-export function buildUserMessage(text: string): ChatMessage {
- return { role: "user", chunks: [{ type: "text", text }] };
+/**
+ * Build the persisted user message for a turn. When `images` are provided, each
+ * is appended as an `image` chunk AFTER the text chunk, so the persisted message
+ * carries both the prompt text and the attached images (the frontend renders
+ * the images; vision-capable providers receive them natively; non-vision
+ * providers have them transcribed by the vision handoff before streaming).
+ *
+ * Pure: inputs → a ChatMessage, no I/O.
+ */
+export function buildUserMessage(text: string, images?: readonly ImageInput[]): ChatMessage {
+ const chunks: Chunk[] = [];
+ if (text.length > 0) {
+ chunks.push({ type: "text", text });
+ }
+ if (images !== undefined) {
+ for (const img of images) {
+ chunks.push({
+ type: "image",
+ url: img.url,
+ ...(img.mimeType !== undefined ? { mimeType: img.mimeType } : {}),
+ });
+ }
+ }
+ // An image-only message (empty text) is valid.
+ if (chunks.length === 0) {
+ chunks.push({ type: "text", text: "" });
+ }
+ return { role: "user", chunks };
}
// ── Provider-error retry backoff schedule ───────────────────────────────────
@@ -20,7 +49,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 +63,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 +79,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 +91,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 +108,98 @@ 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)}`;
+}
+
+// ── Memory telemetry (leak localization) ────────────────────────────────────
+//
+// Pure helpers for process.memoryUsage() sampling. The orchestrator owns the
+// sample SHAPE (this type) so its per-turn sampling and the host-bin periodic
+// timer share one contract without a cross-package import of an
+// implementation — host-bin imports this type, the orchestrator never imports
+// host-bin. Pure: inputs → attributes/delta, no I/O, no clock.
+
+/**
+ * A snapshot of process.memoryUsage() at one instant. Mirrors the subset of
+ * Node/Bun's MemoryUsage we log for leak localization (rss, heapUsed,
+ * heapTotal, external, arrayBuffers). Owned here so the orchestrator's
+ * per-turn sampling and the host-bin periodic timer agree on the shape.
+ */
+export interface MemorySample {
+ readonly rss: number;
+ readonly heapUsed: number;
+ readonly heapTotal: number;
+ readonly external: number;
+ readonly arrayBuffers: number;
+}
+
+const BYTES_PER_MB = 1024 * 1024;
+
+function mb(bytes: number): number {
+ return Math.round(bytes / BYTES_PER_MB);
+}
+
+/**
+ * Pure: format a {@link MemorySample} as flat logger {@link Attributes}
+ * (values in MB, rounded). Flat scalars are serializable (D3) and queryable
+ * (D9) in the journal. No I/O.
+ *
+ * Pass a `prefix` to namespace the keys — e.g. `memorySampleAttributes(delta,
+ * "delta")` yields `deltaRssMB`, so an "after" log can carry both the absolute
+ * sample (`rssMB`) and the per-turn delta (`deltaRssMB`) without key collision.
+ * The first letter of each field is capitalized after the prefix for
+ * readability (`deltaRssMB`, not `deltarssMB`).
+ */
+export function memorySampleAttributes(sample: MemorySample, prefix?: string): Attributes {
+ const p = prefix === undefined ? "" : prefix;
+ const cap = (s: string): string =>
+ s.length === 0 ? s : `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`;
+ const field = (name: string): string => (p.length === 0 ? name : `${p}${cap(name)}`);
+ return {
+ [field("rssMB")]: mb(sample.rss),
+ [field("heapUsedMB")]: mb(sample.heapUsed),
+ [field("heapTotalMB")]: mb(sample.heapTotal),
+ [field("externalMB")]: mb(sample.external),
+ [field("arrayBuffersMB")]: mb(sample.arrayBuffers),
+ };
+}
+
+/**
+ * Pure: compute the signed per-field delta `after - before`. A positive
+ * `rss` delta on a sealed turn flags memory retained by the streaming path
+ * (the prime leak suspect). No I/O.
+ */
+export function memoryDelta(before: MemorySample, after: MemorySample): MemorySample {
+ return {
+ rss: after.rss - before.rss,
+ heapUsed: after.heapUsed - before.heapUsed,
+ heapTotal: after.heapTotal - before.heapTotal,
+ external: after.external - before.external,
+ arrayBuffers: after.arrayBuffers - before.arrayBuffers,
+ };
}
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index adf5d9a..e5746c3 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,589 @@ 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;
+ // The kernel awaits drainSteering (it may return a Promise that
+ // persists the injected messages); the fake mirrors that.
+ const drained = await 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-/);
+
+ // The steering message was PERSISTED to the store (not just injected into
+ // the kernel's in-memory array). Without this, a user could never see the
+ // steering message, and in-flight compaction (which uses the live messages)
+ // would be the only thing keeping it — but only if it fired.
+ const stored = store.data.get("conv-drain") ?? [];
+ const storedSteering = stored.find(
+ (m) =>
+ m.role === "user" &&
+ m.chunks.some((c) => c.type === "text" && c.text === "first\n\nsecond"),
+ );
+ expect(storedSteering).toBeDefined();
+ });
+
+ 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;
+ });
+});
+
+// --- cancelQueuedMessage facade (remove a single queued message by id) ---
+
+describe("cancelQueuedMessage", () => {
+ it("removes a queued message by id → cancelled:true + post-cancel snapshot", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-cancel", "a");
+ queue.enqueue("conv-cancel", "b");
+ const second = queue.getQueue("conv-cancel")[1];
+ if (second === undefined) throw new Error("expected a second enqueued message");
+ const secondId = second.id;
+ expect(queue.getQueue("conv-cancel")).toHaveLength(2);
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-cancel",
+ messageId: secondId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue.map((m) => m.id)).not.toContain(secondId);
+ expect(result.queue).toHaveLength(1);
+ expect(queue.getQueue("conv-cancel").map((m) => m.id)).not.toContain(secondId);
+ });
+
+ it("cancel of the only message empties the queue (cancelled:true)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-only", "solo");
+ const onlyId = queue.getQueue("conv-only")[0]?.id;
+ if (onlyId === undefined) throw new Error("expected an enqueued message");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-only",
+ messageId: onlyId,
+ });
+ expect(result.cancelled).toBe(true);
+ expect(result.queue).toEqual([]);
+ expect(queue.getQueue("conv-only")).toEqual([]);
+ });
+
+ it("cancel of a missing id → cancelled:false, queue unchanged (idempotent)", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-miss", "a");
+ queue.enqueue("conv-miss", "b");
+ const before = queue.getQueue("conv-miss");
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-miss",
+ messageId: "does-not-exist",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue.map((m) => m.id)).toEqual(before.map((m) => m.id));
+ // live state unchanged
+ expect(queue.getQueue("conv-miss").map((m) => m.id)).toEqual(before.map((m) => m.id));
+ });
+
+ it("cancel on an unknown conversation → cancelled:false, empty queue", () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ resolveQueue: () => queue,
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "never-existed",
+ messageId: "anything",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("no queue ext (resolveQueue undefined) → cancelled:false, empty queue (degraded)", () => {
+ const store = createInMemoryStore();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => simpleProvider(),
+ resolveTools: noTools,
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ // resolveQueue intentionally omitted — feature degrades off
+ });
+
+ const result = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-noqueue",
+ messageId: "whatever",
+ });
+ expect(result.cancelled).toBe(false);
+ expect(result.queue).toEqual([]);
+ });
+
+ it("cancelled message is NOT delivered as steering (never runs)", async () => {
+ const store = createInMemoryStore();
+ const queue = createTestQueue();
+ queue.enqueue("conv-steer", "keep-me");
+ const cancelId = queue.enqueue("conv-steer", "cancel-me")[1]?.id;
+ if (cancelId === undefined) throw new Error("expected a second enqueued message");
+ // the first message id (kept)
+ const keepId = queue.getQueue("conv-steer")[0]?.id;
+ if (keepId === undefined) throw new Error("expected a kept message");
+
+ 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,
+ });
+
+ // Cancel the second message BEFORE the turn drains.
+ const cancelResult = orchestrator.cancelQueuedMessage({
+ conversationId: "conv-steer",
+ messageId: cancelId,
+ });
+ expect(cancelResult.cancelled).toBe(true);
+ expect(cancelResult.queue.map((m) => m.id)).toEqual([keepId]);
+
+ const events: AgentEvent[] = [];
+ const unsub = orchestrator.subscribe("conv-steer", (e) => events.push(e));
+
+ orchestrator.startTurn({ conversationId: "conv-steer", text: "go" });
+ await waitForSealed(orchestrator, "conv-steer");
+ unsub();
+
+ // The steering drain combined ONLY the kept message — the cancelled one
+ // is absent from the drained text.
+ expect(drainedMessages).toHaveLength(1);
+ const steerMsg = drainedMessages[0];
+ if (steerMsg === undefined) throw new Error("expected a drained message");
+ const chunk = steerMsg.chunks[0];
+ if (chunk === undefined || chunk.type !== "text") throw new Error("expected text chunk");
+ expect(chunk.text).toBe("keep-me");
+ expect(chunk.text).not.toContain("cancel-me");
+
+ // drainSteering was wired + the queue is now empty (the kept one drained).
+ expect(captured[0]?.drainSteering).toBeDefined();
+ expect(queue.getQueue("conv-steer")).toHaveLength(0);
+
+ // The steering event carries only the kept text.
+ const steering = events.find(isSteering);
+ expect(steering?.text).toBe("keep-me");
+ });
});
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..22bc5cd 100644
--- a/packages/session-orchestrator/src/tools-filter.ts
+++ b/packages/session-orchestrator/src/tools-filter.ts
@@ -2,24 +2,33 @@ 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;
+ /**
+ * The turn's abort signal, threaded through the filter chain so a filter that
+ * awaits slow I/O (e.g. the MCP tools filter connecting to MCP servers) can be
+ * interrupted by `POST /conversations/:id/stop` instead of blocking the turn
+ * until its own timeout fires. Optional: omitted by paths that have no turn
+ * controller (e.g. the cache-warm probe), in which case filters fall back to
+ * their own timeouts. Filters that return a fresh assembly MUST preserve it.
+ */
+ readonly signal?: AbortSignal;
}
/** 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 +51,19 @@ 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,
+ ...(assembly.signal !== undefined ? { signal: assembly.signal } : {}),
+ };
}
diff --git a/packages/session-orchestrator/tsconfig.json b/packages/session-orchestrator/tsconfig.json
index 2ca3bd2..f316387 100644
--- a/packages/session-orchestrator/tsconfig.json
+++ b/packages/session-orchestrator/tsconfig.json
@@ -1,12 +1,13 @@
{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"],
- "references": [
- { "path": "../kernel" },
- { "path": "../conversation-store" },
- { "path": "../credential-store" },
- { "path": "../message-queue" },
- { "path": "../system-prompt" }
- ]
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../conversation-store" },
+ { "path": "../credential-store" },
+ { "path": "../message-queue" },
+ { "path": "../provider-concurrency" },
+ { "path": "../system-prompt" }
+ ]
}