Skip to content

Commit 8f0664d

Browse files
committed
test(sdk): channel interaction coverage in the chat.agent test harness
Add a fire-and-forget deliverChannelEvent to the harness and loop-level tests for the channel interaction paths: a resolved interaction callback resumes the pending tool and finalizes the controls, and a stale callback that matches no pending tool is dropped without running a turn or posting anything.
1 parent ccf96a8 commit 8f0664d

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,20 @@ export type MockChatAgentHarness = {
219219
deliveryId?: string;
220220
}): Promise<MockChatAgentTurn>;
221221

222+
/**
223+
* Deliver a channel event without waiting for a turn. Mirrors an event that
224+
* the run may not turn into a turn: a filtered or deduped delivery, or a
225+
* stale interaction callback that is dropped (matches no pending tool call).
226+
* Resolves once the run has had a chance to process and possibly drop it.
227+
*/
228+
deliverChannelEvent(args: {
229+
event: unknown;
230+
connectorId?: string;
231+
source?: string;
232+
headers?: Record<string, string>;
233+
deliveryId?: string;
234+
}): Promise<void>;
235+
222236
/** Fire a stop signal. Does not wait for the turn — the task keeps running. */
223237
sendStop(message?: string): Promise<void>;
224238

@@ -691,6 +705,27 @@ export function mockChatAgent(
691705
return turn;
692706
},
693707

708+
async deliverChannelEvent(args) {
709+
await harnessReady;
710+
const deliveryId = args.deliveryId ?? `dlv_${++channelDeliveryCounter}`;
711+
await sendSessionInput(sessionId, {
712+
kind: "message",
713+
payload: {
714+
chatId,
715+
trigger: "submit-message",
716+
channelEvent: {
717+
connectorId: args.connectorId ?? DEFAULT_TEST_CONNECTOR_ID,
718+
event: args.event,
719+
source: args.source ?? "custom",
720+
headers: args.headers ?? {},
721+
deliveryId,
722+
},
723+
metadata: clientData,
724+
},
725+
});
726+
await settlePostTurnChannelEgress();
727+
},
728+
694729
async sendStop(message) {
695730
await harnessReady;
696731
await sendSessionInput(sessionId, { kind: "stop", message });

packages/trigger-sdk/test/chatChannels.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import {
66
__makeChannelStreamEditorForTests,
77
__makeChannelStreamTapForTests,
88
} from "../src/v3/ai.js";
9-
import { simulateReadableStream, streamText } from "ai";
9+
import { simulateReadableStream, streamText, tool } from "ai";
1010
import { MockLanguageModelV3 } from "ai/test";
1111
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
12+
import { z } from "zod";
1213

1314
function textStream(text: string) {
1415
const chunks: LanguageModelV3StreamPart[] = [
@@ -178,6 +179,109 @@ describe("chat.agent channels", () => {
178179
});
179180
});
180181

182+
describe("chat.agent channel interactions", () => {
183+
function toolCallStream(toolCallId: string, toolName: string, input: unknown) {
184+
return simulateReadableStream({
185+
chunks: [
186+
{ type: "tool-input-start", id: toolCallId, toolName },
187+
{ type: "tool-input-delta", id: toolCallId, delta: JSON.stringify(input) },
188+
{ type: "tool-input-end", id: toolCallId },
189+
{ type: "tool-call", toolCallId, toolName, input: JSON.stringify(input) },
190+
{
191+
type: "finish",
192+
finishReason: { unified: "tool-calls", raw: "tool_calls" },
193+
usage: {
194+
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
195+
outputTokens: { total: 10, text: 0, reasoning: undefined },
196+
},
197+
},
198+
] as LanguageModelV3StreamPart[],
199+
});
200+
}
201+
202+
it("resumes a pending tool from an interaction callback and finalizes the controls", async () => {
203+
const TC = "tc_approve_1";
204+
const requestApproval = tool({
205+
description: "Request human approval before acting.",
206+
inputSchema: z.object({ action: z.string() }),
207+
});
208+
209+
let call = 0;
210+
const model = new MockLanguageModelV3({
211+
doStream: async () => ({
212+
stream:
213+
call++ === 0
214+
? toolCallStream(TC, "requestApproval", { action: "refund" })
215+
: textStream("refund issued"),
216+
}),
217+
});
218+
219+
const channel = recordingChannelConnector<{ text?: string; callback?: boolean }>({
220+
renderInteraction: (pending) => ({
221+
text: `Approve ${(pending[0]!.input as { action: string }).action}?`,
222+
}),
223+
onInteraction: (event) =>
224+
event?.callback ? { toolCallId: TC, output: { approved: true } } : null,
225+
});
226+
227+
const agent = chat.agent({
228+
id: "chatChannels.hitl-resolve",
229+
channels: [channel],
230+
run: async ({ messages, signal }) =>
231+
streamText({ model, messages, tools: { requestApproval }, abortSignal: signal }),
232+
});
233+
234+
const harness = mockChatAgent(agent, { chatId: "chan-hitl-1" });
235+
try {
236+
await harness.sendChannelEvent({ event: { text: "please refund", threadId: "chan-hitl-1" } });
237+
expect(channel.finalText()).toBe("Approve refund?");
238+
expect(channel.finalized).toHaveLength(0);
239+
240+
await harness.sendChannelEvent({ event: { callback: true } });
241+
expect(channel.finalized).toHaveLength(1);
242+
expect(channel.finalText()).toBe("refund issued");
243+
} finally {
244+
await harness.close();
245+
}
246+
});
247+
248+
it("drops a stale interaction callback and runs no turn", async () => {
249+
let modelCalls = 0;
250+
const model = new MockLanguageModelV3({
251+
doStream: async () => {
252+
modelCalls += 1;
253+
return { stream: textStream("should not run") };
254+
},
255+
});
256+
257+
const interactionEvents: unknown[] = [];
258+
const channel = recordingChannelConnector<{ text?: string; callback?: boolean }>({
259+
onInteraction: (event) => {
260+
interactionEvents.push(event);
261+
return event?.callback ? { toolCallId: "does-not-exist", output: {} } : null;
262+
},
263+
});
264+
265+
const agent = chat.agent({
266+
id: "chatChannels.hitl-stale",
267+
channels: [channel],
268+
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
269+
});
270+
271+
const harness = mockChatAgent(agent, { chatId: "chan-hitl-2" });
272+
try {
273+
await harness.deliverChannelEvent({ event: { callback: true } });
274+
275+
expect(interactionEvents).toHaveLength(1);
276+
expect(modelCalls).toBe(0);
277+
expect(channel.sent).toHaveLength(0);
278+
expect(channel.finalized).toHaveLength(0);
279+
} finally {
280+
await harness.close();
281+
}
282+
});
283+
});
284+
181285
describe("makeChannelStreamEditor", () => {
182286
function streamConnector(send: (message: { text: string }) => Promise<{ ref?: string }>) {
183287
return { delivery: "stream" as const, send } as never;

0 commit comments

Comments
 (0)