@@ -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" ;
1010import { MockLanguageModelV3 } from "ai/test" ;
1111import type { LanguageModelV3StreamPart } from "@ai-sdk/provider" ;
12+ import { z } from "zod" ;
1213
1314function 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+
181285describe ( "makeChannelStreamEditor" , ( ) => {
182286 function streamConnector ( send : ( message : { text : string } ) => Promise < { ref ?: string } > ) {
183287 return { delivery : "stream" as const , send } as never ;
0 commit comments