@@ -164,6 +164,120 @@ describe("agentLoop streaming updates", () => {
164164 expect ( update . assistantMessageEvent ) . not . toHaveProperty ( "partial" ) ;
165165 }
166166 } ) ;
167+
168+ it ( "does not execute tool calls from a max-token-truncated assistant turn" , async ( ) => {
169+ const execute = vi . fn (
170+ async ( ) : Promise < AgentToolResult < unknown > > => ( {
171+ content : [ { type : "text" , text : "should not run" } ] ,
172+ details : { } ,
173+ } ) ,
174+ ) ;
175+ const contexts : Context [ ] = [ ] ;
176+ let streamCalls = 0 ;
177+ const streamFn : StreamFn = async ( _model , context ) => {
178+ contexts . push ( context ) ;
179+ streamCalls += 1 ;
180+ const stream = createAssistantMessageEventStream ( ) ;
181+ if ( streamCalls > 1 ) {
182+ const message : AssistantMessage = {
183+ role : "assistant" ,
184+ content : [ { type : "text" , text : "continued" } ] ,
185+ api : model . api ,
186+ provider : model . provider ,
187+ model : model . id ,
188+ usage : TEST_USAGE ,
189+ stopReason : "stop" ,
190+ timestamp : 2 ,
191+ } ;
192+ queueMicrotask ( ( ) => {
193+ stream . push ( { type : "done" , reason : "stop" , message } ) ;
194+ } ) ;
195+ return stream ;
196+ }
197+ const toolCall = {
198+ type : "toolCall" as const ,
199+ id : "call-truncated-spawn" ,
200+ name : "sessions_spawn" ,
201+ arguments : { } ,
202+ } ;
203+ const message : AssistantMessage = {
204+ role : "assistant" ,
205+ content : [ { type : "text" , text : "spawning" } , toolCall ] ,
206+ api : model . api ,
207+ provider : model . provider ,
208+ model : model . id ,
209+ usage : TEST_USAGE ,
210+ stopReason : "length" ,
211+ timestamp : 1 ,
212+ } ;
213+
214+ queueMicrotask ( ( ) => {
215+ stream . push ( { type : "start" , partial : { ...message , content : [ ] } } ) ;
216+ stream . push ( { type : "toolcall_start" , contentIndex : 1 , partial : message } ) ;
217+ stream . push ( {
218+ type : "toolcall_end" ,
219+ contentIndex : 1 ,
220+ toolCall,
221+ partial : message ,
222+ } ) ;
223+ stream . push ( { type : "done" , reason : "length" , message } ) ;
224+ } ) ;
225+
226+ return stream ;
227+ } ;
228+
229+ const stream = agentLoop (
230+ [ { role : "user" , content : "spawn specialists" , timestamp : 1 } ] ,
231+ {
232+ systemPrompt : "" ,
233+ messages : [ ] ,
234+ tools : [
235+ {
236+ name : "sessions_spawn" ,
237+ label : "sessions_spawn" ,
238+ description : "Spawn a child session" ,
239+ parameters : Type . Object ( { } , { additionalProperties : false } ) ,
240+ execute,
241+ } ,
242+ ] ,
243+ } ,
244+ {
245+ ...config ,
246+ getFollowUpMessages : async ( ) =>
247+ streamCalls === 1 ? [ { role : "user" , content : "continue" , timestamp : 2 } ] : [ ] ,
248+ } ,
249+ undefined ,
250+ streamFn ,
251+ ) ;
252+
253+ const events = await collectEvents ( stream ) ;
254+ const messages = await stream . result ( ) ;
255+ const truncatedMessageEnd = events . find (
256+ ( event ) : event is Extract < AgentEvent , { type : "message_end" } > =>
257+ event . type === "message_end" &&
258+ event . message . role === "assistant" &&
259+ event . message . stopReason === "length" ,
260+ ) ;
261+
262+ expect ( execute ) . not . toHaveBeenCalled ( ) ;
263+ expect ( events . some ( ( event ) => event . type === "tool_execution_start" ) ) . toBe ( false ) ;
264+ expect ( messages . map ( ( message ) => message . role ) ) . toEqual ( [
265+ "user" ,
266+ "assistant" ,
267+ "user" ,
268+ "assistant" ,
269+ ] ) ;
270+ expect ( messages [ 1 ] ) . toMatchObject ( { role : "assistant" , stopReason : "length" } ) ;
271+ expect ( messages [ 1 ] ) . not . toMatchObject ( {
272+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
273+ } ) ;
274+ expect ( truncatedMessageEnd ?. message ) . not . toMatchObject ( {
275+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
276+ } ) ;
277+ expect ( contexts [ 1 ] ?. messages [ 1 ] ) . not . toMatchObject ( {
278+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
279+ } ) ;
280+ } ) ;
167281} ) ;
168282
169283describe ( "runAgentLoop deferred tool hydration" , ( ) => {
@@ -936,7 +1050,7 @@ describe("agentLoop thinking state", () => {
9361050 totalTokens : 0 ,
9371051 cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
9381052 } ,
939- stopReason : "stop" ,
1053+ stopReason : content . some ( ( item ) => item . type === "toolCall" ) ? "toolUse" : "stop" ,
9401054 timestamp : 1 ,
9411055 } ;
9421056 }
@@ -968,7 +1082,7 @@ describe("agentLoop thinking state", () => {
9681082 : [ { type : "text" , text : "done" } ] ;
9691083 stream . push ( {
9701084 type : "done" ,
971- reason : "stop" ,
1085+ reason : content . some ( ( item ) => item . type === "toolCall" ) ? "toolUse" : "stop" ,
9721086 message : makeAssistantMessage ( activeModel , content ) ,
9731087 } ) ;
9741088 stream . end ( ) ;
0 commit comments