@@ -28,6 +28,7 @@ import {
2828} from "./helpers/sessionStream" ;
2929import { runRealChatAgent } from "./helpers/agentHarness" ;
3030import {
31+ testApprovalChatAgent ,
3132 testChatAgent ,
3233 testChatModelLocal ,
3334 testHitlChatAgent ,
@@ -232,6 +233,43 @@ function joinChunks(parts: CollectedPart[]): string {
232233 . join ( "" ) ;
233234}
234235
236+ /** A model that returns `texts[i]` on its i-th `doStream` call. */
237+ function sequenceModel ( texts : string [ ] ) {
238+ let idx = 0 ;
239+ return new MockLanguageModelV3 ( {
240+ doStream : async ( ) => {
241+ const text = texts [ Math . min ( idx , texts . length - 1 ) ] ! ;
242+ idx ++ ;
243+ const chunks = [
244+ { type : "text-start" , id : "t1" } ,
245+ { type : "text-delta" , id : "t1" , delta : text } ,
246+ { type : "text-end" , id : "t1" } ,
247+ FINISH_STOP ,
248+ ] ;
249+ return { stream : simulateReadableStream ( { chunks : chunks as never } ) } ;
250+ } ,
251+ } ) ;
252+ }
253+
254+ function regenerateBody ( addressingKey : string ) {
255+ return JSON . stringify ( {
256+ kind : "message" ,
257+ payload : { chatId : addressingKey , trigger : "regenerate-message" , metadata : { } } ,
258+ } ) ;
259+ }
260+
261+ function findApprovalRequest (
262+ parts : CollectedPart [ ]
263+ ) : { approvalId : string ; toolCallId : string } | undefined {
264+ for ( const p of parts ) {
265+ const c = p . chunk as { type ?: string ; approvalId ?: string ; toolCallId ?: string } | null ;
266+ if ( c && c . type === "tool-approval-request" && c . approvalId && c . toolCallId ) {
267+ return { approvalId : c . approvalId , toolCallId : c . toolCallId } ;
268+ }
269+ }
270+ return undefined ;
271+ }
272+
235273describe ( "session agent e2e (real chat.agent loop)" , ( ) => {
236274 it ( "EA1: a real agent turn streams assistant text to .out" , async ( ) => {
237275 const { addressingKey, token, apiKey, baseUrl } = await setupSession ( ) ;
@@ -717,4 +755,214 @@ describe("session agent e2e (real chat.agent loop)", () => {
717755 await secondRun . close ( ) ;
718756 }
719757 } ) ;
758+
759+ it ( "EA10: regenerate re-runs the last user turn with a fresh model call" , async ( ) => {
760+ const { addressingKey, token, apiKey, baseUrl } = await setupSession ( testPlainChatAgent . id ) ;
761+ const agent = runRealChatAgent ( {
762+ agentId : testPlainChatAgent . id ,
763+ baseUrl,
764+ addressingKey,
765+ secretKey : apiKey ,
766+ model : sequenceModel ( [ "first-answer" , "regenerated-answer" ] ) ,
767+ modelLocal : testChatModelLocal ,
768+ } ) ;
769+
770+ try {
771+ await appendInput ( {
772+ baseUrl,
773+ addressingKey,
774+ token,
775+ partId : "u1" ,
776+ body : submitBody ( addressingKey , userMessage ( "explain it" , "u1" ) ) ,
777+ } ) ;
778+ const first = await collectSessionOut ( {
779+ baseUrl,
780+ addressingKey,
781+ token,
782+ until : ( p ) => p . some ( isTurnComplete ) ,
783+ maxMs : 30_000 ,
784+ } ) ;
785+ expect ( joinChunks ( first . parts ) ) . toContain ( "first-answer" ) ;
786+
787+ await appendInput ( {
788+ baseUrl,
789+ addressingKey,
790+ token,
791+ partId : "regen1" ,
792+ body : regenerateBody ( addressingKey ) ,
793+ } ) ;
794+ const second = await collectSessionOut ( {
795+ baseUrl,
796+ addressingKey,
797+ token,
798+ until : ( p ) => p . filter ( isTurnComplete ) . length >= 2 ,
799+ maxMs : 30_000 ,
800+ } ) ;
801+ expect ( joinChunks ( second . parts ) , "the regenerated turn produced a fresh answer" ) . toContain (
802+ "regenerated-answer"
803+ ) ;
804+ } finally {
805+ await agent . close ( ) ;
806+ }
807+ } ) ;
808+
809+ it ( "EA11: a needsApproval tool parks on an approval request and runs once approved" , async ( ) => {
810+ const { addressingKey, token, apiKey, baseUrl } = await setupSession ( testApprovalChatAgent . id ) ;
811+ const toolCallId = "tc_delete_e2e" ;
812+ const agent = runRealChatAgent ( {
813+ agentId : testApprovalChatAgent . id ,
814+ baseUrl,
815+ addressingKey,
816+ secretKey : apiKey ,
817+ model : toolCallThenText ( {
818+ toolName : "deleteResource" ,
819+ toolCallId,
820+ input : { resource : "widget-1" } ,
821+ finalText : "deleted widget-1" ,
822+ } ) ,
823+ modelLocal : testChatModelLocal ,
824+ } ) ;
825+
826+ try {
827+ await appendInput ( {
828+ baseUrl,
829+ addressingKey,
830+ token,
831+ partId : "u1" ,
832+ body : submitBody ( addressingKey , userMessage ( "delete widget-1" , "u1" ) ) ,
833+ } ) ;
834+ const turn1 = await collectSessionOut ( {
835+ baseUrl,
836+ addressingKey,
837+ token,
838+ until : ( p ) => p . some ( isTurnComplete ) ,
839+ maxMs : 30_000 ,
840+ } ) ;
841+ const approval = findApprovalRequest ( turn1 . parts ) ;
842+ expect ( approval , "turn 1 emits an approval request instead of executing" ) . toBeTruthy ( ) ;
843+ expect ( joinChunks ( turn1 . parts ) , "the tool did not execute before approval" ) . not . toContain (
844+ "deleted widget-1"
845+ ) ;
846+
847+ const answer = {
848+ id : "a-approval" ,
849+ role : "assistant" ,
850+ parts : [
851+ {
852+ type : "tool-deleteResource" ,
853+ toolCallId : approval ! . toolCallId ,
854+ state : "approval-responded" ,
855+ approval : { id : approval ! . approvalId , approved : true } ,
856+ } ,
857+ ] ,
858+ } ;
859+ await appendInput ( {
860+ baseUrl,
861+ addressingKey,
862+ token,
863+ partId : "u2" ,
864+ body : submitBody ( addressingKey , answer ) ,
865+ } ) ;
866+ const turn2 = await collectSessionOut ( {
867+ baseUrl,
868+ addressingKey,
869+ token,
870+ until : ( p ) => p . filter ( isTurnComplete ) . length >= 2 ,
871+ maxMs : 30_000 ,
872+ } ) ;
873+ expect (
874+ joinChunks ( turn2 . parts ) ,
875+ "after approval the tool runs and the agent answers"
876+ ) . toContain ( "deleted widget-1" ) ;
877+ } finally {
878+ await agent . close ( ) ;
879+ }
880+ } ) ;
881+
882+ it ( "EA12: two chats stay isolated - neither run bleeds into the other's .out" , async ( ) => {
883+ const chatA = await setupSession ( testPlainChatAgent . id ) ;
884+ const chatB = await setupSession ( testPlainChatAgent . id ) ;
885+
886+ const agentA = runRealChatAgent ( {
887+ agentId : testPlainChatAgent . id ,
888+ baseUrl : chatA . baseUrl ,
889+ addressingKey : chatA . addressingKey ,
890+ secretKey : chatA . apiKey ,
891+ model : textModel ( "answer-for-A" ) ,
892+ modelLocal : testChatModelLocal ,
893+ } ) ;
894+ try {
895+ await appendInput ( {
896+ baseUrl : chatA . baseUrl ,
897+ addressingKey : chatA . addressingKey ,
898+ token : chatA . token ,
899+ partId : "a1" ,
900+ body : submitBody ( chatA . addressingKey , userMessage ( "hi from A" , "a1" ) ) ,
901+ } ) ;
902+ await collectSessionOut ( {
903+ baseUrl : chatA . baseUrl ,
904+ addressingKey : chatA . addressingKey ,
905+ token : chatA . token ,
906+ until : ( p ) => p . some ( isTurnComplete ) ,
907+ maxMs : 30_000 ,
908+ } ) ;
909+ } finally {
910+ await agentA . close ( ) ;
911+ }
912+
913+ const agentB = runRealChatAgent ( {
914+ agentId : testPlainChatAgent . id ,
915+ baseUrl : chatB . baseUrl ,
916+ addressingKey : chatB . addressingKey ,
917+ secretKey : chatB . apiKey ,
918+ model : textModel ( "answer-for-B" ) ,
919+ modelLocal : testChatModelLocal ,
920+ } ) ;
921+ try {
922+ await appendInput ( {
923+ baseUrl : chatB . baseUrl ,
924+ addressingKey : chatB . addressingKey ,
925+ token : chatB . token ,
926+ partId : "b1" ,
927+ body : submitBody ( chatB . addressingKey , userMessage ( "hi from B" , "b1" ) ) ,
928+ } ) ;
929+ await collectSessionOut ( {
930+ baseUrl : chatB . baseUrl ,
931+ addressingKey : chatB . addressingKey ,
932+ token : chatB . token ,
933+ until : ( p ) => p . some ( isTurnComplete ) ,
934+ maxMs : 30_000 ,
935+ } ) ;
936+ } finally {
937+ await agentB . close ( ) ;
938+ }
939+
940+ const outA = joinChunks (
941+ (
942+ await collectSessionOut ( {
943+ baseUrl : chatA . baseUrl ,
944+ addressingKey : chatA . addressingKey ,
945+ token : chatA . token ,
946+ until : ( p ) => p . some ( isTurnComplete ) ,
947+ maxMs : 15_000 ,
948+ } )
949+ ) . parts
950+ ) ;
951+ const outB = joinChunks (
952+ (
953+ await collectSessionOut ( {
954+ baseUrl : chatB . baseUrl ,
955+ addressingKey : chatB . addressingKey ,
956+ token : chatB . token ,
957+ until : ( p ) => p . some ( isTurnComplete ) ,
958+ maxMs : 15_000 ,
959+ } )
960+ ) . parts
961+ ) ;
962+
963+ expect ( outA ) . toContain ( "answer-for-A" ) ;
964+ expect ( outA , "A's stream never sees B's output" ) . not . toContain ( "answer-for-B" ) ;
965+ expect ( outB ) . toContain ( "answer-for-B" ) ;
966+ expect ( outB , "B's stream never sees A's output" ) . not . toContain ( "answer-for-A" ) ;
967+ } ) ;
720968} ) ;
0 commit comments