@@ -164,6 +164,126 @@ 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+ const replayedTruncatedMessage = contexts [ 1 ] ?. messages [ 1 ] ;
262+
263+ if ( ! truncatedMessageEnd || ! replayedTruncatedMessage ) {
264+ throw new Error ( "expected the truncated assistant message to be emitted and replayed" ) ;
265+ }
266+
267+ expect ( execute ) . not . toHaveBeenCalled ( ) ;
268+ expect ( events . some ( ( event ) => event . type === "tool_execution_start" ) ) . toBe ( false ) ;
269+ expect ( messages . map ( ( message ) => message . role ) ) . toEqual ( [
270+ "user" ,
271+ "assistant" ,
272+ "user" ,
273+ "assistant" ,
274+ ] ) ;
275+ expect ( messages [ 1 ] ) . toMatchObject ( { role : "assistant" , stopReason : "length" } ) ;
276+ expect ( messages [ 1 ] ) . not . toMatchObject ( {
277+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
278+ } ) ;
279+ expect ( truncatedMessageEnd . message ) . not . toMatchObject ( {
280+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
281+ } ) ;
282+ expect ( replayedTruncatedMessage ) . toMatchObject ( { role : "assistant" , stopReason : "length" } ) ;
283+ expect ( replayedTruncatedMessage ) . not . toMatchObject ( {
284+ content : expect . arrayContaining ( [ expect . objectContaining ( { type : "toolCall" } ) ] ) ,
285+ } ) ;
286+ } ) ;
167287} ) ;
168288
169289describe ( "runAgentLoop deferred tool hydration" , ( ) => {
@@ -936,7 +1056,7 @@ describe("agentLoop thinking state", () => {
9361056 totalTokens : 0 ,
9371057 cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
9381058 } ,
939- stopReason : "stop" ,
1059+ stopReason : content . some ( ( item ) => item . type === "toolCall" ) ? "toolUse" : "stop" ,
9401060 timestamp : 1 ,
9411061 } ;
9421062 }
@@ -968,7 +1088,7 @@ describe("agentLoop thinking state", () => {
9681088 : [ { type : "text" , text : "done" } ] ;
9691089 stream . push ( {
9701090 type : "done" ,
971- reason : "stop" ,
1091+ reason : content . some ( ( item ) => item . type === "toolCall" ) ? "toolUse" : "stop" ,
9721092 message : makeAssistantMessage ( activeModel , content ) ,
9731093 } ) ;
9741094 stream . end ( ) ;
0 commit comments