Skip to content

Commit 2b97fbb

Browse files
committed
test(sdk): channel connector support in the chat.agent test harness
Add channel-event delivery to the mockChatAgent harness (sendChannelEvent) and a recordingChannelConnector helper to @trigger.dev/sdk/ai/test, so a chat.agent's channel round-trip (inbound mapping, ack placeholder, egress send, edit-in-place, and lifecycle reactions) can be driven and asserted entirely offline.
1 parent 73e592b commit 2b97fbb

4 files changed

Lines changed: 418 additions & 0 deletions

File tree

packages/trigger-sdk/src/v3/test/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@ import "./setup-catalog.js";
88

99
export {
1010
mockChatAgent,
11+
DEFAULT_TEST_CONNECTOR_ID,
1112
type MockChatAgentOptions,
1213
type MockChatAgentHarness,
1314
type MockChatAgentTurn,
1415
} from "./mock-chat-agent.js";
1516

17+
export {
18+
recordingChannelConnector,
19+
type RecordingChannelConnector,
20+
type RecordingChannelConnectorOptions,
21+
type RecordedSend,
22+
type RecordedReaction,
23+
type RecordedFinalize,
24+
type TestChannelEvent,
25+
} from "./mock-channel-connector.js";
26+
1627
// Re-export the lower-level task context harness so consumers can build
1728
// their own test helpers without adding a separate `@trigger.dev/core`
1829
// dependency to their reference projects.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
}

packages/trigger-sdk/src/v3/test/mock-chat-agent.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ type ChatWirePayload = {
3636
messageId?: string;
3737
metadata?: unknown;
3838
action?: unknown;
39+
/**
40+
* A channel-delivered turn (Slack or any `chat.channels.*` connector). Carries
41+
* the raw verified provider event; the run resolves the connector by
42+
* `connectorId` from `chat.agent({ channels })` and applies its `inbound()`
43+
* mapper to produce the turn's message. Present instead of `message`.
44+
*/
45+
channelEvent?: {
46+
connectorId: string;
47+
event: unknown;
48+
source: string;
49+
headers: Record<string, string>;
50+
deliveryId: string;
51+
};
3952
continuation?: boolean;
4053
previousRunId?: string;
4154
idleTimeoutInSeconds?: number;
@@ -187,6 +200,25 @@ export type MockChatAgentHarness = {
187200
/** Send a custom action and wait for the next turn-complete. */
188201
sendAction(action: unknown): Promise<MockChatAgentTurn>;
189202

203+
/**
204+
* Deliver a verified channel event (Slack or any `chat.channels.*`
205+
* connector) and wait for the next turn-complete. Mirrors what the hosted
206+
* webhook ingress appends to `session.in`: a `submit-message` wire payload
207+
* carrying `channelEvent` instead of `message`. The run resolves the
208+
* connector by `connectorId`, maps the event with its `inbound()`, runs the
209+
* turn, and posts the reply back through the connector's `send()`.
210+
*
211+
* `connectorId` defaults to the `id` of the connector on the agent when the
212+
* agent lists exactly one; pass it explicitly for multi-connector agents.
213+
*/
214+
sendChannelEvent(args: {
215+
event: unknown;
216+
connectorId?: string;
217+
source?: string;
218+
headers?: Record<string, string>;
219+
deliveryId?: string;
220+
}): Promise<MockChatAgentTurn>;
221+
190222
/** Fire a stop signal. Does not wait for the turn — the task keeps running. */
191223
sendStop(message?: string): Promise<void>;
192224

@@ -278,6 +310,28 @@ export type MockChatAgentHarness = {
278310
readonly allRawChunks: unknown[];
279311
};
280312

313+
/**
314+
* Default `connectorId` used by {@link MockChatAgentHarness.sendChannelEvent}
315+
* when the caller omits it. Matches the default `id` of
316+
* {@link recordingChannelConnector}, so a single-connector agent needs no
317+
* explicit id on either side.
318+
*/
319+
export const DEFAULT_TEST_CONNECTOR_ID = "test-channel";
320+
321+
/**
322+
* Wait for a channel turn's post-completion egress to land. The run loop
323+
* writes the `trigger:turn-complete` chunk (which unblocks the harness's
324+
* turn-complete latch) BEFORE it awaits the connector's final `send()` and
325+
* its done/error reactions. Draining a handful of macrotasks lets those
326+
* awaited-but-immediate calls settle so `sendChannelEvent` callers can assert
327+
* against the connector's recorded sends/reactions deterministically.
328+
*/
329+
async function settlePostTurnChannelEgress(): Promise<void> {
330+
for (let i = 0; i < 5; i++) {
331+
await new Promise((resolve) => setTimeout(resolve, 0));
332+
}
333+
}
334+
281335
const CONTROL_CHUNK_TYPES = new Set(["trigger:turn-complete", "trigger:upgrade-required"]);
282336

283337
function isControlChunk(chunk: unknown): boolean {
@@ -366,6 +420,8 @@ export function mockChatAgent(
366420
let closeSessionInput: ((sessionId: string) => void) | undefined;
367421
let runSignal!: AbortController;
368422

423+
let channelDeliveryCounter = 0;
424+
369425
// A latch that resolves every time `trigger:turn-complete` appears on the chat stream.
370426
// We use a shared pending promise and replace it after each completion.
371427
let turnCompleteResolvers: Array<() => void> = [];
@@ -617,6 +673,24 @@ export function mockChatAgent(
617673
});
618674
},
619675

676+
async sendChannelEvent(args) {
677+
const deliveryId = args.deliveryId ?? `dlv_${++channelDeliveryCounter}`;
678+
const turn = await sendPayloadAndWait({
679+
chatId,
680+
trigger: "submit-message",
681+
channelEvent: {
682+
connectorId: args.connectorId ?? DEFAULT_TEST_CONNECTOR_ID,
683+
event: args.event,
684+
source: args.source ?? "custom",
685+
headers: args.headers ?? {},
686+
deliveryId,
687+
},
688+
metadata: clientData,
689+
});
690+
await settlePostTurnChannelEgress();
691+
return turn;
692+
},
693+
620694
async sendStop(message) {
621695
await harnessReady;
622696
await sendSessionInput(sessionId, { kind: "stop", message });

0 commit comments

Comments
 (0)