import { describe, expect, test, mock } from "bun:test"; import type { Middleware, Action } from "@gizmo-ai/runtime"; import { createServerPlugin } from "../plugin.ts"; import type { PersistenceStore, ActionRecord, RunRecord } from "../persistence/index.ts"; /** State type for tests */ type TestState = Record; /** * Helper to extract middleware from a plugin. * RuntimePlugin.middleware can be a single function or an array. */ function getMiddleware(plugin: { middleware?: Middleware | Middleware[] }): Middleware { if (!plugin.middleware) { throw new Error("Plugin has no middleware"); } if (Array.isArray(plugin.middleware)) { if (plugin.middleware.length === 0) { throw new Error("Plugin middleware array is empty"); } return plugin.middleware[0]; } return plugin.middleware; } describe("createServerPlugin", () => { test("creates plugin with event bus", () => { const serverPlugin = createServerPlugin(); expect(serverPlugin.plugin).toBeDefined(); expect(serverPlugin.bus).toBeDefined(); expect(serverPlugin.slices).toBeUndefined(); }); test("stores configured slices", () => { const serverPlugin = createServerPlugin({ slices: ["execution", "agent"], }); expect(serverPlugin.slices).toEqual(["execution", "agent"]); }); test("middleware emits state.changed for slice changes", () => { const serverPlugin = createServerPlugin(); const handler = mock(() => {}); serverPlugin.bus.on("state.changed", handler); // Create mock API let currentState = { test: { value: 1 } }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; // Create middleware chain const next = mock((action: { type: string }) => { // Simulate state change in next if (action.type === "UPDATE") { currentState = { test: { value: 2 } }; } return action; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); // Dispatch action that changes state chain({ type: "UPDATE" }); expect(next).toHaveBeenCalledWith({ type: "UPDATE" }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ slice: "test", before: { value: 1 }, after: { value: 2 }, causingAction: { type: "UPDATE" }, }) ); }); test("middleware only emits for configured slices", () => { const serverPlugin = createServerPlugin({ slices: ["agent"], // Only track 'agent' slice }); const handler = mock(() => {}); serverPlugin.bus.on("state.changed", handler); let currentState = { agent: { loop: 0 }, execution: { state: "idle" } }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((action: { type: string }) => { if (action.type === "UPDATE_EXECUTION") { currentState = { ...currentState, execution: { state: "pending" } }; } if (action.type === "UPDATE_AGENT") { currentState = { ...currentState, agent: { loop: 1 } }; } return action; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); // Update execution (not tracked) chain({ type: "UPDATE_EXECUTION" }); expect(handler).not.toHaveBeenCalled(); // Update agent (tracked) chain({ type: "UPDATE_AGENT" }); expect(handler).toHaveBeenCalledTimes(1); }); test("middleware does not emit when state unchanged", () => { const serverPlugin = createServerPlugin(); const handler = mock(() => {}); serverPlugin.bus.on("state.changed", handler); const currentState = { test: { value: 1 } }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; const next = mock(() => { // State doesn't change return { type: "NOOP" }; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); chain({ type: "NOOP" }); expect(handler).not.toHaveBeenCalled(); }); test("middleware emits execution.lifecycle for lifecycle actions", () => { const serverPlugin = createServerPlugin(); const handler = mock(() => {}); serverPlugin.bus.on("execution.lifecycle", handler); const api = { getState: () => ({}), dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((a: unknown) => a); const chain = getMiddleware(serverPlugin.plugin)(api)(next); // Dispatch execution started action (PayloadAction with .payload) chain({ type: "RUNTIME_EXECUTION_STARTED", payload: { executionId: "exec-123", turnId: "turn-1", input: "test", }, }); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ status: "started", executionId: "exec-123", turnId: "turn-1", }) ); }); test("middleware emits for all execution lifecycle events", () => { const serverPlugin = createServerPlugin(); const handler = mock(() => {}); serverPlugin.bus.on("execution.lifecycle", handler); const api = { getState: () => ({}), dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((a: unknown) => a); const chain = getMiddleware(serverPlugin.plugin)(api)(next); // Actions are PayloadAction types with properties under .payload const lifecycleActions = [ { type: "RUNTIME_EXECUTION_STARTED", payload: { executionId: "e1", turnId: "t1", input: "test" } }, { type: "RUNTIME_EXECUTION_COMPLETED", payload: { executionId: "e1" } }, { type: "RUNTIME_EXECUTION_FAILED", payload: { executionId: "e1", error: new Error("test") } }, { type: "RUNTIME_EXECUTION_ABORTED", payload: { executionId: "e1" } }, ]; lifecycleActions.forEach((action) => chain(action)); expect(handler).toHaveBeenCalledTimes(4); }); test("auto-excludes non-serializable slices when slices not configured", () => { const serverPlugin = createServerPlugin(); // No slices config const handler = mock(() => {}); serverPlugin.bus.on("state.changed", handler); // State with both serializable and non-serializable slices let currentState: TestState = { agent: { status: "idle" }, tools: [{ name: "test", execute: () => Promise.resolve("") }], // Has function execution: { id: "exec-1" }, }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((action: { type: string }) => { if (action.type === "UPDATE") { // Update all slices currentState = { agent: { status: "running" }, tools: [{ name: "test", execute: () => Promise.resolve("") }], execution: { id: "exec-2" }, }; } return action; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); chain({ type: "UPDATE" }); // Should emit for agent and execution, but NOT tools expect(handler).toHaveBeenCalledTimes(2); const calls = handler.mock.calls as unknown as Array<[{ slice: string }]>; const slices = calls.map((call) => call[0].slice); expect(slices).toContain("agent"); expect(slices).toContain("execution"); expect(slices).not.toContain("tools"); }); test("warns about skipped non-serializable slices (one-time)", () => { const consoleWarnSpy = mock(() => {}); const originalWarn = console.warn; console.warn = consoleWarnSpy; try { const serverPlugin = createServerPlugin(); let currentState: TestState = { tools: [{ name: "test", execute: () => {} }], agent: { status: "idle" }, }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((action: { type: string }) => { currentState = { tools: [{ name: "test", execute: () => {} }], agent: { status: "running" }, }; return action; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); // First dispatch should trigger warning chain({ type: "UPDATE1" }); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("Skipping non-serializable slices: tools") ); // Second dispatch should NOT trigger warning again chain({ type: "UPDATE2" }); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); // Still 1 } finally { console.warn = originalWarn; } }); test("includes slice if explicitly configured, even with functions", () => { const serverPlugin = createServerPlugin({ slices: ["tools"], // User explicitly wants this }); const handler = mock(() => {}); serverPlugin.bus.on("state.changed", handler); let currentState: TestState = { tools: [{ name: "test", execute: () => {} }], agent: { status: "idle" }, }; const api = { getState: () => currentState, dispatch: () => {}, subscribe: () => () => {}, }; const next = mock((action: { type: string }) => { if (action.type === "UPDATE") { currentState = { tools: [{ name: "test2", execute: () => {} }], agent: { status: "running" }, }; } return action; }); const chain = getMiddleware(serverPlugin.plugin)(api)(next); chain({ type: "UPDATE" }); // Should include tools (user's explicit choice), but NOT agent (not in config) expect(handler).toHaveBeenCalledTimes(1); const firstCall = handler.mock.calls[0] as unknown as [{ slice: string }]; expect(firstCall[0].slice).toBe("tools"); }); test("recordAction tracks run history without middleware wiring", () => { const serverPlugin = createServerPlugin({ historySize: 10, }); const runningState = { execution: { id: "exec-1", state: "pending", turnIds: ["turn-1"] }, }; const completedState = { execution: { id: "exec-1", state: "completed", turnIds: ["turn-1"] }, }; serverPlugin.recordAction( { type: "RUNTIME_EXECUTION_STARTED", payload: { executionId: "exec-1", turnId: "turn-1", input: "hello", }, }, runningState ); serverPlugin.recordAction( { type: "RUNTIME_EXECUTION_COMPLETED", payload: { executionId: "exec-1", }, }, completedState ); const run = serverPlugin.getRunHistory().get("exec-1"); expect(run).toBeDefined(); expect(run?.status).toBe("completed"); }); test("persisted action log excludes AGENT_MODEL_STREAM_* actions", async () => { const appendedActions: ActionRecord[] = []; const mockStore: PersistenceStore = { appendRun: mock(async () => {}), updateRun: mock(async () => {}), queryRuns: async () => [], getRun: async () => null, appendAction: mock(async (_id: string, record: ActionRecord) => { appendedActions.push(record); }), getActions: async () => [], appendState: mock(async () => {}), getLatestState: async () => null, queryState: async () => [], init: async () => {}, close: async () => {}, }; const serverPlugin = createServerPlugin({ historySize: 10, persistence: { enabled: true, baseDir: ".gizmo" }, }); // Replace the internal store by creating a plugin with persistence enabled, // then use recordAction which goes through trackRunHistory directly. // We need to test via middleware to exercise the store. // Instead, create a plugin that uses our mock store. // The cleanest way is to test via the middleware chain. // Build a plugin with persistence enabled — but we need to intercept the store. // Let's test via recordAction + check the mock store on the instance. // Actually, the persistence store is created internally. Let's use the middleware approach. // Create plugin with real persistence config — then we check appendAction calls. // Better: use the plugin's persistence property. const serverPlugin2 = createServerPlugin({ historySize: 10, persistence: { enabled: true, baseDir: "/tmp/gizmo-test-" + Date.now() }, }); // We can't easily mock the internal store, so let's test via middleware. // The approach: use middleware chain with a state that has execution.id, // and verify that streaming actions don't reach appendAction. // Alternative: test with a real store and read back. // Simplest: use recordAction (which calls trackRunHistory) with the real store. // Actually, let's just verify the behavior at the type level by checking // that in-memory still has streaming actions, but appendAction is called // only for non-streaming ones. We'll use the persistence property. const store = serverPlugin2.persistence!; expect(store).toBeDefined(); // Initialize await store.init(); const execId = "test-exec-" + Date.now(); const runningState = { execution: { id: execId, state: "pending", turnIds: ["turn-1"] }, }; // Start execution serverPlugin2.recordAction( { type: "RUNTIME_EXECUTION_STARTED", payload: { executionId: execId, turnId: "turn-1", input: "hello" }, }, runningState ); // Non-streaming action serverPlugin2.recordAction( { type: "AGENT_MODEL_REQUESTED", payload: {} }, runningState ); // Streaming actions (should be filtered) serverPlugin2.recordAction( { type: "AGENT_MODEL_STREAM_STARTED", payload: {} }, runningState ); serverPlugin2.recordAction( { type: "AGENT_MODEL_STREAM_CONTENT_DELTA", payload: { delta: "Hello" } }, runningState ); serverPlugin2.recordAction( { type: "AGENT_MODEL_STREAM_CONTENT_DELTA", payload: { delta: " world" } }, runningState ); serverPlugin2.recordAction( { type: "AGENT_MODEL_STREAM_REASONING_DELTA", payload: { delta: "thinking" } }, runningState ); serverPlugin2.recordAction( { type: "AGENT_MODEL_STREAM_COMPLETED", payload: {} }, runningState ); // Non-streaming action serverPlugin2.recordAction( { type: "AGENT_MODEL_RESPONDED", payload: { content: "Hello world" } }, runningState ); // Wait for async writes to flush await new Promise((resolve) => setTimeout(resolve, 100)); // Read back persisted actions (sort by seq — async writes may land out of order) const actions = (await store.getActions(execId)).sort((a, b) => a.seq - b.seq); const actionTypes = actions.map((a) => a.type); // Should contain non-streaming actions expect(actionTypes).toContain("RUNTIME_EXECUTION_STARTED"); expect(actionTypes).toContain("AGENT_MODEL_REQUESTED"); expect(actionTypes).toContain("AGENT_MODEL_RESPONDED"); // Should NOT contain any streaming actions const streamingActions = actionTypes.filter((t) => t.startsWith("AGENT_MODEL_STREAM_")); expect(streamingActions).toHaveLength(0); // Sequences should be dense (no gaps) const seqs = actions.map((a) => a.seq); expect(seqs).toEqual([0, 1, 2]); // STARTED=0, REQUESTED=1, RESPONDED=2 // In-memory run should still have ALL actions (including streaming) const inMemoryRun = serverPlugin2.getRunHistory().get(execId); expect(inMemoryRun).toBeDefined(); expect(inMemoryRun!.actions.length).toBe(8); // All 8 actions await store.close(); }); test("hydration from lean log reconstructs correct state", async () => { const serverPlugin = createServerPlugin({ historySize: 10, persistence: { enabled: true, baseDir: "/tmp/gizmo-hydrate-test-" + Date.now() }, }); const store = serverPlugin.persistence!; await store.init(); const execId = "hydrate-test-" + Date.now(); const runningState = { execution: { id: execId, state: "running", turnIds: ["turn-1"] }, }; const completedState = { execution: { id: execId, state: "completed", turnIds: ["turn-1"] }, }; // Simulate a full agent cycle with streaming serverPlugin.recordAction( { type: "RUNTIME_EXECUTION_STARTED", payload: { executionId: execId, turnId: "turn-1", input: "hello" }, }, runningState ); serverPlugin.recordAction( { type: "AGENT_LOOP_INCREMENTED", payload: { loopCount: 1 } }, runningState ); serverPlugin.recordAction( { type: "AGENT_MODEL_REQUESTED", payload: {} }, runningState ); // Streaming deltas (should be filtered from persistence) for (let i = 0; i < 5; i++) { serverPlugin.recordAction( { type: "AGENT_MODEL_STREAM_CONTENT_DELTA", payload: { delta: `chunk${i}` } }, runningState ); } serverPlugin.recordAction( { type: "AGENT_MODEL_RESPONDED", payload: { content: "chunk0chunk1chunk2chunk3chunk4" } }, runningState ); serverPlugin.recordAction( { type: "AGENT_TOOL_CALL_REQUESTED", payload: { name: "bash", args: {} } }, runningState ); serverPlugin.recordAction( { type: "AGENT_TOOL_CALL_EXECUTED", payload: { name: "bash", output: "ok" } }, runningState ); serverPlugin.recordAction( { type: "RUNTIME_EXECUTION_COMPLETED", payload: { executionId: execId }, }, completedState ); // Wait for async writes await new Promise((resolve) => setTimeout(resolve, 100)); // Sort by seq — async writes may land out of order in JSONL const actions = (await store.getActions(execId)).sort((a, b) => a.seq - b.seq); // Verify lean log has the complete story without streaming noise const types = actions.map((a) => a.type); expect(types).toEqual([ "RUNTIME_EXECUTION_STARTED", "AGENT_LOOP_INCREMENTED", "AGENT_MODEL_REQUESTED", "AGENT_MODEL_RESPONDED", "AGENT_TOOL_CALL_REQUESTED", "AGENT_TOOL_CALL_EXECUTED", "RUNTIME_EXECUTION_COMPLETED", ]); // Dense sequence numbers const seqs = actions.map((a) => a.seq); expect(seqs).toEqual([0, 1, 2, 3, 4, 5, 6]); // The model response contains the complete output (no info loss) // payload is sanitizeAction(action), so content is at .payload.payload.content const respondedAction = actions.find((a) => a.type === "AGENT_MODEL_RESPONDED"); expect(respondedAction).toBeDefined(); expect((respondedAction!.payload as any).payload.content).toBe("chunk0chunk1chunk2chunk3chunk4"); await store.close(); }); });