|
| 1 | +import { chat } from "../ai.js"; |
| 2 | +import type { |
| 3 | + ChannelAckCtx, |
| 4 | + ChannelConnector, |
| 5 | + ChannelInteractionCtx, |
| 6 | + ChannelInteractionResolution, |
| 7 | + ChannelMessage, |
| 8 | + ChannelMessageInput, |
| 9 | + ChannelPendingToolCall, |
| 10 | + ChannelReaction, |
| 11 | + ChannelReactions, |
| 12 | + ChannelSendCtx, |
| 13 | +} from "../ai.js"; |
| 14 | +import { webhooks } from "../webhooks.js"; |
| 15 | +import { DEFAULT_TEST_CONNECTOR_ID } from "./mock-chat-agent.js"; |
| 16 | + |
| 17 | +/** The default event shape {@link recordingChannelConnector} maps when no `inbound` is given. */ |
| 18 | +export type TestChannelEvent = { text: string; threadId?: string }; |
| 19 | + |
| 20 | +/** A single `send()` call captured by {@link recordingChannelConnector}. */ |
| 21 | +export type RecordedSend<TEvent = unknown> = { |
| 22 | + /** The channel message the connector was asked to post (or edit into place). */ |
| 23 | + message: ChannelMessage; |
| 24 | + /** The egress context: `final`, `mode`, `previousRef`, the raw `event`, `deliveryId`. */ |
| 25 | + ctx: ChannelSendCtx<TEvent>; |
| 26 | + /** The ref this send resolved to (echoes `previousRef` on an edit, else a fresh id). */ |
| 27 | + ref: string; |
| 28 | +}; |
| 29 | + |
| 30 | +/** A reaction applied to the triggering message, captured by {@link recordingChannelConnector}. */ |
| 31 | +export type RecordedReaction<TEvent = unknown> = { reaction: ChannelReaction; event: TEvent }; |
| 32 | + |
| 33 | +/** A HITL `finalizeInteraction()` call, captured by {@link recordingChannelConnector}. */ |
| 34 | +export type RecordedFinalize<TEvent = unknown> = { |
| 35 | + event: TEvent; |
| 36 | + resolution: ChannelInteractionResolution; |
| 37 | +}; |
| 38 | + |
| 39 | +/** |
| 40 | + * A real {@link ChannelConnector} whose egress hooks record what they were |
| 41 | + * asked to do instead of touching a network, so a test can assert the channel |
| 42 | + * round-trip a `chat.agent` turn produced. Built through the real |
| 43 | + * `chat.channels.custom` factory, so it is a genuinely-shaped connector; only |
| 44 | + * `send` / `ack` / `react` / `finalizeInteraction` are swapped for recorders. |
| 45 | + */ |
| 46 | +export type RecordingChannelConnector<TEvent = unknown> = ChannelConnector<TEvent> & { |
| 47 | + /** Every `send()` call in order: the ack, any stream edits, and the final reply. */ |
| 48 | + readonly sent: ReadonlyArray<RecordedSend<TEvent>>; |
| 49 | + /** Turn-start placeholder posts ("final" delivery): `final: false`, no `previousRef`. */ |
| 50 | + readonly acks: ReadonlyArray<RecordedSend<TEvent>>; |
| 51 | + /** Stream-mode intermediate edits: `mode: "stream"`, `final: false`, with `previousRef`. */ |
| 52 | + readonly edits: ReadonlyArray<RecordedSend<TEvent>>; |
| 53 | + /** Every reaction applied to the triggering message (lifecycle + `run().channel`). */ |
| 54 | + readonly reactionsApplied: ReadonlyArray<RecordedReaction<TEvent>>; |
| 55 | + /** Every HITL `finalizeInteraction()` call. */ |
| 56 | + readonly finalized: ReadonlyArray<RecordedFinalize<TEvent>>; |
| 57 | + /** The ref of the most recent `send()`, i.e. the current edit target. */ |
| 58 | + readonly lastRef: string | undefined; |
| 59 | + /** Text of the final reply (`final: true`), or `undefined` if none posted yet. */ |
| 60 | + finalText(): string | undefined; |
| 61 | +}; |
| 62 | + |
| 63 | +/** Options for {@link recordingChannelConnector}. */ |
| 64 | +export type RecordingChannelConnectorOptions<TEvent = TestChannelEvent> = { |
| 65 | + /** Connector id. Defaults to {@link DEFAULT_TEST_CONNECTOR_ID} so it lines up with `sendChannelEvent`. */ |
| 66 | + id?: string; |
| 67 | + /** `"final"` (default: ack then edit-to-answer) or `"stream"` (debounced live edits). */ |
| 68 | + delivery?: "final" | "stream"; |
| 69 | + /** Session key template. Unused in-run (server-side routing only); defaults to `"{body.threadId}"`. */ |
| 70 | + key?: string; |
| 71 | + /** Map the raw event to the turn's message. Defaults to reading `event.text`. */ |
| 72 | + inbound?: (event: TEvent) => ChannelMessageInput; |
| 73 | + /** |
| 74 | + * Placeholder posted at turn start ("final" delivery). Defaults to `{ text: "..." }`. |
| 75 | + * Pass `null` (or a function returning `null`) to post no ack, so the final reply |
| 76 | + * arrives as a fresh message instead of an edit. |
| 77 | + */ |
| 78 | + ack?: ChannelMessage | null | ((event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null); |
| 79 | + /** HITL: map a verified callback event to a tool resolution (null => treat as a normal message). */ |
| 80 | + onInteraction?: (event: TEvent) => ChannelInteractionResolution | null; |
| 81 | + /** HITL: map the pending tool call(s) to the controls posted in the thread. */ |
| 82 | + renderInteraction?: ( |
| 83 | + pending: ChannelPendingToolCall[], |
| 84 | + ctx: ChannelInteractionCtx<TEvent> |
| 85 | + ) => ChannelMessage | null; |
| 86 | + /** Lifecycle reaction choices (working/done/error). Requires nothing extra; `react` is always recorded. */ |
| 87 | + reactions?: ChannelReactions<TEvent>; |
| 88 | +}; |
| 89 | + |
| 90 | +/** |
| 91 | + * Create a {@link RecordingChannelConnector} for driving a `chat.agent` |
| 92 | + * channel turn offline. Pair it with `mockChatAgent(...).sendChannelEvent(...)`: |
| 93 | + * list the connector on the agent's `channels`, deliver an event, then assert |
| 94 | + * against `connector.sent` / `.acks` / `.edits` / `.finalText()`. |
| 95 | + * |
| 96 | + * @example |
| 97 | + * ```ts |
| 98 | + * const channel = recordingChannelConnector(); |
| 99 | + * const agent = chat.agent({ id: "support", channels: [channel], run: ... }); |
| 100 | + * const harness = mockChatAgent(agent); |
| 101 | + * await harness.sendChannelEvent({ event: { text: "hi", threadId: "t1" } }); |
| 102 | + * expect(channel.finalText()).toBe("hello"); |
| 103 | + * ``` |
| 104 | + */ |
| 105 | +export function recordingChannelConnector<TEvent = TestChannelEvent>( |
| 106 | + options: RecordingChannelConnectorOptions<TEvent> = {} |
| 107 | +): RecordingChannelConnector<TEvent> { |
| 108 | + const sent: RecordedSend<TEvent>[] = []; |
| 109 | + const reactionsApplied: RecordedReaction<TEvent>[] = []; |
| 110 | + const finalized: RecordedFinalize<TEvent>[] = []; |
| 111 | + let refCounter = 0; |
| 112 | + let lastRef: string | undefined; |
| 113 | + |
| 114 | + const inbound = options.inbound ?? ((event: TEvent) => (event as { text?: string })?.text ?? ""); |
| 115 | + |
| 116 | + const ackFn: (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null = |
| 117 | + options.ack === undefined |
| 118 | + ? () => ({ text: "..." }) |
| 119 | + : typeof options.ack === "function" |
| 120 | + ? (options.ack as (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null) |
| 121 | + : () => options.ack as ChannelMessage | null; |
| 122 | + |
| 123 | + const send = async (message: ChannelMessage, ctx: ChannelSendCtx<TEvent>) => { |
| 124 | + const ref = ctx.previousRef ?? `ref_${++refCounter}`; |
| 125 | + sent.push({ message, ctx, ref }); |
| 126 | + lastRef = ref; |
| 127 | + return { ref }; |
| 128 | + }; |
| 129 | + |
| 130 | + const react = async (reaction: ChannelReaction, ctx: { event: TEvent }) => { |
| 131 | + reactionsApplied.push({ reaction, event: ctx.event }); |
| 132 | + }; |
| 133 | + |
| 134 | + const finalizeInteraction = async (event: TEvent, resolution: ChannelInteractionResolution) => { |
| 135 | + finalized.push({ event, resolution }); |
| 136 | + }; |
| 137 | + |
| 138 | + const connector = chat.channels.custom({ |
| 139 | + id: options.id ?? DEFAULT_TEST_CONNECTOR_ID, |
| 140 | + source: webhooks.custom<TEvent>({ |
| 141 | + scheme: "shared-secret", |
| 142 | + placement: "header", |
| 143 | + fieldName: "x-test-signature", |
| 144 | + }), |
| 145 | + key: (options.key ?? "{body.threadId}") as never, |
| 146 | + inbound: inbound as (event: TEvent) => ChannelMessageInput, |
| 147 | + ack: ackFn as never, |
| 148 | + send: send as never, |
| 149 | + react: react as never, |
| 150 | + finalizeInteraction: finalizeInteraction as never, |
| 151 | + ...(options.onInteraction ? { onInteraction: options.onInteraction as never } : {}), |
| 152 | + ...(options.renderInteraction ? { renderInteraction: options.renderInteraction as never } : {}), |
| 153 | + ...(options.reactions ? { reactions: options.reactions as never } : {}), |
| 154 | + delivery: options.delivery ?? "final", |
| 155 | + }) as ChannelConnector<TEvent>; |
| 156 | + |
| 157 | + Object.defineProperties(connector, { |
| 158 | + sent: { get: () => sent, enumerable: true }, |
| 159 | + reactionsApplied: { get: () => reactionsApplied, enumerable: true }, |
| 160 | + finalized: { get: () => finalized, enumerable: true }, |
| 161 | + lastRef: { get: () => lastRef, enumerable: true }, |
| 162 | + acks: { |
| 163 | + get: () => sent.filter((s) => s.ctx.final === false && s.ctx.previousRef === undefined), |
| 164 | + enumerable: true, |
| 165 | + }, |
| 166 | + edits: { |
| 167 | + get: () => |
| 168 | + sent.filter( |
| 169 | + (s) => s.ctx.mode === "stream" && s.ctx.final === false && s.ctx.previousRef !== undefined |
| 170 | + ), |
| 171 | + enumerable: true, |
| 172 | + }, |
| 173 | + }); |
| 174 | + |
| 175 | + (connector as RecordingChannelConnector<TEvent>).finalText = () => { |
| 176 | + for (let i = sent.length - 1; i >= 0; i--) { |
| 177 | + if (sent[i]!.ctx.final) return sent[i]!.message.text; |
| 178 | + } |
| 179 | + return undefined; |
| 180 | + }; |
| 181 | + |
| 182 | + return connector as RecordingChannelConnector<TEvent>; |
| 183 | +} |
0 commit comments