import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { z } from "zod"; import { BasicAgent, defineTool, type ToolDefinition } from "../index"; import { EventType, type BaseEvent, type ReasoningStartEvent, type RunAgentInput, } from "@ag-ui/client"; import { streamText } from "ai"; import { mockStreamTextResponse, textStart, textDelta, finish, abort, error, collectEvents, toolCallStreamingStart, toolCallDelta, toolCall, toolResult, reasoningStart, reasoningDelta, reasoningEnd, } from "./test-helpers"; // Mock the ai module vi.mock("ai", () => ({ streamText: vi.fn(), tool: vi.fn((config) => config), })); // Mock the SDK clients vi.mock("@ai-sdk/openai", () => ({ createOpenAI: vi.fn(() => (modelId: string) => ({ modelId, provider: "openai", })), })); vi.mock("@ai-sdk/anthropic", () => ({ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId, provider: "anthropic", })), })); vi.mock("@ai-sdk/google", () => ({ createGoogleGenerativeAI: vi.fn(() => (modelId: string) => ({ modelId, provider: "google", })), })); describe("BasicAgent", () => { const originalEnv = process.env; beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; process.env.OPENAI_API_KEY = "test-key"; process.env.ANTHROPIC_API_KEY = "test-key"; process.env.GOOGLE_API_KEY = "test-key"; }); afterEach(() => { process.env = originalEnv; }); describe("Basic Event Emission", () => { it("should emit RUN_STARTED and RUN_FINISHED events", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([textDelta("Hello"), finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); expect(events[0]).toMatchObject({ type: EventType.RUN_STARTED, threadId: "thread1", runId: "run1", }); expect(events[events.length - 1]).toMatchObject({ type: EventType.RUN_FINISHED, threadId: "thread1", runId: "run1", }); }); it("should emit TEXT_MESSAGE_CHUNK events for text deltas", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ textDelta("Hello"), textDelta(" world"), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const textEvents = events.filter( (e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK, ); expect(textEvents).toHaveLength(2); expect(textEvents[0]).toMatchObject({ type: EventType.TEXT_MESSAGE_CHUNK, role: "assistant", delta: "Hello", }); expect(textEvents[1]).toMatchObject({ type: EventType.TEXT_MESSAGE_CHUNK, delta: " world", }); }); it("should generate unique messageId when provider returns id '0'", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ textStart("0"), // Simulate Google Gemini returning "0" textDelta("First message"), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const textEvents = events.filter( (e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK, ); expect(textEvents).toHaveLength(1); // Verify that messageId is NOT "0" - should be a UUID expect(textEvents[0].messageId).not.toBe("0"); expect(textEvents[0].messageId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); }); it("should use provider-supplied messageId when it's not '0'", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); const validId = "msg_abc123"; vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ textStart(validId), // Valid ID from provider textDelta("Test message"), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const textEvents = events.filter( (e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK, ); expect(textEvents).toHaveLength(1); // Verify that the valid ID from provider is used expect(textEvents[0].messageId).toBe(validId); }); }); describe("Tool Call Events", () => { it("should emit tool call lifecycle events", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ toolCallStreamingStart("call1", "testTool"), toolCallDelta("call1", '{"arg'), toolCallDelta("call1", '":"val"}'), toolCall("call1", "testTool", { arg: "val" }), toolResult("call1", "testTool", { result: "success" }), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); // Check for TOOL_CALL_START const startEvent = events.find( (e: any) => e.type === EventType.TOOL_CALL_START, ); expect(startEvent).toMatchObject({ type: EventType.TOOL_CALL_START, toolCallId: "call1", toolCallName: "testTool", }); // Check for TOOL_CALL_ARGS const argsEvents = events.filter( (e: any) => e.type === EventType.TOOL_CALL_ARGS, ); expect(argsEvents).toHaveLength(2); // Check for TOOL_CALL_END const endEvent = events.find( (e: any) => e.type === EventType.TOOL_CALL_END, ); expect(endEvent).toMatchObject({ type: EventType.TOOL_CALL_END, toolCallId: "call1", }); // Check for TOOL_CALL_RESULT const resultEvent = events.find( (e: any) => e.type === EventType.TOOL_CALL_RESULT, ); expect(resultEvent).toMatchObject({ type: EventType.TOOL_CALL_RESULT, role: "tool", toolCallId: "call1", }); }); }); describe("Prompt Building", () => { it("should not add system message when no prompt, context, or state", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [{ id: "1", role: "user", content: "Hello" }], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.messages).toHaveLength(1); expect(callArgs.messages[0].role).toBe("user"); }); it("should prepend system message with config prompt", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", prompt: "You are a helpful assistant.", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [{ id: "1", role: "user", content: "Hello" }], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.messages).toHaveLength(2); expect(callArgs.messages[0]).toMatchObject({ role: "system", content: "You are a helpful assistant.", }); }); it("should include context in system message", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [ { description: "User Name", value: "John Doe" }, { description: "Location", value: "New York" }, ], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; const systemMessage = callArgs.messages[0]; expect(systemMessage.role).toBe("system"); expect(systemMessage.content).toContain("Context from the application"); expect(systemMessage.content).toContain("User Name"); expect(systemMessage.content).toContain("John Doe"); expect(systemMessage.content).toContain("Location"); expect(systemMessage.content).toContain("New York"); }); it("should include state in system message", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: { counter: 0, items: ["a", "b"] }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; const systemMessage = callArgs.messages[0]; expect(systemMessage.role).toBe("system"); expect(systemMessage.content).toContain("Application State"); expect(systemMessage.content).toContain("AGUISendStateSnapshot"); expect(systemMessage.content).toContain("AGUISendStateDelta"); expect(systemMessage.content).toContain('"counter": 0'); expect(systemMessage.content).toContain('"items"'); }); it("should combine prompt, context, and state", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", prompt: "You are helpful.", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [{ description: "Context", value: "Data" }], state: { value: 1 }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; const systemMessage = callArgs.messages[0]; expect(systemMessage.content).toContain("You are helpful."); expect(systemMessage.content).toContain("Context from the application"); expect(systemMessage.content).toContain("Application State"); // Check order: prompt, then context, then state const promptIndex = systemMessage.content.indexOf("You are helpful."); const contextIndex = systemMessage.content.indexOf( "Context from the application", ); const stateIndex = systemMessage.content.indexOf("Application State"); expect(promptIndex).toBeLessThan(contextIndex); expect(contextIndex).toBeLessThan(stateIndex); }); }); describe("Forward System/Developer Messages", () => { it("should ignore system messages by default", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "sys1", role: "system", content: "System instruction" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; // Should only have the user message, system message ignored expect(callArgs.messages).toHaveLength(1); expect(callArgs.messages[0].role).toBe("user"); }); it("should ignore developer messages by default", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "dev1", role: "developer", content: "Developer hint" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; // Should only have the user message, developer message ignored expect(callArgs.messages).toHaveLength(1); expect(callArgs.messages[0].role).toBe("user"); }); it("should forward system messages when forwardSystemMessages is true", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", forwardSystemMessages: true, }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "sys1", role: "system", content: "System instruction" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.messages).toHaveLength(2); expect(callArgs.messages[0]).toMatchObject({ role: "system", content: "System instruction", }); expect(callArgs.messages[1].role).toBe("user"); }); it("should forward developer messages as system when forwardDeveloperMessages is true", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", forwardDeveloperMessages: true, }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "dev1", role: "developer", content: "Developer hint" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.messages).toHaveLength(2); // Developer messages are converted to system role expect(callArgs.messages[0]).toMatchObject({ role: "system", content: "Developer hint", }); expect(callArgs.messages[1].role).toBe("user"); }); it("should forward both system and developer messages when both flags are true", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", forwardSystemMessages: true, forwardDeveloperMessages: true, }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "sys1", role: "system", content: "System instruction" }, { id: "dev1", role: "developer", content: "Developer hint" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.messages).toHaveLength(3); expect(callArgs.messages[0]).toMatchObject({ role: "system", content: "System instruction", }); expect(callArgs.messages[1]).toMatchObject({ role: "system", content: "Developer hint", }); expect(callArgs.messages[2].role).toBe("user"); }); it("should place config prompt before forwarded system/developer messages", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", prompt: "You are a helpful assistant.", forwardSystemMessages: true, forwardDeveloperMessages: true, }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [ { id: "sys1", role: "system", content: "System instruction" }, { id: "dev1", role: "developer", content: "Developer hint" }, { id: "user1", role: "user", content: "Hello" }, ], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; // Config prompt is prepended as first system message expect(callArgs.messages).toHaveLength(4); expect(callArgs.messages[0]).toMatchObject({ role: "system", content: "You are a helpful assistant.", }); expect(callArgs.messages[1]).toMatchObject({ role: "system", content: "System instruction", }); expect(callArgs.messages[2]).toMatchObject({ role: "system", content: "Developer hint", }); expect(callArgs.messages[3].role).toBe("user"); }); }); describe("Tool Configuration", () => { it("should include tools from config", async () => { const tool1 = defineTool({ name: "configTool", description: "A config tool", parameters: z.object({ input: z.string() }), execute: async () => ({ result: "ok" }), }); const agent = new BasicAgent({ model: "openai/gpt-4o", tools: [tool1], }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.tools).toHaveProperty("configTool"); }); it("should merge config tools with input tools", async () => { const configTool = defineTool({ name: "configTool", description: "From config", parameters: z.object({}), execute: async () => ({ result: "ok" }), }); const agent = new BasicAgent({ model: "openai/gpt-4o", tools: [configTool], }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [ { name: "inputTool", description: "From input", parameters: { type: "object", properties: {} }, }, ], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.tools).toHaveProperty("configTool"); expect(callArgs.tools).toHaveProperty("inputTool"); }); it("should always include state update tools", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.tools).toHaveProperty("AGUISendStateSnapshot"); expect(callArgs.tools).toHaveProperty("AGUISendStateDelta"); }); }); describe("Property Overrides", () => { it("should respect overridable properties", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", temperature: 0.5, overridableProperties: ["temperature"], }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, forwardedProps: { temperature: 0.9 }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.temperature).toBe(0.9); }); it("should ignore non-overridable properties", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", temperature: 0.5, overridableProperties: [], // No properties can be overridden }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, forwardedProps: { temperature: 0.9 }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.temperature).toBe(0.5); // Original value, not overridden }); }); describe("Error Handling", () => { it("should emit RUN_ERROR event on failure", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockImplementation(() => { throw new Error("Test error"); }); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; try { await collectEvents(agent["run"](input)); expect.fail("Should have thrown"); } catch (error: any) { // Error is expected - check that we got a RUN_ERROR event // Note: The error is thrown after emitting the event expect(error.message).toContain("Test error"); } }); }); describe("Reasoning Event Emission", () => { it("should emit full reasoning lifecycle events", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Let me think..."), reasoningDelta(" about this."), reasoningEnd(), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); // Verify event order const eventTypes = events.map((e: any) => e.type); expect(eventTypes[0]).toBe(EventType.RUN_STARTED); const reasoningStartIdx = eventTypes.indexOf(EventType.REASONING_START); const reasoningMsgStartIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_START, ); const reasoningContentIndices = eventTypes.reduce( (acc: number[], type: string, idx: number) => type === EventType.REASONING_MESSAGE_CONTENT ? [...acc, idx] : acc, [], ); const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); expect(reasoningStartIdx).toBeGreaterThan(0); expect(reasoningMsgStartIdx).toBeGreaterThan(reasoningStartIdx); expect(reasoningContentIndices).toHaveLength(2); expect(reasoningContentIndices[0]).toBeGreaterThan(reasoningMsgStartIdx); expect(reasoningMsgEndIdx).toBeGreaterThan( reasoningContentIndices[reasoningContentIndices.length - 1], ); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); // Verify consistent messageId across all reasoning events const reasoningEvents = events.filter((e: any) => [ EventType.REASONING_START, EventType.REASONING_MESSAGE_START, EventType.REASONING_MESSAGE_CONTENT, EventType.REASONING_MESSAGE_END, EventType.REASONING_END, ].includes(e.type), ); const messageIds = reasoningEvents.map((e: any) => e.messageId); expect(new Set(messageIds).size).toBe(1); // Verify REASONING_MESSAGE_START has role "reasoning" const msgStartEvent = events.find( (e: any) => e.type === EventType.REASONING_MESSAGE_START, ); expect(msgStartEvent).toMatchObject({ role: "reasoning" }); // Verify content deltas const contentEvents = events.filter( (e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT, ); expect(contentEvents[0]).toMatchObject({ delta: "Let me think..." }); expect(contentEvents[1]).toMatchObject({ delta: " about this." }); // Verify last event is RUN_FINISHED expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should emit reasoning events followed by text events", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("thinking"), reasoningEnd(), textDelta("Hello"), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e: any) => e.type); // Reasoning events should come before text events const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const textChunkIdx = eventTypes.indexOf(EventType.TEXT_MESSAGE_CHUNK); expect(reasoningEndIdx).toBeLessThan(textChunkIdx); // Reasoning messageId should differ from text messageId const reasoningEvent = events.find( (e: any) => e.type === EventType.REASONING_START, ); const textEvent = events.find( (e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK, ); expect(reasoningEvent.messageId).not.toBe(textEvent.messageId); }); it("should use provider-supplied reasoning id", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart("reasoning-msg-123"), reasoningDelta("content"), reasoningEnd(), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const reasoningEvents = events.filter((e: any) => [ EventType.REASONING_START, EventType.REASONING_MESSAGE_START, EventType.REASONING_MESSAGE_CONTENT, EventType.REASONING_MESSAGE_END, EventType.REASONING_END, ].includes(e.type), ); for (const event of reasoningEvents) { expect(event.messageId).toBe("reasoning-msg-123"); } }); it("should generate unique reasoningMessageId when provider returns id '0'", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart("0"), reasoningDelta("content"), reasoningEnd(), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const reasoningEvent = events.find( (e: any) => e.type === EventType.REASONING_START, ); expect(reasoningEvent.messageId).not.toBe("0"); expect(reasoningEvent.messageId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); }); it("should handle empty reasoning content", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta(""), reasoningEnd(), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); // Empty delta must NOT be emitted — EventSchemas rejects delta: "" const contentEvents = events.filter( (e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT, ); expect(contentEvents).toHaveLength(0); // Full lifecycle should still complete const eventTypes = events.map((e: any) => e.type); expect(eventTypes).toContain(EventType.REASONING_START); expect(eventTypes).toContain(EventType.REASONING_MESSAGE_START); expect(eventTypes).toContain(EventType.REASONING_MESSAGE_END); expect(eventTypes).toContain(EventType.REASONING_END); expect(eventTypes).toContain(EventType.RUN_FINISHED); }); it("should handle reasoning-only stream (no text output)", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Deep thought"), reasoningEnd(), finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); // No TEXT_MESSAGE_CHUNK events const textEvents = events.filter( (e: any) => e.type === EventType.TEXT_MESSAGE_CHUNK, ); expect(textEvents).toHaveLength(0); // Reasoning events are present const reasoningContentEvents = events.filter( (e: any) => e.type === EventType.REASONING_MESSAGE_CONTENT, ); expect(reasoningContentEvents).toHaveLength(1); expect(reasoningContentEvents[0]).toMatchObject({ delta: "Deep thought", }); }); it("should skip empty reasoning deltas and continue stream", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta(""), reasoningEnd(), finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); // No REASONING_MESSAGE_CONTENT events — empty delta skipped const contentEvents = events.filter( (e) => e.type === EventType.REASONING_MESSAGE_CONTENT, ); expect(contentEvents).toHaveLength(0); // Stream still completes with RUN_FINISHED const eventTypes = events.map((e) => e.type); expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should auto-close reasoning when SDK omits reasoning-end before tool call", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Thinking..."), // NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour toolCallStreamingStart("call1", "testTool"), toolCallDelta("call1", '{"arg":"val"}'), toolCall("call1", "testTool", { arg: "val" }), toolResult("call1", "testTool", { result: "success" }), finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TOOL_CALL_START const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const toolCallStartIdx = eventTypes.indexOf(EventType.TOOL_CALL_START); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(reasoningEndIdx).toBeLessThan(toolCallStartIdx); // Each close event must appear exactly once (guard against double-emit) expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); // Stream still completes with RUN_FINISHED expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should auto-close reasoning when SDK omits reasoning-end before text", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Let me think"), // NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour textStart(), textDelta("Answer"), finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // REASONING_MESSAGE_END must appear before REASONING_END, which must appear before TEXT_MESSAGE_CHUNK const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const textChunkIdx = eventTypes.indexOf(EventType.TEXT_MESSAGE_CHUNK); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(reasoningEndIdx).toBeLessThan(textChunkIdx); // Each close event must appear exactly once (guard against double-emit) expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); // Stream still completes with RUN_FINISHED expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should auto-close reasoning when SDK omits reasoning-end before finish", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Deep thought"), // NO reasoningEnd() — simulates @ai-sdk/anthropic behaviour finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // REASONING_MESSAGE_END must appear before REASONING_END (auto-closed by finish case) const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); // Each close event must appear exactly once (guard against double-emit) expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); // Stream still completes with RUN_FINISHED expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should auto-close reasoning when stream aborts mid-reasoning", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Thinking..."), // NO reasoningEnd() — stream aborts before SDK can close reasoning abort(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_FINISHED const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const runFinishedIdx = eventTypes.indexOf(EventType.RUN_FINISHED); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(runFinishedIdx).toBeGreaterThan(reasoningEndIdx); // Each close event must appear exactly once (guard against double-emit) expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); // Stream still completes with RUN_FINISHED expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should auto-close reasoning when stream errors mid-reasoning", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Thinking..."), // NO reasoningEnd() — stream errors before SDK can close reasoning error("stream failed"), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; // subscriber.error() causes collectEvents to reject, so collect manually const events: BaseEvent[] = []; await new Promise((resolve) => { agent["run"](input).subscribe({ next: (e) => events.push(e), error: () => resolve(), // error is expected complete: () => resolve(), }); }); const eventTypes = events.map((e) => e.type); // REASONING_MESSAGE_END must appear before REASONING_END, both before RUN_ERROR const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const runErrorIdx = eventTypes.indexOf(EventType.RUN_ERROR); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(runErrorIdx).toBeGreaterThan(reasoningEndIdx); // Each close event must appear exactly once (guard against double-emit) expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); }); it("should auto-close reasoning for consecutive blocks with no reasoning-end between them", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("First thought"), // NO reasoningEnd() — second block starts immediately reasoningStart(), reasoningDelta("Second thought"), reasoningEnd(), finish(), ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // Both reasoning blocks must be properly closed — two complete lifecycles expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(2); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(2); // First block's REASONING_END must appear before second block's REASONING_START const firstReasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const secondReasoningStartIdx = eventTypes.lastIndexOf( EventType.REASONING_START, ); expect(firstReasoningEndIdx).toBeLessThan(secondReasoningStartIdx); // The two blocks must use distinct messageIds const startEvents = events.filter( (e): e is ReasoningStartEvent => e.type === EventType.REASONING_START, ); expect(startEvents).toHaveLength(2); expect(startEvents[0].messageId).not.toBe(startEvents[1].messageId); expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should close reasoning when an exception is thrown mid-stream", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); // Simulate the fullStream generator throwing mid-iteration (not a stream error event) const throwingStream = { fullStream: (async function* () { yield reasoningStart(); yield reasoningDelta("Thinking..."); throw new Error("unexpected network failure"); })(), }; vi.mocked(streamText).mockReturnValue( throwingStream as unknown as ReturnType, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; // subscriber.error() causes collectEvents to reject, so collect manually const events: BaseEvent[] = []; await new Promise((resolve) => { agent["run"](input).subscribe({ next: (e) => events.push(e), error: () => resolve(), // error is expected complete: () => resolve(), }); }); const eventTypes = events.map((e) => e.type); // Reasoning must be closed before RUN_ERROR despite the exception path const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const runErrorIdx = eventTypes.indexOf(EventType.RUN_ERROR); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(runErrorIdx).toBeGreaterThan(reasoningEndIdx); expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); }); it("should close reasoning and emit RUN_FINISHED when stream exhausts without terminal event", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); // Stream ends with no finish/abort/error — exercises !terminalEventEmitted fallback vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("Thinking..."), // deliberate: no finish(), no abort(), no error() ]), ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // Reasoning must be closed before RUN_FINISHED via fallback const reasoningMsgEndIdx = eventTypes.indexOf( EventType.REASONING_MESSAGE_END, ); const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const runFinishedIdx = eventTypes.indexOf(EventType.RUN_FINISHED); expect(reasoningMsgEndIdx).toBeGreaterThan(0); expect(reasoningEndIdx).toBeGreaterThan(reasoningMsgEndIdx); expect(runFinishedIdx).toBeGreaterThan(reasoningEndIdx); expect( eventTypes.filter((t) => t === EventType.REASONING_MESSAGE_END), ).toHaveLength(1); expect( eventTypes.filter((t) => t === EventType.REASONING_END), ).toHaveLength(1); expect(eventTypes[eventTypes.length - 1]).toBe(EventType.RUN_FINISHED); }); it("should handle reasoning interleaved with tool calls", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([ reasoningStart(), reasoningDelta("I need to call a tool"), reasoningEnd(), toolCallStreamingStart("call1", "testTool"), toolCallDelta("call1", '{"arg":"val"}'), toolCall("call1", "testTool", { arg: "val" }), toolResult("call1", "testTool", { result: "success" }), finish(), ]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; const events = await collectEvents(agent["run"](input)); const eventTypes = events.map((e) => e.type); // Reasoning events precede tool call events const reasoningEndIdx = eventTypes.indexOf(EventType.REASONING_END); const toolCallStartIdx = eventTypes.indexOf(EventType.TOOL_CALL_START); expect(reasoningEndIdx).toBeLessThan(toolCallStartIdx); // Both lifecycles complete expect(eventTypes).toContain(EventType.REASONING_START); expect(eventTypes).toContain(EventType.REASONING_END); expect(eventTypes).toContain(EventType.TOOL_CALL_START); expect(eventTypes).toContain(EventType.TOOL_CALL_END); }); }); describe("Provider Options", () => { it("should pass providerOptions to streamText", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", providerOptions: { openai: { reasoningEffort: "high", reasoningSummary: "detailed" }, }, }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.providerOptions).toEqual({ openai: { reasoningEffort: "high", reasoningSummary: "detailed" }, }); }); it("should allow providerOptions override via forwardedProps when overridable", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", providerOptions: { openai: { reasoningEffort: "low" }, }, overridableProperties: ["providerOptions"], }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, forwardedProps: { providerOptions: { openai: { reasoningEffort: "high" }, }, }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.providerOptions).toEqual({ openai: { reasoningEffort: "high" }, }); }); it("should NOT allow providerOptions override when not in overridableProperties", async () => { const agent = new BasicAgent({ model: "openai/gpt-4o", providerOptions: { openai: { reasoningEffort: "low" }, }, overridableProperties: [], }); vi.mocked(streamText).mockReturnValue( mockStreamTextResponse([finish()]) as any, ); const input: RunAgentInput = { threadId: "thread1", runId: "run1", messages: [], tools: [], context: [], state: {}, forwardedProps: { providerOptions: { openai: { reasoningEffort: "high" }, }, }, }; await collectEvents(agent["run"](input)); const callArgs = vi.mocked(streamText).mock.calls[0][0]; expect(callArgs.providerOptions).toEqual({ openai: { reasoningEffort: "low" }, }); }); }); });