|
| 1 | +import { describe, expect, test } from "bun:test"; |
| 2 | +import type { ReactorEmittedEvent } from "@intx/inference"; |
| 3 | +import type { ReactorInboundEvent } from "@intx/types/runtime"; |
| 4 | +import { onReactorShutdown, onTurnBoundary } from "./reactor-events.js"; |
| 5 | + |
| 6 | +// Bare `{ type: string }` literals only prove the string comparison works. |
| 7 | +// The generic exists so the guards narrow across both `ReactorInboundEvent` |
| 8 | +// (the director-facing union, `src/agent/director.ts` / `compaction.ts`) and |
| 9 | +// `ReactorEmittedEvent` (the stream-facing union consumers see) without |
| 10 | +// redeclaring either union in `reactor-events.ts`. These tests drive real |
| 11 | +// members of both unions through the guards so a future change that breaks |
| 12 | +// narrowing on either union — e.g. a renamed variant, or the guard's |
| 13 | +// signature drifting to accept only one union — fails here instead of |
| 14 | +// surfacing as a silent `never` match downstream. |
| 15 | + |
| 16 | +// `reactor.done` is emitted-only: it does not exist on `ReactorInboundEvent` |
| 17 | +// at all, so `onReactorShutdown` narrows to `never` for every director-side |
| 18 | +// event. That is exactly the distinction the doc and the guard both draw |
| 19 | +// ("did a turn end" is a question directors ask; "did the reactor shut |
| 20 | +// down" is not), and it is a fact the integration harness cannot exercise |
| 21 | +// on its own — the agent stream never hands a `ReactorInboundEvent` to |
| 22 | +// application code, only `ReactorEmittedEvent`. |
| 23 | +const inboundEvents: ReactorInboundEvent[] = [ |
| 24 | + { |
| 25 | + type: "message.received", |
| 26 | + message: { role: "user", content: [{ type: "text", text: "hi" }] }, |
| 27 | + } as unknown as ReactorInboundEvent, |
| 28 | + { |
| 29 | + type: "inference.done", |
| 30 | + turn: {}, |
| 31 | + usage: {}, |
| 32 | + source: {}, |
| 33 | + } as unknown as ReactorInboundEvent, |
| 34 | + { |
| 35 | + type: "inference.error", |
| 36 | + error: {}, |
| 37 | + partial: {}, |
| 38 | + } as unknown as ReactorInboundEvent, |
| 39 | + { type: "tool.done", result: {} } as unknown as ReactorInboundEvent, |
| 40 | + { |
| 41 | + type: "reactor.gate.cleared", |
| 42 | + gateId: "g1", |
| 43 | + reason: "resolved", |
| 44 | + } as unknown as ReactorInboundEvent, |
| 45 | + { type: "abort", reason: {} } as unknown as ReactorInboundEvent, |
| 46 | +]; |
| 47 | + |
| 48 | +const emittedEvents: ReactorEmittedEvent[] = [ |
| 49 | + { |
| 50 | + type: "message.received", |
| 51 | + seq: 0, |
| 52 | + data: { message: { role: "user", content: [{ type: "text", text: "hi" }] } }, |
| 53 | + } as unknown as ReactorEmittedEvent, |
| 54 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 55 | + { type: "reactor.done", data: {} } as unknown as ReactorEmittedEvent, |
| 56 | + { type: "tool.done", data: {} } as unknown as ReactorEmittedEvent, |
| 57 | +]; |
| 58 | + |
| 59 | +describe("onTurnBoundary", () => { |
| 60 | + test("narrows ReactorInboundEvent to exactly the inference.done member", () => { |
| 61 | + const matches = inboundEvents.filter(onTurnBoundary); |
| 62 | + expect(matches.map((e) => e.type)).toEqual(["inference.done"]); |
| 63 | + // Type-level: narrowing must land on the real union member, with its |
| 64 | + // real fields, not an unrelated shape — this line fails to compile if |
| 65 | + // onTurnBoundary stops narrowing E correctly. |
| 66 | + const [narrowed] = matches; |
| 67 | + const _turn: unknown = narrowed?.turn; |
| 68 | + void _turn; |
| 69 | + }); |
| 70 | + |
| 71 | + test("narrows ReactorEmittedEvent to exactly the inference.done member", () => { |
| 72 | + const matches = emittedEvents.filter(onTurnBoundary); |
| 73 | + expect(matches.map((e) => e.type)).toEqual(["inference.done"]); |
| 74 | + }); |
| 75 | + |
| 76 | + // The property all three shipped defects violated: code that gated a |
| 77 | + // turn boundary on `reactor.done` only ever saw it once, at shutdown. |
| 78 | + // A multi-turn session must trip this guard once per turn. |
| 79 | + test("fires more than once across a multi-turn stream of real events", () => { |
| 80 | + const turnEvents: ReactorEmittedEvent[] = [ |
| 81 | + { type: "inference.start", data: {} } as unknown as ReactorEmittedEvent, |
| 82 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 83 | + { type: "tool.done", data: {} } as unknown as ReactorEmittedEvent, |
| 84 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 85 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 86 | + ]; |
| 87 | + |
| 88 | + const boundaries = turnEvents.filter(onTurnBoundary); |
| 89 | + |
| 90 | + expect(boundaries.length).toBe(3); |
| 91 | + expect(boundaries.length).toBeGreaterThan(1); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +describe("onReactorShutdown", () => { |
| 96 | + test("never matches any ReactorInboundEvent member — reactor.done is emitted-only", () => { |
| 97 | + const matches = inboundEvents.filter(onReactorShutdown); |
| 98 | + expect(matches).toEqual([]); |
| 99 | + }); |
| 100 | + |
| 101 | + test("narrows ReactorEmittedEvent to exactly the reactor.done member, once per session", () => { |
| 102 | + const sessionEvents: ReactorEmittedEvent[] = [ |
| 103 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 104 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 105 | + { type: "inference.done", data: {} } as unknown as ReactorEmittedEvent, |
| 106 | + { type: "reactor.done", data: {} } as unknown as ReactorEmittedEvent, |
| 107 | + ]; |
| 108 | + |
| 109 | + const matches = emittedEvents.filter(onReactorShutdown); |
| 110 | + expect(matches.map((e) => e.type)).toEqual(["reactor.done"]); |
| 111 | + |
| 112 | + const shutdowns = sessionEvents.filter(onReactorShutdown); |
| 113 | + expect(shutdowns.length).toBe(1); |
| 114 | + }); |
| 115 | +}); |
0 commit comments