/** * agent-runner.test.ts — unit tests for the session event accumulator. * * `applySessionEvent` runs synchronously inside the SDK's event pipeline; a * throw there crashes the host (the SDK's run-failure path re-emits events * through the same callback → stack overflow), stranding run-state at the * phase boundary. These tests pin the handler to never throw on the partial / * malformed event shapes streaming sessions actually emit. */ import { describe, expect, it } from "bun:test"; import { applySessionEvent, emptySessionError, type SessionEventAccumulator, } from "../src/agent-runner.js"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent"; function fresh(): SessionEventAccumulator { return { text: "", sawMessage: false }; } /** Build a typed-as-unknown event so malformed shapes compile in tests. */ function event(shape: unknown): AgentSessionEvent { return shape as AgentSessionEvent; } describe("applySessionEvent", () => { it("accumulates text_delta stream events in order", () => { const acc = fresh(); applySessionEvent( acc, event({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "foo" }, }), ); applySessionEvent( acc, event({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "bar" }, }), ); expect(acc.text).toBe("foobar"); }); it("does not throw on a message_update with no assistantMessageEvent", () => { const acc = fresh(); expect(() => applySessionEvent(acc, event({ type: "message_update" })), ).not.toThrow(); expect(acc.text).toBe(""); }); it("does not throw on a message_update with an unknown event shape", () => { const acc = fresh(); expect(() => applySessionEvent(acc, event({ type: "bogus_event" }))).not.toThrow(); expect(() => applySessionEvent(acc, event(null))).not.toThrow(); expect(() => applySessionEvent(acc, event(undefined))).not.toThrow(); expect(acc.text).toBe(""); }); it("captures full text + stopReason from message_end when nothing streamed", () => { const acc = fresh(); applySessionEvent( acc, event({ type: "message_end", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "full report" }], }, }), ); expect(acc.text).toBe("full report"); expect(acc.stopReason).toBe("stop"); }); it("does not throw on a message_end with no message", () => { const acc = fresh(); expect(() => applySessionEvent(acc, event({ type: "message_end" }))).not.toThrow(); expect(acc.text).toBe(""); }); it("records an errorMessage surfaced on the final message", () => { const acc = fresh(); applySessionEvent( acc, event({ type: "message_end", message: { role: "assistant", errorMessage: "upstream 529" }, }), ); expect(acc.errorMessage).toBe("upstream 529"); }); it("forwards stream-driving events and swallows a throwing forwarder", () => { const originalError = console.error; console.error = () => {}; try { const forwarded: string[] = []; const forward = (ev: AgentSessionEvent) => { forwarded.push(ev.type); if (ev.type === "tool_execution_end") throw new Error("renderer boom"); }; const acc = fresh(); applySessionEvent( acc, event({ type: "tool_execution_start", toolName: "bash" }), forward, ); applySessionEvent( acc, event({ type: "tool_execution_end", toolName: "bash" }), forward, ); expect(forwarded).toEqual(["tool_execution_start", "tool_execution_end"]); } finally { console.error = originalError; } }); it("does not forward non-stream-driving events", () => { const forwarded: string[] = []; const acc = fresh(); applySessionEvent( acc, event({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "x" } }), (ev) => forwarded.push(ev.type), ); applySessionEvent(acc, event({ type: "agent_settled" }), (ev) => forwarded.push(ev.type), ); expect(forwarded).toEqual([]); expect(acc.text).toBe("x"); }); it("marks sawMessage when any message or tool event is observed", () => { const fromUpdate = fresh(); applySessionEvent(fromUpdate, event({ type: "message_update" })); expect(fromUpdate.sawMessage).toBe(true); const fromEnd = fresh(); applySessionEvent(fromEnd, event({ type: "message_end", message: null })); expect(fromEnd.sawMessage).toBe(true); const fromTool = fresh(); applySessionEvent(fromTool, event({ type: "tool_execution_start" })); expect(fromTool.sawMessage).toBe(true); }); }); describe("emptySessionError", () => { it("fails a session that settled with no text and no observed events", () => { expect(emptySessionError({ text: "", sawMessage: false })).toContain( "no output", ); }); it("fails an empty session whose final message reported stopReason error", () => { expect( emptySessionError({ text: "", sawMessage: true, stopReason: "error" }), ).toBe("sub-agent session ended in error with no output."); }); it("surfaces a recorded errorMessage regardless of text", () => { expect( emptySessionError({ text: "partial output", sawMessage: true, errorMessage: "upstream 529", }), ).toBe("upstream 529"); }); it("accepts an empty-text session that demonstrably ran (tool activity)", () => { expect( emptySessionError({ text: "", sawMessage: true, stopReason: "end_turn" }), ).toBeUndefined(); }); it("accepts any session with text", () => { expect( emptySessionError({ text: "report", sawMessage: false }), ).toBeUndefined(); }); });