diff options
Diffstat (limited to 'packages/session-orchestrator/src/queue.test.ts')
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 1058 |
1 files changed, 529 insertions, 529 deletions
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index adf5d9a..a09a441 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -1,15 +1,15 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { - AgentEvent, - ChatMessage, - ProviderContract, - ProviderEvent, - ReasoningEffort, - RunTurnInput, - RunTurnResult, - StoredChunk, - ToolContract, - TurnMetrics, + AgentEvent, + ChatMessage, + ProviderContract, + ProviderEvent, + ReasoningEffort, + RunTurnInput, + RunTurnResult, + StoredChunk, + ToolContract, + TurnMetrics, } from "@dispatch/kernel"; import { runTurn } from "@dispatch/kernel"; import { createMessageQueueService } from "@dispatch/message-queue"; @@ -21,177 +21,177 @@ import type { ToolAssembly } from "./tools-filter.js"; // a shared test-helper module wired between test files is a coupling smell) --- function createInMemoryStore(): ConversationStore & { - readonly data: Map<string, ChatMessage[]>; - readonly metricsData: Map<string, TurnMetrics[]>; - readonly cwdData: Map<string, string>; - readonly effortData: Map<string, ReasoningEffort>; - readonly modelData: Map<string, string>; + readonly data: Map<string, ChatMessage[]>; + readonly metricsData: Map<string, TurnMetrics[]>; + readonly cwdData: Map<string, string>; + readonly effortData: Map<string, ReasoningEffort>; + readonly modelData: Map<string, string>; } { - const data = new Map<string, ChatMessage[]>(); - const metricsData = new Map<string, TurnMetrics[]>(); - const cwdData = new Map<string, string>(); - const effortData = new Map<string, ReasoningEffort>(); - const modelData = new Map<string, string>(); - return { - data, - metricsData, - cwdData, - effortData, - modelData, - async append(conversationId, messages) { - const existing = data.get(conversationId) ?? []; - data.set(conversationId, [...existing, ...messages]); - }, - async load(conversationId) { - return [...(data.get(conversationId) ?? [])]; - }, - async loadSince(conversationId, sinceSeq) { - const messages = data.get(conversationId) ?? []; - const result: StoredChunk[] = []; - let seq = 1; - for (const msg of messages) { - for (const chunk of msg.chunks) { - if (sinceSeq === undefined || seq > sinceSeq) { - result.push({ seq, role: msg.role, chunk }); - } - seq++; - } - } - return result; - }, - async appendMetrics(conversationId, metrics) { - const existing = metricsData.get(conversationId) ?? []; - metricsData.set(conversationId, [...existing, metrics]); - }, - async loadMetrics(conversationId) { - return [...(metricsData.get(conversationId) ?? [])]; - }, - async getCwd(conversationId) { - return cwdData.get(conversationId) ?? null; - }, - async setCwd(conversationId, cwd) { - cwdData.set(conversationId, cwd); - }, - async clearCwd(conversationId) { - cwdData.delete(conversationId); - }, - async getComputerId() { - return null; - }, - async setComputerId() {}, - async clearComputerId() {}, - async getReasoningEffort(conversationId) { - return effortData.get(conversationId) ?? null; - }, - async setReasoningEffort(conversationId, effort) { - effortData.set(conversationId, effort); - }, - async getModel(conversationId) { - return modelData.get(conversationId) ?? null; - }, - async setModel(conversationId, model) { - if (model === "") { - modelData.delete(conversationId); - } else { - modelData.set(conversationId, model); - } - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace(id) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle(id, title) { - return { - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { - id, - title: id, - defaultCwd, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { - id, - title: id, - defaultCwd: null, - defaultComputerId, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd(conversationId) { - return cwdData.get(conversationId) ?? null; - }, - async getEffectiveComputer() { - return null; - }, - }; + const data = new Map<string, ChatMessage[]>(); + const metricsData = new Map<string, TurnMetrics[]>(); + const cwdData = new Map<string, string>(); + const effortData = new Map<string, ReasoningEffort>(); + const modelData = new Map<string, string>(); + return { + data, + metricsData, + cwdData, + effortData, + modelData, + async append(conversationId, messages) { + const existing = data.get(conversationId) ?? []; + data.set(conversationId, [...existing, ...messages]); + }, + async load(conversationId) { + return [...(data.get(conversationId) ?? [])]; + }, + async loadSince(conversationId, sinceSeq) { + const messages = data.get(conversationId) ?? []; + const result: StoredChunk[] = []; + let seq = 1; + for (const msg of messages) { + for (const chunk of msg.chunks) { + if (sinceSeq === undefined || seq > sinceSeq) { + result.push({ seq, role: msg.role, chunk }); + } + seq++; + } + } + return result; + }, + async appendMetrics(conversationId, metrics) { + const existing = metricsData.get(conversationId) ?? []; + metricsData.set(conversationId, [...existing, metrics]); + }, + async loadMetrics(conversationId) { + return [...(metricsData.get(conversationId) ?? [])]; + }, + async getCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdData.set(conversationId, cwd); + }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, + async getReasoningEffort(conversationId) { + return effortData.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + effortData.set(conversationId, effort); + }, + async getModel(conversationId) { + return modelData.get(conversationId) ?? null; + }, + async setModel(conversationId, model) { + if (model === "") { + modelData.delete(conversationId); + } else { + modelData.set(conversationId, model); + } + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle(id, title) { + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async getEffectiveComputer() { + return null; + }, + }; } function identityApplyToolsFilter(assembly: ToolAssembly): Promise<ToolAssembly> { - return Promise.resolve(assembly); + return Promise.resolve(assembly); } function noTools(): readonly ToolContract[] { - return []; + return []; } function simpleProvider(): ProviderContract { - return { - id: "fake", - stream: async function* () { - yield { type: "text-delta", delta: "ok" } as ProviderEvent; - yield { type: "finish", reason: "stop" } as ProviderEvent; - }, - }; + return { + id: "fake", + stream: async function* () { + yield { type: "text-delta", delta: "ok" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; } /** @@ -201,395 +201,395 @@ function simpleProvider(): ProviderContract { * of @dispatch/* — it's a plain fake of the outermost runTurn edge. */ function createDrainingCaptureRunTurn(): { - captured: RunTurnInput[]; - drainedMessages: ChatMessage[]; - wasDrainCalled: () => boolean; - runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; + captured: RunTurnInput[]; + drainedMessages: ChatMessage[]; + wasDrainCalled: () => boolean; + runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; } { - const captured: RunTurnInput[] = []; - const drainedMessages: ChatMessage[] = []; - let drainCalled = false; - return { - captured, - drainedMessages, - wasDrainCalled: () => drainCalled, - runTurn: async (input) => { - captured.push(input); - if (input.drainSteering !== undefined) { - drainCalled = true; - const drained = input.drainSteering(); - drainedMessages.push(...drained); - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }, - }; + const captured: RunTurnInput[] = []; + const drainedMessages: ChatMessage[] = []; + let drainCalled = false; + return { + captured, + drainedMessages, + wasDrainCalled: () => drainCalled, + runTurn: async (input) => { + captured.push(input); + if (input.drainSteering !== undefined) { + drainCalled = true; + const drained = input.drainSteering(); + drainedMessages.push(...drained); + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }, + }; } function waitForSealed( - orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, + orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, ): Promise<void> { - return new Promise((resolve) => { - const unsub = orchestrator.subscribe(conversationId, (e) => { - if (e.type === "turn-sealed") { - unsub(); - resolve(); - } - }); - }); + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + unsub(); + resolve(); + } + }); + }); } function waitForSealedCount( - orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], - conversationId: string, - count: number, + orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + count: number, ): Promise<void> { - return new Promise((resolve) => { - let seen = 0; - const unsub = orchestrator.subscribe(conversationId, (e) => { - if (e.type === "turn-sealed") { - seen++; - if (seen >= count) { - unsub(); - resolve(); - } - } - }); - }); + return new Promise((resolve) => { + let seen = 0; + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + seen++; + if (seen >= count) { + unsub(); + resolve(); + } + } + }); + }); } function isSteering(e: AgentEvent): e is Extract<AgentEvent, { type: "steering" }> { - return e.type === "steering"; + return e.type === "steering"; } function isUserMessage(e: AgentEvent): e is Extract<AgentEvent, { type: "user-message" }> { - return e.type === "user-message"; + return e.type === "user-message"; } function createTestQueue() { - return createMessageQueueService({ - id: () => `q-${Math.random().toString(36).slice(2, 8)}`, - now: () => 1000, - notify: () => {}, - }); + return createMessageQueueService({ + id: () => `q-${Math.random().toString(36).slice(2, 8)}`, + now: () => 1000, + notify: () => {}, + }); } // --- drainSteering (mid-turn, at the tool-result boundary) --- describe("drainSteering", () => { - it("drainSteering drains the queue + emits a steering event + returns one combined user message", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - queue.enqueue("conv-drain", "first"); - queue.enqueue("conv-drain", "second"); - - const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-drain", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-drain", text: "go" }); - await waitForSealed(orchestrator, "conv-drain"); - unsub(); - - // drainSteering was wired on the RunTurnInput - expect(captured).toHaveLength(1); - expect(captured[0]?.drainSteering).toBeDefined(); - expect(typeof captured[0]?.drainSteering).toBe("function"); - - // The fake runTurn called drainSteering → returned one combined user message - expect(drainedMessages).toHaveLength(1); - const steerMsg = drainedMessages[0]; - if (steerMsg === undefined) throw new Error("expected drained message"); - expect(steerMsg.role).toBe("user"); - expect(steerMsg.chunks).toHaveLength(1); - const chunk = steerMsg.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("text"); - if (chunk.type === "text") { - expect(chunk.text).toBe("first\n\nsecond"); - } - - // The queue was drained (cleared) - expect(queue.getQueue("conv-drain")).toHaveLength(0); - - // A steering event was emitted into the hub with the combined text - const steering = events.find(isSteering); - expect(steering).toBeDefined(); - expect(steering?.conversationId).toBe("conv-drain"); - expect(steering?.text).toBe("first\n\nsecond"); - expect(steering?.turnId).toMatch(/^turn-/); - }); - - it("drainSteering on an empty queue returns [] and emits nothing", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { - drainedMessages, - wasDrainCalled, - runTurn: captureRunTurn, - } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-empty", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-empty", text: "go" }); - await waitForSealed(orchestrator, "conv-empty"); - unsub(); - - // drainSteering was wired and called, but returned [] - expect(wasDrainCalled()).toBe(true); - expect(drainedMessages).toHaveLength(0); - - // No steering event was emitted - expect(events.filter(isSteering)).toHaveLength(0); - }); - - it("no queue ext (resolveQueue undefined) → drainSteering omitted; turn unchanged", async () => { - const store = createInMemoryStore(); - - const { captured, wasDrainCalled, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => ({ id: "p", stream: async function* () {} }), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: captureRunTurn, - // resolveQueue intentionally omitted — feature degrades off - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-noqueue", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-noqueue", text: "go" }); - await waitForSealed(orchestrator, "conv-noqueue"); - unsub(); - - // drainSteering is absent from the RunTurnInput (not undefined — omitted) - expect(captured).toHaveLength(1); - expect(captured[0]?.drainSteering).toBeUndefined(); - expect(wasDrainCalled()).toBe(false); - - // No steering event; turn sealed normally - expect(events.filter(isSteering)).toHaveLength(0); - expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); - }); + it("drainSteering drains the queue + emits a steering event + returns one combined user message", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + queue.enqueue("conv-drain", "first"); + queue.enqueue("conv-drain", "second"); + + const { captured, drainedMessages, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-drain", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-drain", text: "go" }); + await waitForSealed(orchestrator, "conv-drain"); + unsub(); + + // drainSteering was wired on the RunTurnInput + expect(captured).toHaveLength(1); + expect(captured[0]?.drainSteering).toBeDefined(); + expect(typeof captured[0]?.drainSteering).toBe("function"); + + // The fake runTurn called drainSteering → returned one combined user message + expect(drainedMessages).toHaveLength(1); + const steerMsg = drainedMessages[0]; + if (steerMsg === undefined) throw new Error("expected drained message"); + expect(steerMsg.role).toBe("user"); + expect(steerMsg.chunks).toHaveLength(1); + const chunk = steerMsg.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("text"); + if (chunk.type === "text") { + expect(chunk.text).toBe("first\n\nsecond"); + } + + // The queue was drained (cleared) + expect(queue.getQueue("conv-drain")).toHaveLength(0); + + // A steering event was emitted into the hub with the combined text + const steering = events.find(isSteering); + expect(steering).toBeDefined(); + expect(steering?.conversationId).toBe("conv-drain"); + expect(steering?.text).toBe("first\n\nsecond"); + expect(steering?.turnId).toMatch(/^turn-/); + }); + + it("drainSteering on an empty queue returns [] and emits nothing", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { + drainedMessages, + wasDrainCalled, + runTurn: captureRunTurn, + } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-empty", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-empty", text: "go" }); + await waitForSealed(orchestrator, "conv-empty"); + unsub(); + + // drainSteering was wired and called, but returned [] + expect(wasDrainCalled()).toBe(true); + expect(drainedMessages).toHaveLength(0); + + // No steering event was emitted + expect(events.filter(isSteering)).toHaveLength(0); + }); + + it("no queue ext (resolveQueue undefined) → drainSteering omitted; turn unchanged", async () => { + const store = createInMemoryStore(); + + const { captured, wasDrainCalled, runTurn: captureRunTurn } = createDrainingCaptureRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + // resolveQueue intentionally omitted — feature degrades off + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-noqueue", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-noqueue", text: "go" }); + await waitForSealed(orchestrator, "conv-noqueue"); + unsub(); + + // drainSteering is absent from the RunTurnInput (not undefined — omitted) + expect(captured).toHaveLength(1); + expect(captured[0]?.drainSteering).toBeUndefined(); + expect(wasDrainCalled()).toBe(false); + + // No steering event; turn sealed normally + expect(events.filter(isSteering)).toHaveLength(0); + expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); + }); }); // --- Post-seal carry (turn ended with a non-empty queue → new turn) --- describe("post-seal carry", () => { - it("post-seal: non-empty queue → a new turn starts with the combined message", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - queue.enqueue("conv-carry", "queued-a"); - queue.enqueue("conv-carry", "queued-b"); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-carry", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-carry", text: "original" }); - // Wait for the original turn + the carried turn to both seal. - await waitForSealedCount(orchestrator, "conv-carry", 2); - unsub(); - - // Two user-message events: the original prompt + the carried combined text. - const userMessages = events.filter(isUserMessage); - expect(userMessages).toHaveLength(2); - expect(userMessages[0]?.text).toBe("original"); - expect(userMessages[1]?.text).toBe("queued-a\n\nqueued-b"); - - // No steering event — the carry case emits user-message, not steering. - expect(events.filter(isSteering)).toHaveLength(0); - - // The queue was drained by the carry. - expect(queue.getQueue("conv-carry")).toHaveLength(0); - - // Both turns persisted (original + carry). - expect(store.data.get("conv-carry")?.length).toBeGreaterThanOrEqual(4); - }); - - it("post-seal: empty queue → no new turn", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const events: AgentEvent[] = []; - const unsub = orchestrator.subscribe("conv-no-carry", (e) => events.push(e)); - - orchestrator.startTurn({ conversationId: "conv-no-carry", text: "original" }); - await waitForSealed(orchestrator, "conv-no-carry"); - // Give the carry check a chance to run (it's in the finally, synchronous - // after turn-sealed, but await yields first). - await new Promise<void>((resolve) => setTimeout(resolve, 10)); - unsub(); - - // Only one user-message (the original) — no carry turn. - expect(events.filter(isUserMessage)).toHaveLength(1); - expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); - }); + it("post-seal: non-empty queue → a new turn starts with the combined message", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + queue.enqueue("conv-carry", "queued-a"); + queue.enqueue("conv-carry", "queued-b"); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-carry", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-carry", text: "original" }); + // Wait for the original turn + the carried turn to both seal. + await waitForSealedCount(orchestrator, "conv-carry", 2); + unsub(); + + // Two user-message events: the original prompt + the carried combined text. + const userMessages = events.filter(isUserMessage); + expect(userMessages).toHaveLength(2); + expect(userMessages[0]?.text).toBe("original"); + expect(userMessages[1]?.text).toBe("queued-a\n\nqueued-b"); + + // No steering event — the carry case emits user-message, not steering. + expect(events.filter(isSteering)).toHaveLength(0); + + // The queue was drained by the carry. + expect(queue.getQueue("conv-carry")).toHaveLength(0); + + // Both turns persisted (original + carry). + expect(store.data.get("conv-carry")?.length).toBeGreaterThanOrEqual(4); + }); + + it("post-seal: empty queue → no new turn", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const events: AgentEvent[] = []; + const unsub = orchestrator.subscribe("conv-no-carry", (e) => events.push(e)); + + orchestrator.startTurn({ conversationId: "conv-no-carry", text: "original" }); + await waitForSealed(orchestrator, "conv-no-carry"); + // Give the carry check a chance to run (it's in the finally, synchronous + // after turn-sealed, but await yields first). + await new Promise<void>((resolve) => setTimeout(resolve, 10)); + unsub(); + + // Only one user-message (the original) — no carry turn. + expect(events.filter(isUserMessage)).toHaveLength(1); + expect(events.filter((e) => e.type === "turn-sealed")).toHaveLength(1); + }); }); // --- enqueue facade (the single entry transports call) --- describe("enqueue", () => { - it("enqueue when idle → starts a turn (startedTurn:true)", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn, - resolveQueue: () => queue, - }); - - const result = orchestrator.enqueue({ conversationId: "conv-idle", text: "hello" }); - expect(result.startedTurn).toBe(true); - expect(result.queue).toHaveLength(0); - - await waitForSealed(orchestrator, "conv-idle"); - - // The turn ran and persisted. - expect(store.data.get("conv-idle")).toBeDefined(); - expect(store.data.get("conv-idle")?.length).toBeGreaterThanOrEqual(2); - }); - - it("enqueue when active → queues (startedTurn:false, snapshot with the message)", async () => { - const store = createInMemoryStore(); - const queue = createTestQueue(); - - let resolveFirst: (() => void) | undefined; - const firstBlocker = new Promise<void>((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { - callCount++; - if (callCount === 1) { - await firstBlocker; - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingFirstRunTurn, - resolveQueue: () => queue, - }); - - // Start the original turn (it blocks in runTurn). - orchestrator.startTurn({ conversationId: "conv-active", text: "first" }); - // Let the turn reach the blocked runTurn call. - await new Promise<void>((resolve) => setTimeout(resolve, 10)); - - // Enqueue while active. - const result = orchestrator.enqueue({ conversationId: "conv-active", text: "second" }); - expect(result.startedTurn).toBe(false); - expect(result.queue).toHaveLength(1); - expect(result.queue[0]?.text).toBe("second"); - - // The queue holds the enqueued message. - expect(queue.getQueue("conv-active")).toHaveLength(1); - - // Release the original turn → it seals → post-seal carry starts a new - // turn with the enqueued message. Subscribe before releasing to catch - // both turn-sealed events. - const sealed = waitForSealedCount(orchestrator, "conv-active", 2); - resolveFirst?.(); - await sealed; - }); - - it("enqueue when active + no queue ext → startedTurn:false, empty queue (degraded)", async () => { - const store = createInMemoryStore(); - - let resolveFirst: (() => void) | undefined; - const firstBlocker = new Promise<void>((resolve) => { - resolveFirst = resolve; - }); - let callCount = 0; - const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { - callCount++; - if (callCount === 1) { - await firstBlocker; - } - return { - messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], - usage: { inputTokens: 1, outputTokens: 1 }, - finishReason: "stop", - }; - }; - - const { orchestrator } = createSessionOrchestrator({ - conversationStore: store, - resolveProvider: () => simpleProvider(), - resolveTools: noTools, - applyToolsFilter: identityApplyToolsFilter, - runTurn: blockingFirstRunTurn, - // resolveQueue omitted — no queue extension loaded (degraded) - }); - - orchestrator.startTurn({ conversationId: "conv-degraded", text: "first" }); - await new Promise<void>((resolve) => setTimeout(resolve, 10)); - - // Enqueue while active, but no queue ext → message dropped, empty snapshot. - const result = orchestrator.enqueue({ conversationId: "conv-degraded", text: "second" }); - expect(result.startedTurn).toBe(false); - expect(result.queue).toHaveLength(0); - - // Release the original turn; no carry (no queue ext). - const sealed = waitForSealed(orchestrator, "conv-degraded"); - resolveFirst?.(); - await sealed; - }); + it("enqueue when idle → starts a turn (startedTurn:true)", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn, + resolveQueue: () => queue, + }); + + const result = orchestrator.enqueue({ conversationId: "conv-idle", text: "hello" }); + expect(result.startedTurn).toBe(true); + expect(result.queue).toHaveLength(0); + + await waitForSealed(orchestrator, "conv-idle"); + + // The turn ran and persisted. + expect(store.data.get("conv-idle")).toBeDefined(); + expect(store.data.get("conv-idle")?.length).toBeGreaterThanOrEqual(2); + }); + + it("enqueue when active → queues (startedTurn:false, snapshot with the message)", async () => { + const store = createInMemoryStore(); + const queue = createTestQueue(); + + let resolveFirst: (() => void) | undefined; + const firstBlocker = new Promise<void>((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { + callCount++; + if (callCount === 1) { + await firstBlocker; + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingFirstRunTurn, + resolveQueue: () => queue, + }); + + // Start the original turn (it blocks in runTurn). + orchestrator.startTurn({ conversationId: "conv-active", text: "first" }); + // Let the turn reach the blocked runTurn call. + await new Promise<void>((resolve) => setTimeout(resolve, 10)); + + // Enqueue while active. + const result = orchestrator.enqueue({ conversationId: "conv-active", text: "second" }); + expect(result.startedTurn).toBe(false); + expect(result.queue).toHaveLength(1); + expect(result.queue[0]?.text).toBe("second"); + + // The queue holds the enqueued message. + expect(queue.getQueue("conv-active")).toHaveLength(1); + + // Release the original turn → it seals → post-seal carry starts a new + // turn with the enqueued message. Subscribe before releasing to catch + // both turn-sealed events. + const sealed = waitForSealedCount(orchestrator, "conv-active", 2); + resolveFirst?.(); + await sealed; + }); + + it("enqueue when active + no queue ext → startedTurn:false, empty queue (degraded)", async () => { + const store = createInMemoryStore(); + + let resolveFirst: (() => void) | undefined; + const firstBlocker = new Promise<void>((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + const blockingFirstRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => { + callCount++; + if (callCount === 1) { + await firstBlocker; + } + return { + messages: [{ role: "assistant", chunks: [{ type: "text", text: "done" }] }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: "stop", + }; + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => simpleProvider(), + resolveTools: noTools, + applyToolsFilter: identityApplyToolsFilter, + runTurn: blockingFirstRunTurn, + // resolveQueue omitted — no queue extension loaded (degraded) + }); + + orchestrator.startTurn({ conversationId: "conv-degraded", text: "first" }); + await new Promise<void>((resolve) => setTimeout(resolve, 10)); + + // Enqueue while active, but no queue ext → message dropped, empty snapshot. + const result = orchestrator.enqueue({ conversationId: "conv-degraded", text: "second" }); + expect(result.startedTurn).toBe(false); + expect(result.queue).toHaveLength(0); + + // Release the original turn; no carry (no queue ext). + const sealed = waitForSealed(orchestrator, "conv-degraded"); + resolveFirst?.(); + await sealed; + }); }); |
