import { describe, it, expect, vi, beforeEach } from "vitest"; import { LocalThreadRuntimeCore } from "../legacy-runtime/runtime-cores/local/LocalThreadRuntimeCore"; import type { ChatModelAdapter } from "../legacy-runtime/runtime-cores/local/ChatModelAdapter"; import type { LocalRuntimeOptionsBase } from "../legacy-runtime/runtime-cores/local/LocalRuntimeOptions"; import type { ModelContextProvider } from "@assistant-ui/core"; import type { AppendMessage } from "@assistant-ui/core"; const createMockContextProvider = (): ModelContextProvider => ({ getModelContext: () => ({}), }); const createOptions = ( adapter: ChatModelAdapter, overrides: Partial = {}, ): LocalRuntimeOptionsBase => ({ adapters: { chatModel: adapter }, ...overrides, }); const createUserAppendMessage = ( text: string, overrides: Partial = {}, ): AppendMessage => ({ role: "user", content: [{ type: "text", text }], attachments: [], metadata: { custom: {} }, parentId: null, sourceId: null, runConfig: undefined, createdAt: new Date(), ...overrides, }); describe("LocalThreadRuntimeCore", () => { let contextProvider: ModelContextProvider; beforeEach(() => { contextProvider = createMockContextProvider(); }); it("appends a user message and runs the chat model", async () => { const adapter: ChatModelAdapter = { run: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "Hello!" }], status: { type: "complete", reason: "stop" }, }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); await core.append(createUserAppendMessage("Hi")); const messages = core.messages; expect(messages).toHaveLength(2); expect(messages[0]!.role).toBe("user"); expect(messages[1]!.role).toBe("assistant"); expect(messages[1]!.content).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "text", text: "Hello!" }), ]), ); expect(messages[1]!.status).toEqual({ type: "complete", reason: "stop" }); expect(adapter.run).toHaveBeenCalledOnce(); }); it("streams assistant response via async generator", async () => { const adapter: ChatModelAdapter = { run: vi.fn().mockImplementation(async function* () { yield { content: [{ type: "text" as const, text: "Hel" }], }; yield { content: [{ type: "text" as const, text: "Hello world" }], status: { type: "complete" as const, reason: "stop" as const }, }; }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); await core.append(createUserAppendMessage("Stream test")); const messages = core.messages; expect(messages).toHaveLength(2); const assistant = messages[1]!; expect(assistant.role).toBe("assistant"); expect(assistant.content).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "text", text: "Hello world" }), ]), ); expect(assistant.status).toEqual({ type: "complete", reason: "stop" }); }); it("cancels a running response", async () => { const adapter: ChatModelAdapter = { run: vi.fn().mockImplementation(async function* ({ abortSignal }) { yield { content: [{ type: "text" as const, text: "partial" }], }; // Wait until the abort signal fires await new Promise((_resolve, reject) => { if (abortSignal.aborted) { reject(abortSignal.reason); return; } abortSignal.addEventListener( "abort", () => reject(abortSignal.reason), { once: true }, ); }); }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); // Start append but don't await (it will hang until cancelled) const appendPromise = core.append(createUserAppendMessage("Cancel me")); // Give the generator time to yield first chunk await new Promise((r) => setTimeout(r, 50)); core.cancelRun(); await appendPromise; const messages = core.messages; expect(messages).toHaveLength(2); const assistant = messages[1]!; expect(assistant.status).toBeDefined(); expect(assistant.status!.type).toBe("incomplete"); if (assistant.status!.type === "incomplete") { expect(assistant.status!.reason).toBe("cancelled"); } }); it("handles adapter errors gracefully", async () => { const adapter: ChatModelAdapter = { run: vi.fn().mockRejectedValue(new Error("Model unavailable")), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); await expect( core.append(createUserAppendMessage("Error test")), ).rejects.toThrow("Model unavailable"); const messages = core.messages; expect(messages).toHaveLength(2); const assistant = messages[1]!; expect(assistant.status).toBeDefined(); expect(assistant.status!.type).toBe("incomplete"); if (assistant.status!.type === "incomplete") { expect(assistant.status!.reason).toBe("error"); expect(assistant.status!.error).toBe("Model unavailable"); } }); it("continues tool execution loop", async () => { let callCount = 0; const adapter: ChatModelAdapter = { run: vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) { return Promise.resolve({ content: [ { type: "tool-call", toolCallId: "tc1", toolName: "myTool", args: { input: "test" }, argsText: '{"input":"test"}', }, ], status: { type: "requires-action", reason: "tool-calls" }, }); } return Promise.resolve({ content: [{ type: "text", text: "Done with tool" }], status: { type: "complete", reason: "stop" }, }); }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); await core.append(createUserAppendMessage("Use a tool")); // The assistant message should have a tool-call with no result yet // and status requires-action. Now add the tool result. const assistantMsg = core.messages[1]!; expect(assistantMsg.role).toBe("assistant"); core.addToolResult({ messageId: assistantMsg.id, toolName: "myTool", toolCallId: "tc1", result: "tool output", isError: false, }); // Wait for the second roundtrip to complete await new Promise((r) => setTimeout(r, 100)); expect(adapter.run).toHaveBeenCalledTimes(2); const finalMessages = core.messages; const finalAssistant = finalMessages[1]!; // Content should include both the tool-call from round 1 and text from round 2 expect(finalAssistant.content.some((c) => c.type === "tool-call")).toBe( true, ); expect( finalAssistant.content.some( (c) => c.type === "text" && c.text === "Done with tool", ), ).toBe(true); }); it("stops at maxSteps", async () => { const adapter: ChatModelAdapter = { run: vi.fn().mockResolvedValue({ content: [ { type: "tool-call", toolCallId: "tc1", toolName: "myTool", args: {}, argsText: "{}", }, ], status: { type: "requires-action", reason: "tool-calls" }, metadata: { steps: [{ usage: { promptTokens: 10, completionTokens: 5 } }], }, }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter, { maxSteps: 1 }), ); await core.append(createUserAppendMessage("Tool call")); // Add tool result - this should NOT trigger another roundtrip because maxSteps=1 const assistantMsg = core.messages[1]!; core.addToolResult({ messageId: assistantMsg.id, toolName: "myTool", toolCallId: "tc1", result: "result", isError: false, }); // Wait a bit to ensure no additional roundtrip happened await new Promise((r) => setTimeout(r, 100)); // Should have been called only once (the initial run) expect(adapter.run).toHaveBeenCalledTimes(1); const finalAssistant = core.messages[1]!; expect(finalAssistant.status).toBeDefined(); expect(finalAssistant.status!.type).toBe("incomplete"); if (finalAssistant.status!.type === "incomplete") { expect(finalAssistant.status!.reason).toBe("tool-calls"); } }); it("capabilities update from options", () => { const adapter: ChatModelAdapter = { run: vi.fn().mockResolvedValue({ content: [] }), }; const core = new LocalThreadRuntimeCore( contextProvider, createOptions(adapter), ); // Default capabilities expect(core.capabilities.speech).toBe(false); expect(core.capabilities.dictation).toBe(false); expect(core.capabilities.attachments).toBe(false); expect(core.capabilities.feedback).toBe(false); // Update with adapters core.__internal_setOptions({ adapters: { chatModel: adapter, speech: {} as any, dictation: {} as any, attachments: {} as any, feedback: {} as any, }, }); expect(core.capabilities.speech).toBe(true); expect(core.capabilities.dictation).toBe(true); expect(core.capabilities.attachments).toBe(true); expect(core.capabilities.feedback).toBe(true); // Update back to no adapters core.__internal_setOptions({ adapters: { chatModel: adapter }, }); expect(core.capabilities.speech).toBe(false); expect(core.capabilities.dictation).toBe(false); expect(core.capabilities.attachments).toBe(false); expect(core.capabilities.feedback).toBe(false); }); });