diff options
Diffstat (limited to 'packages/session-orchestrator/src/orchestrator.test.ts')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 8584 |
1 files changed, 4696 insertions, 3888 deletions
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); + }); }); |
